Skip to content

Commit 90f595b

Browse files
committed
GitLab: Add support to use threads instead of comments
1 parent 2bcccbd commit 90f595b

File tree

9 files changed

+356
-54
lines changed

9 files changed

+356
-54
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
- GitLab: Added provider tests [#1319](https://github.com/danger/danger-js/pull/1319) [@ivankatliarchuk]
2323

2424
- GitHub: Added `danger.github.pr.draft` field to DSL
25-
25+
- GitLab: Add support for using threads instead of comments [#1331](https://github.com/danger/danger-js/pull/1331) [@uncaught]
2626
<!-- Your comment above this -->
2727

2828
## 11.1.2

docs/usage/gitlab.html.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,8 @@ danger.gitlab.
4141
/** The commits associated with the merge request */
4242
commits: GitLabMRCommit[]
4343
```
44+
45+
---
46+
47+
If you want danger to open threads (discussions) instead of just commenting in merge requests, set an environment
48+
variable `DANGER_GITLAB_USE_THREADS` with value `1` or `true`.

source/danger.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,6 +1662,12 @@ interface GitLabNote {
16621662
noteable_iid: number
16631663
}
16641664

1665+
interface GitLabDiscussion {
1666+
id: string //40 character hex
1667+
individual_note: boolean
1668+
notes: GitLabNote[]
1669+
}
1670+
16651671
interface GitLabDiscussionTextPosition {
16661672
position_type: "text"
16671673
base_sha: string
@@ -1673,6 +1679,10 @@ interface GitLabDiscussionTextPosition {
16731679
old_line: number | null
16741680
}
16751681

1682+
interface GitLabDiscussionCreationOptions {
1683+
position?: GitLabDiscussionTextPosition
1684+
}
1685+
16761686
interface GitLabInlineNote extends GitLabNote {
16771687
position: {
16781688
base_sha: string

source/dsl/GitLabDSL.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ export interface GitLabNote {
204204
noteable_iid: number
205205
}
206206

207+
export interface GitLabDiscussion {
208+
id: string; //40 character hex
209+
individual_note: boolean;
210+
notes: GitLabNote[];
211+
}
212+
207213
export interface GitLabDiscussionTextPosition {
208214
position_type: "text"
209215
base_sha: string
@@ -215,6 +221,10 @@ export interface GitLabDiscussionTextPosition {
215221
old_line: number | null
216222
}
217223

224+
export interface GitLabDiscussionCreationOptions {
225+
position?: GitLabDiscussionTextPosition
226+
}
227+
218228
export interface GitLabInlineNote extends GitLabNote {
219229
position: {
220230
base_sha: string

source/platforms/GitLab.ts

Lines changed: 76 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import GitLabAPI from "./gitlab/GitLabAPI"
2-
import { Platform, Comment } from "./platform"
2+
import { Comment, Platform } from "./platform"
33
import { GitDSL, GitJSONDSL } from "../dsl/GitDSL"
44
import { GitCommit } from "../dsl/Commit"
5-
import { GitLabDSL, GitLabJSONDSL, GitLabNote } from "../dsl/GitLabDSL"
6-
5+
import { GitLabDiscussion, GitLabDSL, GitLabJSONDSL, GitLabNote } from "../dsl/GitLabDSL"
76
import { debug } from "../debug"
87
import { dangerIDToString } from "../runner/templates/githubIssueTemplate"
8+
99
const d = debug("GitLab")
1010

11+
const useThreads = () => process.env.DANGER_GITLAB_USE_THREADS === "1" ||
12+
process.env.DANGER_GITLAB_USE_THREADS === "true"
13+
1114
class GitLab implements Platform {
1215
public readonly name: string
1316

@@ -78,7 +81,8 @@ class GitLab implements Platform {
7881
return {
7982
id: `${note.id}`,
8083
body: note.body,
81-
// XXX: we should re-use the logic in getDangerNotes, need to check what inline comment template we're using if any
84+
// XXX: we should re-use the logic in getDangerNotes, need to check what inline comment template we're using if
85+
// any
8286
ownedByDanger: note.author.id === dangerUserID && note.body.includes(dangerID),
8387
}
8488
})
@@ -95,51 +99,54 @@ class GitLab implements Platform {
9599
updateOrCreateComment = async (dangerID: string, newComment: string): Promise<string> => {
96100
d("updateOrCreateComment", { dangerID, newComment })
97101

98-
const notes: GitLabNote[] = await this.getDangerNotes(dangerID)
99-
100-
let note: GitLabNote
101-
102-
if (notes.length) {
103-
// update the first
104-
note = await this.api.updateMergeRequestNote(notes[0].id, newComment)
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
105110

106-
// delete the rest
107-
for (let deleteme of notes) {
108-
if (deleteme === notes[0]) {
109-
continue
110-
}
111+
let newOrUpdatedNote: GitLabNote
111112

112-
await this.api.deleteMergeRequestNote(deleteme.id)
113-
}
113+
if (existingNote) {
114+
// update the existing comment
115+
newOrUpdatedNote = await this.api.updateMergeRequestNote(existingNote.id, newComment)
114116
} else {
115-
// create a new note
116-
note = await this.api.createMergeRequestNote(newComment)
117+
// create a new comment
118+
newOrUpdatedNote = await this.createComment(newComment)
117119
}
118120

119121
// create URL from note
120122
// "https://gitlab.com/group/project/merge_requests/154#note_132143425"
121-
return `${this.api.mergeRequestURL}#note_${note.id}`
123+
return `${this.api.mergeRequestURL}#note_${newOrUpdatedNote.id}`
122124
}
123125

124-
createComment = async (comment: string): Promise<any> => {
126+
createComment = async (comment: string): Promise<GitLabNote> => {
125127
d("createComment", { comment })
128+
if (useThreads()) {
129+
return (await this.api.createMergeRequestDiscussion(comment)).notes[0]
130+
}
126131
return this.api.createMergeRequestNote(comment)
127132
}
128133

129-
createInlineComment = async (git: GitDSL, comment: string, path: string, line: number): Promise<string> => {
134+
createInlineComment = async (git: GitDSL, comment: string, path: string, line: number): Promise<GitLabDiscussion> => {
130135
d("createInlineComment", { git, comment, path, line })
131136

132137
const mr = await this.api.getMergeRequestInfo()
133138

134139
return this.api.createMergeRequestDiscussion(comment, {
135-
position_type: "text",
136-
base_sha: mr.diff_refs.base_sha,
137-
start_sha: mr.diff_refs.start_sha,
138-
head_sha: mr.diff_refs.head_sha,
139-
old_path: path,
140-
old_line: null,
141-
new_path: path,
142-
new_line: line,
140+
position: {
141+
position_type: "text",
142+
base_sha: mr.diff_refs.base_sha,
143+
start_sha: mr.diff_refs.start_sha,
144+
head_sha: mr.diff_refs.head_sha,
145+
old_path: path,
146+
old_line: null,
147+
new_path: path,
148+
new_line: line,
149+
},
143150
})
144151
}
145152

@@ -156,26 +163,54 @@ class GitLab implements Platform {
156163
}
157164

158165
deleteMainComment = async (dangerID: string): Promise<boolean> => {
159-
const notes = await this.getDangerNotes(dangerID)
160-
for (let note of notes) {
161-
d("deleteMainComment", { id: note.id })
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))
171+
}
172+
173+
deleteNotes = async (notes: GitLabNote[]): Promise<boolean> => {
174+
for (const note of notes) {
175+
d("deleteNotes", { id: note.id })
162176
await this.api.deleteMergeRequestNote(note.id)
163177
}
164178

165179
return notes.length > 0
166180
}
167181

182+
/**
183+
* Only fetches the discussions where danger owns the top note
184+
*/
185+
getDangerDiscussions = async (dangerID: string): Promise<GitLabDiscussion[]> => {
186+
const noteFilter = await this.getDangerNoteFilter(dangerID)
187+
const discussions: GitLabDiscussion[] = await this.api.getMergeRequestDiscussions()
188+
return discussions.filter(({ notes }) => notes.length && noteFilter(notes[0]))
189+
}
190+
191+
reduceNotesFromDiscussions = (discussions: GitLabDiscussion[]): GitLabNote[] => {
192+
return discussions.reduce<GitLabNote[]>((acc, { notes }) => [...acc, ...notes], [])
193+
}
194+
168195
getDangerNotes = async (dangerID: string): Promise<GitLabNote[]> => {
169-
const { id: dangerUserId } = await this.api.getUser()
196+
const noteFilter = await this.getDangerNoteFilter(dangerID)
170197
const notes: GitLabNote[] = await this.api.getMergeRequestNotes()
198+
return notes.filter(noteFilter)
199+
}
171200

172-
return notes.filter(
173-
({ author: { id }, body, system, type }: GitLabNote) =>
174-
!system && // system notes are generated when the user interacts with the UI e.g. changing a PR title
175-
type == null && // we only want "normal" comments on the main body of the MR;
201+
getDangerNoteFilter = async (
202+
dangerID: string,
203+
): Promise<(note: GitLabNote) => boolean> => {
204+
const { id: dangerUserId } = await this.api.getUser()
205+
return ({ author: { id }, body, system }: GitLabNote): boolean => {
206+
return !system && // system notes are generated when the user interacts with the UI e.g. changing a PR title
176207
id === dangerUserId &&
177-
body!.includes(dangerIDToString(dangerID)) // danger-id-(dangerID) is included in a hidden comment in the githubIssueTemplate
178-
)
208+
//we do not check the `type` - it's `null` most of the time,
209+
// only in discussions/threads this is `DiscussionNote` for all notes. But even if danger only creates a
210+
// normal `null`-comment, any user replying to that comment will turn it into a `DiscussionNote`-typed one.
211+
// So we cannot assume anything here about danger's note type.
212+
body.includes(dangerIDToString(dangerID))
213+
}
179214
}
180215

181216
updateStatus = async (): Promise<boolean> => {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import GitLabAPI from "../gitlab/GitLabAPI"
2+
import GitLab from "../GitLab"
3+
import { GitLabDiscussion, GitLabNote, GitLabUser, GitLabUserProfile } from "../../dsl/GitLabDSL"
4+
5+
const baseUri = "https://my-gitlab.org/my-project"
6+
7+
const dangerUserId = 8797215
8+
9+
const getUser = async (): Promise<GitLabUserProfile> => {
10+
const user: Partial<GitLabUserProfile> = { id: dangerUserId }
11+
return user as GitLabUserProfile
12+
}
13+
14+
const dangerID = "dddanger1234"
15+
const dangerIdString = `DangerID: danger-id-${dangerID};`
16+
17+
function mockNote(id: number, authorId: number, body = ""): GitLabNote {
18+
const author: Partial<GitLabUser> = { id: authorId }
19+
const note: Partial<GitLabNote> = { author: author as GitLabUser, body, id }
20+
return note as GitLabNote
21+
}
22+
23+
function mockDiscussion(id: string, notes: GitLabNote[]): GitLabDiscussion {
24+
const discussion: Partial<GitLabDiscussion> = { id, notes }
25+
return discussion as GitLabDiscussion
26+
}
27+
28+
function mockApi(withExisting = false): GitLabAPI {
29+
const note1 = mockNote(1001, dangerUserId, `some body ${dangerIdString} asdf`)
30+
const note2 = mockNote(1002, 125235)
31+
const note3 = mockNote(1003, dangerUserId, `another danger ${dangerIdString} body`)
32+
const note4 = mockNote(1004, 745774)
33+
const note5 = mockNote(1005, 745774)
34+
const discussion1 = mockDiscussion("aaaaffff1111", [note1, note2])
35+
const discussion2 = mockDiscussion("aaaaffff2222", [note3, note4])
36+
const discussion3 = mockDiscussion("aaaaffff3333", [note5])
37+
38+
const newNote = mockNote(4711, dangerUserId)
39+
const newDiscussion = mockDiscussion("aaaaffff0000", [newNote])
40+
41+
const api: Partial<GitLabAPI> = {
42+
getUser,
43+
createMergeRequestDiscussion: jest.fn(() => Promise.resolve(newDiscussion)),
44+
createMergeRequestNote: jest.fn(() => Promise.resolve(newNote)),
45+
deleteMergeRequestNote: jest.fn(() => Promise.resolve(true)),
46+
getMergeRequestDiscussions: jest.fn(() => Promise.resolve(withExisting
47+
? [discussion1, discussion2, discussion3]
48+
: [])),
49+
mergeRequestURL: baseUri,
50+
updateMergeRequestNote: jest.fn(() => Promise.resolve(newNote)),
51+
}
52+
return api as GitLabAPI
53+
}
54+
55+
describe("updateOrCreateComment", () => {
56+
const comment = "my new comment"
57+
58+
it("create a new single comment", async () => {
59+
delete process.env.DANGER_GITLAB_USE_THREADS
60+
61+
const api = mockApi()
62+
const url = await (new GitLab(api as GitLabAPI)).updateOrCreateComment(dangerID, comment)
63+
expect(url).toEqual(`${baseUri}#note_4711`)
64+
65+
expect(api.getMergeRequestDiscussions).toHaveBeenCalledTimes(1)
66+
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(0)
67+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(0)
68+
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(1)
69+
expect(api.createMergeRequestNote).toHaveBeenCalledWith(comment)
70+
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
71+
})
72+
73+
it("create a new thread", async () => {
74+
process.env.DANGER_GITLAB_USE_THREADS = "true"
75+
76+
const api = mockApi()
77+
const url = await (new GitLab(api as GitLabAPI)).updateOrCreateComment(dangerID, comment)
78+
expect(url).toEqual(`${baseUri}#note_4711`)
79+
80+
expect(api.getMergeRequestDiscussions).toHaveBeenCalledTimes(1)
81+
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(0)
82+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(1)
83+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledWith(comment)
84+
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(0)
85+
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
86+
})
87+
88+
it("update an existing thread", async () => {
89+
const api = mockApi(true)
90+
const url = await (new GitLab(api as GitLabAPI)).updateOrCreateComment(dangerID, comment)
91+
expect(url).toEqual(`${baseUri}#note_4711`)
92+
93+
expect(api.getMergeRequestDiscussions).toHaveBeenCalledTimes(1)
94+
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(2)
95+
expect(api.deleteMergeRequestNote).toHaveBeenNthCalledWith(1, 1003)
96+
expect(api.deleteMergeRequestNote).toHaveBeenNthCalledWith(2, 1004)
97+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(0)
98+
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(0)
99+
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(1)
100+
expect(api.updateMergeRequestNote).toHaveBeenCalledWith(1001, comment)
101+
})
102+
})
103+
104+
describe("deleteMainComment", () => {
105+
it("delete nothing", async () => {
106+
const api = mockApi()
107+
const result = await (new GitLab(api as GitLabAPI)).deleteMainComment(dangerID)
108+
expect(result).toEqual(false)
109+
110+
expect(api.getMergeRequestDiscussions).toHaveBeenCalledTimes(1)
111+
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(0)
112+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(0)
113+
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(0)
114+
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
115+
})
116+
117+
it("delete all danger attached notes", async () => {
118+
const api = mockApi(true)
119+
const result = await (new GitLab(api as GitLabAPI)).deleteMainComment(dangerID)
120+
expect(result).toEqual(true)
121+
122+
expect(api.getMergeRequestDiscussions).toHaveBeenCalledTimes(1)
123+
expect(api.deleteMergeRequestNote).toHaveBeenCalledTimes(4)
124+
expect(api.deleteMergeRequestNote).toHaveBeenNthCalledWith(1, 1001)
125+
expect(api.deleteMergeRequestNote).toHaveBeenNthCalledWith(2, 1002)
126+
expect(api.deleteMergeRequestNote).toHaveBeenNthCalledWith(3, 1003)
127+
expect(api.deleteMergeRequestNote).toHaveBeenNthCalledWith(4, 1004)
128+
expect(api.createMergeRequestDiscussion).toHaveBeenCalledTimes(0)
129+
expect(api.createMergeRequestNote).toHaveBeenCalledTimes(0)
130+
expect(api.updateMergeRequestNote).toHaveBeenCalledTimes(0)
131+
})
132+
})

0 commit comments

Comments
 (0)