Skip to content

Commit b8f1511

Browse files
joeldicksondicko2
andauthored
Refactor comment management in VCS adapters (#166)
- Update ESLint setting to use explicit fixing. - Modify VCSAdapter interface to require current comments for removal. - Enhance GitHubAdapter and GitLabAdapter to manage existing comments and discussions, preventing duplicates during creation. - Implement selective deletion of outdated comments based on current issues in both adapters. - Add new methods in GitLabMRService for handling discussions. This improves comment handling and ensures that only relevant comments are maintained. Co-authored-by: jdickson <joel.dickson@agoda.com>
1 parent f429092 commit b8f1511

9 files changed

Lines changed: 200 additions & 32 deletions

File tree

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"editor.codeActionsOnSave": {
3-
"source.fixAll.eslint": true
3+
"source.fixAll.eslint": "explicit"
44
},
55
"typescript.preferences.importModuleSpecifier": "relative"
66
}

src/Provider/@interfaces/VCSAdapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Diff } from '../../Git/@types/PatchTypes';
22
import { IAnalyzerBot } from '../../AnalyzerBot/@interfaces/IAnalyzerBot';
3+
import { Comment } from '../../AnalyzerBot/@types/CommentTypes';
34

45
export interface VCSAdapter {
56
init(): Promise<void>;
@@ -14,5 +15,5 @@ export interface VCSAdapter {
1415
line: number,
1516
nLines?: number,
1617
): Promise<void>;
17-
removeExistingComments(): Promise<void>;
18+
removeExistingComments(currentComments: Comment[]): Promise<void>;
1819
}

src/Provider/CommonVCS/VCSEngine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export class VCSEngine implements VCS {
1919
await this.setup(items);
2020

2121
if (this.config.removeOldComment) {
22-
await this.adapter.removeExistingComments();
22+
await this.adapter.removeExistingComments(this.analyzerBot.comments);
2323
}
2424

2525
if (!this.config.silent) {

src/Provider/GitHub/GitHub.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@ const mockedUserId = 1234;
2222
const mockedSha = '123456';
2323

2424
const mockedReviews = [
25-
{ id: mockedReviewId, user: { id: mockedUserId } },
25+
{
26+
id: mockedReviewId,
27+
user: { id: mockedUserId },
28+
body: 'test review comment',
29+
path: 'test.js',
30+
line: 1,
31+
},
2632
] as PullsListReviewCommentsResponseData;
2733

2834
const mockedComments = [
29-
{ id: mockedCommentId, user: { id: mockedUserId } },
35+
{ id: mockedCommentId, user: { id: mockedUserId }, body: 'test comment' },
3036
] as IssuesListCommentsResponseData;
3137

3238
class PrServiceMock implements IGitHubPRService {

src/Provider/GitHub/GitHubAdapter.ts

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,52 @@ import { Diff } from '../../Git/@types/PatchTypes';
44
import { CommitStatus } from './CommitStatus';
55
import { Log } from '../../Logger';
66
import { IAnalyzerBot } from '../../AnalyzerBot/@interfaces/IAnalyzerBot';
7+
import { Comment } from '../../AnalyzerBot/@types/CommentTypes';
78

89
export class GitHubAdapter implements VCSAdapter {
910
private commitId: string;
11+
private existingComments: Set<string> = new Set();
12+
private existingCommentIds: Map<string, number> = new Map();
13+
private existingReviewIds: Map<string, number> = new Map();
14+
1015
constructor(private readonly prService: IGitHubPRService) {}
1116

1217
async init(): Promise<void> {
1318
this.commitId = await this.prService.getLatestCommitSha();
19+
20+
// Store existing comments for duplicate detection
21+
const [userId, comments, reviews] = await Promise.all([
22+
this.prService.getCurrentUserId(),
23+
this.prService.listAllComments(),
24+
this.prService.listAllReviewComments(),
25+
]);
26+
27+
// Store regular comments
28+
comments
29+
.filter((comment) => comment.user?.id === userId)
30+
.forEach((comment) => {
31+
if (comment.body) {
32+
this.existingComments.add(comment.body);
33+
this.existingCommentIds.set(comment.body, comment.id);
34+
}
35+
});
36+
37+
// Store review comments
38+
reviews
39+
.filter((review) => review.user?.id === userId)
40+
.forEach((review) => {
41+
const key = this.generateCommentKey(
42+
review.path || '',
43+
review.line || 0,
44+
review.body,
45+
);
46+
this.existingComments.add(key);
47+
this.existingReviewIds.set(key, review.id);
48+
});
49+
}
50+
51+
private generateCommentKey(file: string, line: number, text: string): string {
52+
return `${file}:${line}:${text}`;
1453
}
1554

1655
async wrapUp(analyzer: IAnalyzerBot): Promise<boolean> {
@@ -29,8 +68,14 @@ export class GitHubAdapter implements VCSAdapter {
2968
diff(): Promise<Diff[]> {
3069
return this.prService.diff();
3170
}
71+
3272
createComment(comment: string): Promise<void> {
33-
return this.prService.createComment(comment);
73+
if (!this.existingComments.has(comment)) {
74+
return this.prService.createComment(comment);
75+
} else {
76+
Log.debug('Skipped creating duplicate comment');
77+
return Promise.resolve();
78+
}
3479
}
3580

3681
createReviewComment(
@@ -39,27 +84,51 @@ export class GitHubAdapter implements VCSAdapter {
3984
line: number,
4085
nLines: number,
4186
): Promise<void> {
42-
return this.prService.createReviewComment(this.commitId, text, file, line, nLines);
87+
const commentKey = this.generateCommentKey(file, line, text);
88+
89+
if (!this.existingComments.has(commentKey)) {
90+
return this.prService.createReviewComment(this.commitId, text, file, line, nLines);
91+
} else {
92+
Log.debug('Skipped creating duplicate review comment');
93+
return Promise.resolve();
94+
}
4395
}
4496

45-
async removeExistingComments(): Promise<void> {
46-
const [userId, comments, reviews] = await Promise.all([
47-
this.prService.getCurrentUserId(),
48-
this.prService.listAllComments(),
49-
this.prService.listAllReviewComments(),
50-
]);
51-
Log.debug('Get existing CodeCoach comments completed');
97+
async removeExistingComments(currentComments: Comment[]): Promise<void> {
98+
// Create a set of current issue keys
99+
const currentIssueKeys = new Set<string>();
100+
currentComments.forEach((comment) => {
101+
const key = this.generateCommentKey(comment.file, comment.line, comment.text);
102+
currentIssueKeys.add(key);
103+
});
52104

53-
const deleteComments = comments
54-
.filter((comment) => comment.user?.id === userId)
55-
.map((comment) => this.prService.deleteComment(comment.id));
105+
// Delete comments that are no longer relevant
106+
const commentsToDelete: Promise<void>[] = [];
56107

57-
const deleteReviews = reviews
58-
.filter((review) => review.user?.id === userId)
59-
.map((review) => this.prService.deleteReviewComment(review.id));
108+
// Check regular comments
109+
this.existingCommentIds.forEach((commentId, commentText) => {
110+
if (!currentIssueKeys.has(commentText)) {
111+
commentsToDelete.push(this.prService.deleteComment(commentId));
112+
this.existingComments.delete(commentText);
113+
this.existingCommentIds.delete(commentText);
114+
}
115+
});
116+
117+
// Check review comments
118+
this.existingReviewIds.forEach((reviewId, commentKey) => {
119+
if (!currentIssueKeys.has(commentKey)) {
120+
commentsToDelete.push(this.prService.deleteReviewComment(reviewId));
121+
this.existingComments.delete(commentKey);
122+
this.existingReviewIds.delete(commentKey);
123+
}
124+
});
60125

61-
await Promise.all([...deleteComments, ...deleteReviews]);
62-
Log.debug('Delete CodeCoach comments completed');
126+
if (commentsToDelete.length > 0) {
127+
await Promise.all(commentsToDelete);
128+
Log.debug(`Deleted ${commentsToDelete.length} outdated comments`);
129+
} else {
130+
Log.debug('No outdated comments to delete');
131+
}
63132
}
64133

65134
private async setCommitStatus(analyzer: IAnalyzerBot) {

src/Provider/GitLab/GitLab.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ class MrServiceMock implements IGitLabMRService {
4242
createMRDiscussion = jest.fn().mockResolvedValue(undefined);
4343
createNote = jest.fn().mockResolvedValue(undefined);
4444
deleteNote = jest.fn().mockResolvedValue(undefined);
45+
listAllDiscussions = jest.fn().mockResolvedValue([]);
46+
deleteDiscussion = jest.fn().mockResolvedValue(undefined);
4547
diff = jest.fn().mockResolvedValue([mockTouchDiff]);
4648
getCurrentUserId = jest.fn().mockResolvedValue(mockCurrentUserId);
4749
getLatestVersion = jest.fn().mockResolvedValue(mockVersion);
@@ -148,6 +150,19 @@ describe('VCS: GitLab', () => {
148150
expect(service.createNote).not.toHaveBeenCalled();
149151
});
150152

153+
it('should remove existing comments when removeOldComment is enabled', async () => {
154+
const service = new MrServiceMock();
155+
const configsWithRemoveOldComment = { ...configs, removeOldComment: true };
156+
const gitLab = createGitLab(service, configsWithRemoveOldComment);
157+
158+
await gitLab.report([touchFileWarning]);
159+
160+
// With the new approach, we use selective deletion based on current issues
161+
expect(service.listAllNotes).toHaveBeenCalled();
162+
expect(service.listAllDiscussions).toHaveBeenCalled();
163+
// deleteNote and deleteDiscussion will be called for outdated comments
164+
});
165+
151166
describe('when failOnWarnings is true', () => {
152167
const warningConfigs = { ...configs, failOnWarnings: true };
153168

src/Provider/GitLab/GitLabAdapter.ts

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,60 @@ import { Log } from '../../Logger';
44
import { IGitLabMRService } from './IGitLabMRService';
55
import { MergeRequestDiffVersionsSchema, MergeRequestNoteSchema } from '@gitbeaker/core';
66
import { IAnalyzerBot } from '../../AnalyzerBot/@interfaces/IAnalyzerBot';
7+
import { Comment } from '../../AnalyzerBot/@types/CommentTypes';
78

89
export class GitLabAdapter implements VCSAdapter {
910
private latestMrVersion: MergeRequestDiffVersionsSchema;
1011
private existingComments: Set<string> = new Set();
12+
private existingCommentIds: Map<string, number> = new Map();
13+
private existingDiscussionIds: Map<string, string> = new Map();
1114

1215
constructor(private readonly mrService: IGitLabMRService) {}
1316

1417
async init(): Promise<void> {
15-
const [latestVersion, userId, notes] = await Promise.all([
18+
const [latestVersion, userId, notes, discussions] = await Promise.all([
1619
this.mrService.getLatestVersion(),
1720
this.mrService.getCurrentUserId(),
1821
this.mrService.listAllNotes(),
22+
this.mrService.listAllDiscussions(),
1923
]);
2024

2125
this.latestMrVersion = latestVersion;
2226

23-
// Store existing bot comments
27+
// Store existing bot comments and their IDs
2428
notes
2529
.filter(
2630
(note: { author: { id: any }; system: any }) =>
2731
note.author.id === userId && !note.system,
2832
)
29-
.forEach((note: { body: string }) => this.existingComments.add(note.body));
33+
.forEach((note: { id: number; body: string }) => {
34+
this.existingComments.add(note.body);
35+
this.existingCommentIds.set(note.body, note.id);
36+
});
37+
38+
// Store existing discussions and their IDs
39+
discussions
40+
.filter(
41+
(discussion: { notes: any[] }) =>
42+
discussion.notes &&
43+
discussion.notes.some(
44+
(note: { author: { id: any } }) => note.author.id === userId,
45+
),
46+
)
47+
.forEach((discussion: { id: string; notes: any[] }) => {
48+
discussion.notes
49+
.filter((note: { author: { id: any } }) => note.author.id === userId)
50+
.forEach((note: { body: string }) => {
51+
const commentKey = this.generateCommentKey('', 0, note.body);
52+
this.existingComments.add(commentKey);
53+
this.existingDiscussionIds.set(commentKey, discussion.id);
54+
});
55+
});
3056

3157
Log.debug(`Found ${this.existingComments.size} existing CodeCoach comments`);
3258
}
3359

34-
private generateCommentKey(
35-
file: string,
36-
line: number | undefined,
37-
text: string,
38-
): string {
60+
private generateCommentKey(file: string, line: number, text: string): string {
3961
return `${file}:${line}:${text}`;
4062
}
4163

@@ -82,7 +104,40 @@ export class GitLabAdapter implements VCSAdapter {
82104
return this.mrService.diff();
83105
}
84106

85-
async removeExistingComments(): Promise<void> {
86-
Log.debug('Skipping comment removal as we now handle duplicates on creation');
107+
async removeExistingComments(currentComments: Comment[]): Promise<void> {
108+
// Create a set of current issue keys
109+
const currentIssueKeys = new Set<string>();
110+
currentComments.forEach((comment) => {
111+
const key = this.generateCommentKey(comment.file, comment.line, comment.text);
112+
currentIssueKeys.add(key);
113+
});
114+
115+
// Delete comments that are no longer relevant
116+
const commentsToDelete: Promise<void>[] = [];
117+
118+
// Check regular comments
119+
this.existingCommentIds.forEach((commentId, commentText) => {
120+
if (!currentIssueKeys.has(commentText)) {
121+
commentsToDelete.push(this.mrService.deleteNote(commentId));
122+
this.existingComments.delete(commentText);
123+
this.existingCommentIds.delete(commentText);
124+
}
125+
});
126+
127+
// Check discussion comments
128+
this.existingDiscussionIds.forEach((discussionId, commentKey) => {
129+
if (!currentIssueKeys.has(commentKey)) {
130+
commentsToDelete.push(this.mrService.deleteDiscussion(discussionId));
131+
this.existingComments.delete(commentKey);
132+
this.existingDiscussionIds.delete(commentKey);
133+
}
134+
});
135+
136+
if (commentsToDelete.length > 0) {
137+
await Promise.all(commentsToDelete);
138+
Log.debug(`Deleted ${commentsToDelete.length} outdated comments`);
139+
} else {
140+
Log.debug('No outdated comments to delete');
141+
}
87142
}
88143
}

src/Provider/GitLab/GitLabMRService.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ export class GitLabMRService implements IGitLabMRService {
6262
await this.api.MergeRequestNotes.remove(this.projectId, this.mrIid, noteId);
6363
}
6464

65+
async listAllDiscussions(): Promise<any[]> {
66+
return await this.api.MergeRequestDiscussions.all(this.projectId, this.mrIid);
67+
}
68+
69+
async deleteDiscussion(discussionId: string): Promise<void> {
70+
// GitLab discussions are deleted by removing all notes in the discussion
71+
// We need to get the discussion and remove its notes
72+
const discussion = await this.api.MergeRequestDiscussions.show(
73+
this.projectId,
74+
this.mrIid,
75+
discussionId,
76+
);
77+
if (discussion.notes && discussion.notes.length > 0) {
78+
const deletePromises = discussion.notes.map((note: any) =>
79+
this.api.MergeRequestNotes.remove(this.projectId, this.mrIid, note.id),
80+
);
81+
await Promise.all(deletePromises);
82+
}
83+
}
84+
6585
// github can do someone fancy shit here we cant
6686
async createNote(note: string): Promise<void> {
6787
await this.api.MergeRequestNotes.create(this.projectId, this.mrIid, note);

src/Provider/GitLab/IGitLabMRService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export interface IGitLabMRService {
55
createNote(note: string): Promise<void>;
66
listAllNotes(): Promise<MergeRequestNoteSchema[]>;
77
deleteNote(noteId: number): Promise<void>;
8+
listAllDiscussions(): Promise<any[]>;
9+
deleteDiscussion(discussionId: string): Promise<void>;
810
getCurrentUserId(): Promise<number>;
911
diff(): Promise<Diff[]>;
1012
getLatestVersion(): Promise<MergeRequestDiffVersionsSchema>;

0 commit comments

Comments
 (0)