Skip to content

Commit 3d475ce

Browse files
authored
Merge pull request #1382 from DavidBrunow/bugfix/gitLabInlineComments
fix: GitLab Inline comments deleted with main comment updates
2 parents 899a820 + 58dffba commit 3d475ce

File tree

3 files changed

+160
-42
lines changed

3 files changed

+160
-42
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
## Main
1616

1717
<!-- Your comment below this -->
18-
18+
- Fix issue where GitLab inline comments would not show on merge requests #1351 - [@davidbrunow]
1919
<!-- Your comment above this -->
2020

2121
## 11.2.6

source/platforms/GitLab.ts

Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { dangerIDToString } from "../runner/templates/githubIssueTemplate"
88

99
const d = debug("GitLab")
1010

11+
/**
12+
* Determines whether Danger should use threads for the "main" Danger comment.
13+
*/
1114
const useThreads = () =>
1215
process.env.DANGER_GITLAB_USE_THREADS === "1" || process.env.DANGER_GITLAB_USE_THREADS === "true"
1316

@@ -81,8 +84,6 @@ class GitLab implements Platform {
8184
return {
8285
id: `${note.id}`,
8386
body: note.body,
84-
// XXX: we should re-use the logic in getDangerNotes, need to check what inline comment template we're using if
85-
// any
8687
ownedByDanger: note.author.id === dangerUserID && note.body.includes(dangerID),
8788
}
8889
})
@@ -99,28 +100,49 @@ class GitLab implements Platform {
99100
updateOrCreateComment = async (dangerID: string, newComment: string): Promise<string> => {
100101
d("updateOrCreateComment", { dangerID, newComment })
101102

102-
//Even when we don't set danger to create threads, we still need to delete them if someone answered to a single
103-
// comment created by danger, resulting in a discussion/thread. Otherwise we are left with dangling comments
104-
// that will most likely have no meaning out of context.
105-
const discussions = await this.getDangerDiscussions(dangerID)
106-
const firstDiscussion = discussions.shift()
107-
const existingNote = firstDiscussion?.notes[0]
108-
// Delete all notes from all other danger discussions (discussions cannot be deleted as a whole):
109-
await this.deleteNotes(this.reduceNotesFromDiscussions(discussions)) //delete the rest
110-
111-
let newOrUpdatedNote: GitLabNote
103+
if (useThreads()) {
104+
const discussions = await this.getDangerDiscussions(dangerID)
105+
const firstDiscussion = discussions.shift()
106+
const existingNote = firstDiscussion?.notes[0]
107+
// Delete all notes from all other danger discussions (discussions cannot be deleted as a whole):
108+
await this.deleteNotes(this.reduceNotesFromDiscussions(discussions)) //delete the rest
109+
110+
let newOrUpdatedNote: GitLabNote
111+
112+
if (existingNote) {
113+
// update the existing comment
114+
newOrUpdatedNote = await this.api.updateMergeRequestNote(existingNote.id, newComment)
115+
} else {
116+
// create a new comment
117+
newOrUpdatedNote = await this.createComment(newComment)
118+
}
112119

113-
if (existingNote) {
114-
// update the existing comment
115-
newOrUpdatedNote = await this.api.updateMergeRequestNote(existingNote.id, newComment)
120+
// create URL from note
121+
// "https://gitlab.com/group/project/merge_requests/154#note_132143425"
122+
return `${this.api.mergeRequestURL}#note_${newOrUpdatedNote.id}`
116123
} else {
117-
// create a new comment
118-
newOrUpdatedNote = await this.createComment(newComment)
124+
const notes: GitLabNote[] = await this.getMainDangerNotes(dangerID)
125+
126+
let note: GitLabNote
127+
128+
if (notes.length) {
129+
// update the first
130+
note = await this.api.updateMergeRequestNote(notes[0].id, newComment)
131+
// delete the rest
132+
for (let deleteme of notes) {
133+
if (deleteme === notes[0]) {
134+
continue
135+
}
136+
await this.api.deleteMergeRequestNote(deleteme.id)
137+
}
138+
} else {
139+
// create a new note
140+
note = await this.api.createMergeRequestNote(newComment)
141+
}
142+
// create URL from note
143+
// "https://gitlab.com/group/project/merge_requests/154#note_132143425"
144+
return `${this.api.mergeRequestURL}#note_${note.id}`
119145
}
120-
121-
// create URL from note
122-
// "https://gitlab.com/group/project/merge_requests/154#note_132143425"
123-
return `${this.api.mergeRequestURL}#note_${newOrUpdatedNote.id}`
124146
}
125147

126148
createComment = async (comment: string): Promise<GitLabNote> => {
@@ -162,12 +184,24 @@ class GitLab implements Platform {
162184
return await this.api.deleteMergeRequestNote(nid)
163185
}
164186

187+
/**
188+
* Attempts to delete the "main" Danger comment. If the "main" Danger
189+
* comment has any comments on it then that comment will not be deleted.
190+
*/
165191
deleteMainComment = async (dangerID: string): Promise<boolean> => {
166-
//We fetch the discussions even if we are not set to use threads because users could still have replied to a
167-
// comment by danger and thus created a discussion/thread. To not leave dangling notes, we delete the entire thread.
168-
//There is no API to delete entire discussion. They can only be deleted fully by deleting every note:
169-
const discussions = await this.getDangerDiscussions(dangerID)
170-
return await this.deleteNotes(this.reduceNotesFromDiscussions(discussions))
192+
if (useThreads()) {
193+
// There is no API to delete entire discussion. They can only be deleted fully by deleting every note:
194+
const discussions = await this.getDangerDiscussions(dangerID)
195+
return await this.deleteNotes(this.reduceNotesFromDiscussions(discussions))
196+
} else {
197+
const notes = await this.getMainDangerNotes(dangerID)
198+
for (let note of notes) {
199+
d("deleteMainComment", { id: note.id })
200+
await this.api.deleteMergeRequestNote(note.id)
201+
}
202+
203+
return notes.length > 0
204+
}
171205
}
172206

173207
deleteNotes = async (notes: GitLabNote[]): Promise<boolean> => {
@@ -183,7 +217,7 @@ class GitLab implements Platform {
183217
* Only fetches the discussions where danger owns the top note
184218
*/
185219
getDangerDiscussions = async (dangerID: string): Promise<GitLabDiscussion[]> => {
186-
const noteFilter = await this.getDangerNoteFilter(dangerID)
220+
const noteFilter = await this.getDangerDiscussionNoteFilter(dangerID)
187221
const discussions: GitLabDiscussion[] = await this.api.getMergeRequestDiscussions()
188222
return discussions.filter(({ notes }) => notes.length && noteFilter(notes[0]))
189223
}
@@ -192,22 +226,49 @@ class GitLab implements Platform {
192226
return discussions.reduce<GitLabNote[]>((acc, { notes }) => [...acc, ...notes], [])
193227
}
194228

195-
getDangerNotes = async (dangerID: string): Promise<GitLabNote[]> => {
196-
const noteFilter = await this.getDangerNoteFilter(dangerID)
229+
/**
230+
* Attempts to find the "main" Danger note and should return at most
231+
* one item. If the "main" Danger note has any comments on it then that
232+
* note will not be returned.
233+
*/
234+
getMainDangerNotes = async (dangerID: string): Promise<GitLabNote[]> => {
235+
const noteFilter = await this.getDangerMainNoteFilter(dangerID)
197236
const notes: GitLabNote[] = await this.api.getMergeRequestNotes()
198237
return notes.filter(noteFilter)
199238
}
200239

201-
getDangerNoteFilter = async (dangerID: string): Promise<(note: GitLabNote) => boolean> => {
240+
/**
241+
* Filters a note to determine if it was created by Danger.
242+
*/
243+
getDangerDiscussionNoteFilter = async (dangerID: string): Promise<(note: GitLabNote) => boolean> => {
202244
const { id: dangerUserId } = await this.api.getUser()
203245
return ({ author: { id }, body, system }: GitLabNote): boolean => {
204246
return (
205247
!system && // system notes are generated when the user interacts with the UI e.g. changing a PR title
206248
id === dangerUserId &&
207-
//we do not check the `type` - it's `null` most of the time,
208-
// only in discussions/threads this is `DiscussionNote` for all notes. But even if danger only creates a
209-
// normal `null`-comment, any user replying to that comment will turn it into a `DiscussionNote`-typed one.
210-
// So we cannot assume anything here about danger's note type.
249+
body.includes(dangerIDToString(dangerID))
250+
)
251+
}
252+
}
253+
254+
/**
255+
* Filters a note to the "main" Danger note. If that note has any
256+
* comments on it then it will not be found.
257+
*/
258+
getDangerMainNoteFilter = async (dangerID: string): Promise<(note: GitLabNote) => boolean> => {
259+
const { id: dangerUserId } = await this.api.getUser()
260+
return ({ author: { id }, body, system, type }: GitLabNote): boolean => {
261+
return (
262+
!system && // system notes are generated when the user interacts with the UI e.g. changing a PR title
263+
id === dangerUserId &&
264+
// This check for the type being `null` seems to be the best option
265+
// we have to determine whether this note is the "main" Danger note.
266+
// This assumption does not hold if there are any comments on the
267+
// "main" Danger note and in that case a new "main" Danger note will
268+
// be created instead of updating the existing note. This behavior is better
269+
// than the current alternative which is the bug described here:
270+
// https://github.com/danger/danger-js/issues/1351
271+
type == null &&
211272
body.includes(dangerIDToString(dangerID))
212273
)
213274
}

source/platforms/_tests/_gitlab.test.ts

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ const getUser = async (): Promise<GitLabUserProfile> => {
1414
const dangerID = "dddanger1234"
1515
const dangerIdString = `DangerID: danger-id-${dangerID};`
1616

17-
function mockNote(id: number, authorId: number, body = ""): GitLabNote {
17+
function mockNote(
18+
id: number,
19+
authorId: number,
20+
body = "",
21+
type: "DiffNote" | "DiscussionNote" | null = "DiffNote"
22+
): GitLabNote {
1823
const author: Partial<GitLabUser> = { id: authorId }
19-
const note: Partial<GitLabNote> = { author: author as GitLabUser, body, id }
24+
const note: Partial<GitLabNote> = { author: author as GitLabUser, body, id, type: type }
2025
return note as GitLabNote
2126
}
2227

@@ -28,7 +33,7 @@ function mockDiscussion(id: string, notes: GitLabNote[]): GitLabDiscussion {
2833
function mockApi(withExisting = false): GitLabAPI {
2934
const note1 = mockNote(1001, dangerUserId, `some body ${dangerIdString} asdf`)
3035
const note2 = mockNote(1002, 125235)
31-
const note3 = mockNote(1003, dangerUserId, `another danger ${dangerIdString} body`)
36+
const note3 = mockNote(1003, dangerUserId, `Main Danger comment ${dangerIdString} More text to ensure the Danger ID string can be found in the middle of a comment`, null)
3237
const note4 = mockNote(1004, 745774)
3338
const note5 = mockNote(1005, 745774)
3439
const discussion1 = mockDiscussion("aaaaffff1111", [note1, note2])
@@ -46,6 +51,9 @@ function mockApi(withExisting = false): GitLabAPI {
4651
getMergeRequestDiscussions: jest.fn(() =>
4752
Promise.resolve(withExisting ? [discussion1, discussion2, discussion3] : [])
4853
),
54+
getMergeRequestNotes: jest.fn(() =>
55+
Promise.resolve(withExisting ? [note1, note2, note3, note4, note5] : [])
56+
),
4957
mergeRequestURL: baseUri,
5058
updateMergeRequestNote: jest.fn(() => Promise.resolve(newNote)),
5159
}
@@ -55,21 +63,36 @@ function mockApi(withExisting = false): GitLabAPI {
5563
describe("updateOrCreateComment", () => {
5664
const comment = "my new comment"
5765

58-
it("create a new single comment", async () => {
66+
it("create a new single comment not using threads", async () => {
5967
delete process.env.DANGER_GITLAB_USE_THREADS
6068

6169
const api = mockApi()
6270
const url = await new GitLab(api as GitLabAPI).updateOrCreateComment(dangerID, comment)
6371
expect(url).toEqual(`${baseUri}#note_4711`)
6472

65-
expect(api.getMergeRequestDiscussions).toHaveBeenCalledTimes(1)
73+
expect(api.getMergeRequestDiscussions).toHaveBeenCalledTimes(0)
6674
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(0)
6775
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(0)
6876
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(1)
6977
expect(api.createMergeRequestNote).toHaveBeenCalledWith(comment)
7078
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
7179
})
7280

81+
it("create a new single comment using threads", async () => {
82+
process.env.DANGER_GITLAB_USE_THREADS = "true"
83+
84+
const api = mockApi()
85+
const url = await new GitLab(api as GitLabAPI).updateOrCreateComment(dangerID, comment)
86+
expect(url).toEqual(`${baseUri}#note_4711`)
87+
88+
expect(api.getMergeRequestDiscussions).toHaveBeenCalledTimes(1)
89+
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(0)
90+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(1)
91+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledWith(comment)
92+
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(0)
93+
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
94+
})
95+
7396
it("create a new thread", async () => {
7497
process.env.DANGER_GITLAB_USE_THREADS = "true"
7598

@@ -85,7 +108,24 @@ describe("updateOrCreateComment", () => {
85108
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
86109
})
87110

88-
it("update an existing thread", async () => {
111+
it("update an existing main note", async () => {
112+
delete process.env.DANGER_GITLAB_USE_THREADS
113+
114+
const api = mockApi(true)
115+
const url = await new GitLab(api as GitLabAPI).updateOrCreateComment(dangerID, comment)
116+
expect(url).toEqual(`${baseUri}#note_4711`)
117+
118+
expect(api.getMergeRequestNotes).toHaveBeenCalledTimes(1)
119+
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(0)
120+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(0)
121+
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(0)
122+
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(1)
123+
expect(api.updateMergeRequestNote).toHaveBeenCalledWith(1003, comment)
124+
})
125+
126+
it("update an existing main thread", async () => {
127+
process.env.DANGER_GITLAB_USE_THREADS = "true"
128+
89129
const api = mockApi(true)
90130
const url = await new GitLab(api as GitLabAPI).updateOrCreateComment(dangerID, comment)
91131
expect(url).toEqual(`${baseUri}#note_4711`)
@@ -114,7 +154,24 @@ describe("deleteMainComment", () => {
114154
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
115155
})
116156

117-
it("delete all danger attached notes", async () => {
157+
it("delete all danger attached notes not using threads", async () => {
158+
delete process.env.DANGER_GITLAB_USE_THREADS
159+
160+
const api = mockApi(true)
161+
const result = await new GitLab(api as GitLabAPI).deleteMainComment(dangerID)
162+
expect(result).toEqual(true)
163+
164+
expect(api.getMergeRequestNotes).toHaveBeenCalledTimes(1)
165+
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(1)
166+
expect(api.deleteMergeRequestNote).toHaveBeenNthCalledWith(1, 1003)
167+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(0)
168+
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(0)
169+
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
170+
})
171+
172+
it("delete all danger attached notes using threads", async () => {
173+
process.env.DANGER_GITLAB_USE_THREADS = "true"
174+
118175
const api = mockApi(true)
119176
const result = await new GitLab(api as GitLabAPI).deleteMainComment(dangerID)
120177
expect(result).toEqual(true)

0 commit comments

Comments
 (0)