|
6 | 6 | * The GitLab API client used by the extension. No tokens or authentication needed as every requests are |
7 | 7 | * performed from inside the context of the page (GitLab allows API calls if they comes from the site). |
8 | 8 | */ |
9 | | - constructor(baseUrl) { |
| 9 | + constructor(baseUrl, csrfToken) { |
10 | 10 | this.baseUrl = baseUrl; |
| 11 | + this.csrfToken = csrfToken; |
11 | 12 | } |
12 | 13 |
|
13 | 14 | /** |
|
28 | 29 | /** |
29 | 30 | * Sends an HTTP request to the GitLab API. |
30 | 31 | */ |
31 | | - sendRequest(callback, method, endpoint, queryStringParameters = null) { |
| 32 | + sendRequest(callback, method, endpoint, queryStringParameters = null, data = null) { |
32 | 33 | let xhr = new XMLHttpRequest(); |
33 | 34 |
|
34 | 35 | xhr.responseType = 'json'; |
|
40 | 41 | }; |
41 | 42 |
|
42 | 43 | xhr.open(method, this.createEndpointUrl(endpoint, queryStringParameters)); |
43 | | - xhr.send(); |
| 44 | + |
| 45 | + if (['post', 'put', 'patch'].includes(method.toLowerCase())) { |
| 46 | + if (!this.csrfToken) { |
| 47 | + console.error('Cannot issue POST/PUT/PATCH requests without CSRF token'); |
| 48 | + |
| 49 | + return; |
| 50 | + } |
| 51 | + |
| 52 | + xhr.setRequestHeader('X-CSRF-Token', this.csrfToken); |
| 53 | + } |
| 54 | + |
| 55 | + if (data) { |
| 56 | + xhr.setRequestHeader('Content-Type', 'application/json'); |
| 57 | + |
| 58 | + data = JSON.stringify(data); |
| 59 | + } |
| 60 | + |
| 61 | + xhr.send(data); |
44 | 62 | } |
45 | 63 |
|
46 | 64 | /** |
|
58 | 76 | queryStringParameters |
59 | 77 | ); |
60 | 78 | } |
| 79 | + |
| 80 | + /** |
| 81 | + * Update the given Merge Request Id in the given project ID. |
| 82 | + */ |
| 83 | + updateProjectMergeRequest(callback, projectId, mergeRequestId, data) { |
| 84 | + let dataToSend = { |
| 85 | + id: parseInt(projectId, 10), |
| 86 | + merge_request_iid: parseInt(mergeRequestId, 10) |
| 87 | + }; |
| 88 | + |
| 89 | + Object.assign(dataToSend, data); |
| 90 | + |
| 91 | + this.sendRequest( |
| 92 | + callback, |
| 93 | + 'PUT', |
| 94 | + 'projects/' + projectId + '/merge_requests/' + mergeRequestId, |
| 95 | + null, |
| 96 | + dataToSend |
| 97 | + ); |
| 98 | + } |
61 | 99 | } |
62 | 100 |
|
63 | 101 | class ContentScript { |
|
85 | 123 |
|
86 | 124 | this.baseUrl = location.protocol + '//' + location.host; |
87 | 125 | this.baseApiUrl = this.baseUrl + '/api/v4/'; |
88 | | - this.apiClient = new GitLabApiClient(this.baseApiUrl); |
| 126 | + this.apiClient = new GitLabApiClient(this.baseApiUrl, this.getCsrfToken()); |
89 | 127 |
|
90 | 128 | let currentMergeRequestIds = this.getCurrentMergeRequestIds(); |
91 | 129 | let preferencesManager = new globals.Gmrle.PreferencesManager(); |
|
120 | 158 | return link ? link.getAttribute('href') : null; |
121 | 159 | } |
122 | 160 |
|
| 161 | + /** |
| 162 | + * Get the current CSRF token that should be sent in any subsequent POST or PUT requests to the Gitlab API. |
| 163 | + */ |
| 164 | + getCsrfToken() { |
| 165 | + let meta = document.querySelector('meta[name="csrf-token"]'); |
| 166 | + |
| 167 | + return meta ? meta.getAttribute('content') : null; |
| 168 | + } |
| 169 | + |
123 | 170 | /** |
124 | 171 | * Gets all Merge Requests IDs that are currently displayed. |
125 | 172 | */ |
|
151 | 198 | if (self.preferences.enable_button_to_copy_mr_info) { |
152 | 199 | self.attachClickEventToCopyMergeRequestInfoButtons(); |
153 | 200 | } |
154 | | - } else { |
155 | | - alert('Got error from GitLab, check console for more information.'); |
156 | 201 |
|
| 202 | + if (self.preferences.enable_button_to_toggle_wip_status) { |
| 203 | + self.attachClickEventToToggleWipStatusButtons(); |
| 204 | + } |
| 205 | + } else { |
157 | 206 | console.error('Got error from GitLab:', this.status, this.response); |
| 207 | + |
| 208 | + alert('Got error from GitLab, check console for more information.'); |
158 | 209 | } |
159 | 210 | }, |
160 | 211 | this.currentProjectId, |
|
203 | 254 | }); |
204 | 255 | } |
205 | 256 |
|
| 257 | + /** |
| 258 | + * Inserts the given HTML string before the given child target node. |
| 259 | + */ |
| 260 | + parseHtmlAndInsertBefore(targetNode, html) { |
| 261 | + this.parseHtml(html, function(node) { |
| 262 | + targetNode.parentNode.insertBefore(node, targetNode); |
| 263 | + }); |
| 264 | + } |
| 265 | + |
206 | 266 | /** |
207 | 267 | * Actually updates the UI by altering the DOM by adding our stuff. |
208 | 268 | */ |
209 | | - updateMergeRequestsNodes(mergeRequestsDetails) { |
210 | | - mergeRequestsDetails.forEach(function(mergeRequest) { |
211 | | - let mergeRequestContainer = document.querySelector('.mr-list .merge-request[data-id="' + mergeRequest.id + '"]'); |
| 269 | + updateMergeRequestsNodes(mergeRequests) { |
| 270 | + mergeRequests.forEach(function(mergeRequest) { |
| 271 | + let mergeRequestNode = document.querySelector('.mr-list .merge-request[data-id="' + mergeRequest.id + '"]'); |
| 272 | + |
| 273 | + this.setDataAttributesToMergeRequestNode(mergeRequestNode, mergeRequest); |
212 | 274 |
|
213 | | - this.setDataAttributesToMergeRequestContainer(mergeRequestContainer, mergeRequest); |
| 275 | + // ----------------------------------------------- |
| 276 | + // Toggle WIP status button |
| 277 | + |
| 278 | + if (this.preferences.enable_button_to_toggle_wip_status) { |
| 279 | + let toggleWipStatusButton = '<button class="btn btn-secondary btn-md btn-default btn-transparent btn-clipboard has-tooltip gmrle-toggle-wip-status" title="Toggle WIP status" style="padding-left: 0">' + |
| 280 | + '<i class="fa fa-wrench" aria-hidden="true"></i>' + |
| 281 | + '</button> '; |
| 282 | + |
| 283 | + this.parseHtmlAndPrepend( |
| 284 | + mergeRequestNode.querySelector('.merge-request-title'), |
| 285 | + toggleWipStatusButton |
| 286 | + ); |
| 287 | + } |
214 | 288 |
|
215 | 289 | // ----------------------------------------------- |
216 | | - // Jira ticket link (data attributes are set in setDataAttributesToMergeRequestContainer, above) |
| 290 | + // Jira ticket link (data attributes are set in setDataAttributesToNode, above) |
217 | 291 |
|
218 | | - if (('jiraTicketId' in mergeRequestContainer.dataset) && ('jiraTicketUrl' in mergeRequestContainer.dataset)) { |
| 292 | + if (('jiraTicketId' in mergeRequestNode.dataset) && ('jiraTicketUrl' in mergeRequestNode.dataset)) { |
| 293 | + let jiraTicketLinkToolip = null; |
219 | 294 | let jiraTicketLinkLabel = null; |
220 | 295 |
|
221 | 296 | switch (this.preferences.jira_ticket_link_label_type) { |
222 | 297 | case 'ticket_id': |
223 | | - jiraTicketLinkLabel = mergeRequestContainer.dataset.jiraTicketId; |
| 298 | + jiraTicketLinkLabel = mergeRequestNode.dataset.jiraTicketId; |
224 | 299 |
|
225 | 300 | break; |
226 | 301 | case 'icon': |
227 | | - jiraTicketLinkLabel = '<button class="btn btn-secondary btn-md btn-default btn-transparent btn-clipboard has-tooltip" title="Jira ticket ' + mergeRequestContainer.dataset.jiraTicketId + '">' + |
228 | | - '<i class="fa fa-ticket" aria-hidden="true"></i>' + |
229 | | - '</button>'; |
| 302 | + jiraTicketLinkLabel = '<i class="fa fa-ticket" aria-hidden="true"></i>'; |
| 303 | + jiraTicketLinkToolip = 'Jira ticket ' + mergeRequestNode.dataset.jiraTicketId; |
230 | 304 |
|
231 | 305 | break; |
232 | 306 | default: |
233 | 307 | console.error('Invalid link label type ' + this.preferences.jira_ticket_link_label_type); |
234 | 308 | } |
235 | 309 |
|
236 | 310 | if (jiraTicketLinkLabel) { |
237 | | - let jiraTicketLink = '<a href="' + mergeRequestContainer.dataset.jiraTicketUrl + '" class="issuable-milestone">' + |
| 311 | + let jiraTicketLink = '<a href="' + mergeRequestNode.dataset.jiraTicketUrl + '" ' + |
| 312 | + 'class="issuable-milestone ' + (jiraTicketLinkToolip ? 'has-tooltip' : '') + '" ' + |
| 313 | + (jiraTicketLinkToolip ? 'title="' + jiraTicketLinkToolip + '"' : '') + '>' + |
238 | 314 | jiraTicketLinkLabel + |
239 | 315 | '</a> '; |
240 | 316 |
|
241 | | - this.parseHtmlAndPrepend( |
242 | | - mergeRequestContainer.querySelector('.merge-request-title'), |
| 317 | + this.parseHtmlAndInsertBefore( |
| 318 | + mergeRequestNode.querySelector('.merge-request-title-text'), |
243 | 319 | jiraTicketLink |
244 | 320 | ); |
245 | 321 | } |
|
249 | 325 | // Copy MR info button |
250 | 326 |
|
251 | 327 | if (this.preferences.enable_button_to_copy_mr_info) { |
252 | | - let copyMrInfoButton = '<button class="btn btn-secondary btn-md btn-default btn-transparent btn-clipboard has-tooltip gmrle-copy-mr-info" title="Copy Merge Request info">' + |
| 328 | + let copyMrInfoButton = '<button class="btn btn-secondary btn-md btn-default btn-transparent btn-clipboard has-tooltip gmrle-copy-mr-info" title="Copy Merge Request info" style="padding-left: 0">' + |
253 | 329 | '<i class="fa fa-share-square-o" aria-hidden="true"></i>' + |
254 | 330 | '</button> '; |
255 | 331 |
|
256 | 332 | this.parseHtmlAndPrepend( |
257 | | - mergeRequestContainer.querySelector('.issuable-info'), |
| 333 | + mergeRequestNode.querySelector('.issuable-info'), |
258 | 334 | copyMrInfoButton |
259 | 335 | ); |
260 | 336 | } |
|
291 | 367 | newInfoLineToInject += '</div>'; |
292 | 368 |
|
293 | 369 | this.parseHtmlAndAppend( |
294 | | - mergeRequestContainer.querySelector('.issuable-main-info'), |
| 370 | + mergeRequestNode.querySelector('.issuable-main-info'), |
295 | 371 | newInfoLineToInject |
296 | 372 | ); |
297 | 373 | }, this); |
|
300 | 376 | /** |
301 | 377 | * Sets several data-* attributes on a DOM node representing a Merge Request so these values may be used later. |
302 | 378 | */ |
303 | | - setDataAttributesToMergeRequestContainer(mergeRequestContainer, mergeRequest) { |
304 | | - mergeRequestContainer.dataset.title = mergeRequest.title; |
305 | | - mergeRequestContainer.dataset.iid = mergeRequest.iid; |
306 | | - mergeRequestContainer.dataset.url = mergeRequest.web_url; |
307 | | - mergeRequestContainer.dataset.diffsUrl = mergeRequest.web_url + '/diffs'; |
308 | | - mergeRequestContainer.dataset.authorName = mergeRequest.author.name; |
309 | | - mergeRequestContainer.dataset.status = mergeRequest.state; |
310 | | - mergeRequestContainer.dataset.sourceBranchName = mergeRequest.source_branch; |
311 | | - mergeRequestContainer.dataset.targetBranchName = mergeRequest.target_branch; |
| 379 | + setDataAttributesToMergeRequestNode(mergeRequestNode, mergeRequest) { |
| 380 | + mergeRequestNode.dataset.title = mergeRequest.title; |
| 381 | + mergeRequestNode.dataset.iid = mergeRequest.iid; |
| 382 | + mergeRequestNode.dataset.url = mergeRequest.web_url; |
| 383 | + mergeRequestNode.dataset.diffsUrl = mergeRequest.web_url + '/diffs'; |
| 384 | + mergeRequestNode.dataset.authorName = mergeRequest.author.name; |
| 385 | + mergeRequestNode.dataset.status = mergeRequest.state; |
| 386 | + mergeRequestNode.dataset.sourceBranchName = mergeRequest.source_branch; |
| 387 | + mergeRequestNode.dataset.targetBranchName = mergeRequest.target_branch; |
| 388 | + mergeRequestNode.dataset.isWip = mergeRequest.work_in_progress; |
312 | 389 |
|
313 | 390 | if (this.preferences.enable_jira_ticket_link) { |
314 | 391 | let jiraTicketId = this.findFirstJiraTicketId(mergeRequest); |
315 | 392 |
|
316 | 393 | if (jiraTicketId) { |
317 | | - mergeRequestContainer.dataset.jiraTicketId = jiraTicketId; |
318 | | - mergeRequestContainer.dataset.jiraTicketUrl = this.createJiraTicketUrl(jiraTicketId); |
| 394 | + mergeRequestNode.dataset.jiraTicketId = jiraTicketId; |
| 395 | + mergeRequestNode.dataset.jiraTicketUrl = this.createJiraTicketUrl(jiraTicketId); |
319 | 396 | } |
320 | 397 | } |
321 | 398 | } |
|
401 | 478 | }); |
402 | 479 | } |
403 | 480 |
|
| 481 | + /** |
| 482 | + * Attach a click event to all buttons inserted by the extension allowing to toggle Merge Request WIP status. |
| 483 | + */ |
| 484 | + attachClickEventToToggleWipStatusButtons() { |
| 485 | + let self = this; |
| 486 | + |
| 487 | + document.querySelectorAll('button.gmrle-toggle-wip-status').forEach(function(el) { |
| 488 | + el.addEventListener('click', function(e) { |
| 489 | + e.preventDefault(); |
| 490 | + |
| 491 | + self.toggleMergeRequestWipStatus(this.closest('.merge-request'), this); |
| 492 | + }); |
| 493 | + }); |
| 494 | + } |
| 495 | + |
| 496 | + /** |
| 497 | + * Actually toggle a given Merge Request WIP status. |
| 498 | + */ |
| 499 | + toggleMergeRequestWipStatus(mergeRequestNode, toggleButton) { |
| 500 | + toggleButton.disabled = true; |
| 501 | + toggleButton.firstChild.classList.remove('fa-wrench'); |
| 502 | + toggleButton.firstChild.classList.add('fa-spinner', 'fa-spin'); |
| 503 | + |
| 504 | + let isWip = mergeRequestNode.dataset.isWip == 'true'; |
| 505 | + let newTitle = ''; |
| 506 | + |
| 507 | + if (isWip) { |
| 508 | + newTitle = mergeRequestNode.dataset.title.replace(new RegExp('^WIP:'), '').trim(); |
| 509 | + } else { |
| 510 | + newTitle = 'WIP: ' + mergeRequestNode.dataset.title.trim(); |
| 511 | + } |
| 512 | + |
| 513 | + this.apiClient.updateProjectMergeRequest( |
| 514 | + function() { |
| 515 | + if (this.status == 200) { |
| 516 | + mergeRequestNode.dataset.isWip = this.response.work_in_progress; |
| 517 | + mergeRequestNode.dataset.title = this.response.title; |
| 518 | + |
| 519 | + mergeRequestNode.querySelector('.merge-request-title-text a').textContent = this.response.title; |
| 520 | + } else { |
| 521 | + console.error('Got error from GitLab:', this.status, this.response); |
| 522 | + |
| 523 | + alert('Got error from GitLab, check console for more information.'); |
| 524 | + } |
| 525 | + |
| 526 | + toggleButton.disabled = false; |
| 527 | + toggleButton.firstChild.classList.add('fa-wrench'); |
| 528 | + toggleButton.firstChild.classList.remove('fa-spinner', 'fa-spin'); |
| 529 | + }, |
| 530 | + this.currentProjectId, |
| 531 | + mergeRequestNode.dataset.iid, |
| 532 | + { |
| 533 | + title: newTitle |
| 534 | + } |
| 535 | + ); |
| 536 | + } |
| 537 | + |
404 | 538 | /** |
405 | 539 | * Creates the Merge Request info text from a Merge Request container DOM node. |
406 | 540 | */ |
407 | | - buildMergeRequestInfoText(mergeRequestContainer) { |
| 541 | + buildMergeRequestInfoText(mergeRequestNode) { |
408 | 542 | let placeholders = { |
409 | | - MR_TITLE: mergeRequestContainer.dataset.title, |
410 | | - MR_ID: mergeRequestContainer.dataset.iid, |
411 | | - MR_URL: mergeRequestContainer.dataset.url, |
412 | | - MR_DIFFS_URL: mergeRequestContainer.dataset.diffsUrl, |
413 | | - MR_AUTHOR_NAME: mergeRequestContainer.dataset.authorName, |
414 | | - MR_STATUS: mergeRequestContainer.dataset.status, |
415 | | - MR_SOURCE_BRANCH_NAME: mergeRequestContainer.dataset.sourceBranchName, |
416 | | - MR_TARGET_BRANCH_NAME: mergeRequestContainer.dataset.targetBranchName, |
417 | | - MR_JIRA_TICKET_ID: ('jiraTicketId' in mergeRequestContainer.dataset) ? mergeRequestContainer.dataset.jiraTicketId : '', |
418 | | - MR_JIRA_TICKET_URL: ('jiraTicketUrl' in mergeRequestContainer.dataset) ? mergeRequestContainer.dataset.jiraTicketUrl : '' |
| 543 | + MR_TITLE: mergeRequestNode.dataset.title, |
| 544 | + MR_ID: mergeRequestNode.dataset.iid, |
| 545 | + MR_URL: mergeRequestNode.dataset.url, |
| 546 | + MR_DIFFS_URL: mergeRequestNode.dataset.diffsUrl, |
| 547 | + MR_AUTHOR_NAME: mergeRequestNode.dataset.authorName, |
| 548 | + MR_STATUS: mergeRequestNode.dataset.status, |
| 549 | + MR_SOURCE_BRANCH_NAME: mergeRequestNode.dataset.sourceBranchName, |
| 550 | + MR_TARGET_BRANCH_NAME: mergeRequestNode.dataset.targetBranchName, |
| 551 | + MR_JIRA_TICKET_ID: ('jiraTicketId' in mergeRequestNode.dataset) ? mergeRequestNode.dataset.jiraTicketId : '', |
| 552 | + MR_JIRA_TICKET_URL: ('jiraTicketUrl' in mergeRequestNode.dataset) ? mergeRequestNode.dataset.jiraTicketUrl : '' |
419 | 553 | }; |
420 | 554 |
|
421 | 555 | let placeholdersReplaceRegex = new RegExp('{(' + Object.keys(placeholders).join('|') + ')}', 'g'); |
|
0 commit comments