TFS 2017 Changeset History Helper

Changeset reference utilities

  1. // ==UserScript==
  2. // @name TFS 2017 Changeset History Helper
  3. // @namespace http://jonas.ninja
  4. // @version 1.8.0
  5. // @description Changeset reference utilities
  6. // @author @_jnblog
  7. // @match https://*.visualstudio.com/**/_versionControl*
  8. // @grant GM_addStyle
  9. // @grant GM_setClipboard
  10. // ==/UserScript==
  11. /* jshint -W097 */
  12. /* global GM_addStyle */
  13. /* jshint asi: true, multistr: true */
  14.  
  15. var $ = unsafeWindow.jQuery
  16. var mergedChangesetRegex = /\(merge [^\)]* to QA\)/gi
  17. var buttonTemplate = $('<button class="ijg-copyButton">')
  18. var containerTemplate = $('<div class="ijg-copyButtons"></div>')
  19. var urls = {
  20. changesetLinkedWorkItems: '/_apis/tfvc/changesets/{}/workItems',
  21. changesetInfo: '/_apis/tfvc/changesets/{}',
  22. apiVersion: '?api-version=1.0',
  23. }
  24.  
  25. waitForKeyElements('.ms-DetailsRow', doEverything, false)
  26. //waitForKeyElements(".vc-page-title[title^=Changeset]", addChangesetIdCopyUtilities, true)
  27.  
  28. //$(document).on('mouseenter', '.ms-DetailsRow', highlightHistoryResult)
  29. // .on('mouseleave', '.ms-DetailsRow', unhighlightHistoryResult)
  30.  
  31. function doEverything(historyResult) {
  32. historyResult = $(historyResult)
  33. spanifyText(historyResult)
  34. addCopyUtilities(historyResult)
  35. }
  36.  
  37. function spanifyText(historyResult) {
  38. // wraps changeset/task IDs with spans so they can be targeted individually
  39. // adds data to the newly-created spans
  40. historyResult.find('.ms-Link').each(function() {
  41. // commit messages may have either Tasks (deprecated in November 2016) or Changesets
  42. $(this).html($(this).text().replace(/[ct]\d{3,}/gi, function(match) {
  43. var id = match.replace(/[ct]/gi, '')
  44. if (match.startsWith('t')) {
  45. historyResult.data('ijgTaskId', id)
  46. return '<span class="ijg-task-id" data-ijg-task-id="' + id + '">' + match + '</span>'
  47. }
  48. return '<span class="ijg-changeset-id" data-ijg-changeset-id="' + id + '">' + match + '</span>'
  49. }))
  50. })
  51. historyResult.find('.change-info').each(function() {
  52. // '.ms-DetailsRow's will only have changesets, and they will not be prefixed with 'c'
  53. $(this).html($(this).text().replace(/\d{3,}/gi, function(match) {
  54. var changesetId = match.replace(/c/i, '')
  55. return '<span class="ijg-changeset-id" data-ijg-changeset-id="' + changesetId + '">' + match + '</span>'
  56. }))
  57. })
  58. }
  59.  
  60. function addCopyUtilities(historyResult) {
  61. var $container = containerTemplate.clone()
  62. var changesetId = historyResult.find('.ms-Link')[0].getAttribute('href').match(/\d+$/)[0]
  63. var url = historyResult.find('a.ms-Link').prop('href')
  64. var formattedUrl = '*Changeset ' + changesetId + ": " + historyResult.find('a.ms-Link').text() + '*\n' + url
  65. var message = createCommitMessage(historyResult, changesetId)
  66.  
  67. $container
  68. .append(buttonTemplate.clone().text(changesetId) .addClass('ijg-js-copyButton').data('ijgCopyText', changesetId))
  69. .append(buttonTemplate.clone().text('Link') .addClass('ijg-js-copyButton').data('ijgCopyText', formattedUrl))
  70. .append(buttonTemplate.clone().text('Merge Message').addClass('ijg-js-copyButton').data('ijgCopyText', message))
  71.  
  72. historyResult.find('.card-details-section').before($container)
  73.  
  74. // after the ajax call returns, append task IDs to the button
  75. addTaskUtilities(historyResult, function(taskIds) {
  76. var thisTaskButton
  77. if (taskIds.length <= 0) {
  78. // no task IDs to add. Might as well just stop here.
  79. tasksIds = ''
  80. thisTaskButton = buttonTemplate.clone().html('&nbsp;').addClass('ijg-js-copyButton ijg-js-copyTask').css('width', 56)
  81. $container.find('button').last().before(thisTaskButton)
  82. return
  83. }
  84.  
  85. taskIds = taskIds.reduce(function(prev, cur) {
  86. return prev + ', ' + cur
  87. })
  88.  
  89. thisTaskButton = buttonTemplate.clone().text('Task IDs').addClass('ijg-js-copyButton ijg-js-copyTask').data('ijgCopyText', taskIds)
  90. $container.find('button').last().before(thisTaskButton)
  91.  
  92. // merge "Task IDs" buttons vertically to group commits on the same task
  93. // first, store the data
  94. var idsKey = 'ijg-taskIds'
  95. var countKey = 'ijg-countMergeRows'
  96. historyResult.data(idsKey, taskIds).data(countKey, 1)
  97. // second, merge down if the row below already has taskIDs, and they are the same
  98. var next = historyResult.next()
  99. if (next.size() > 0 && next.data(idsKey) == taskIds) {
  100. // the next button matches this one. Merge into this one, and remove the next button
  101. var nextButton = next.find('.ijg-js-copyTask')
  102. }
  103. })
  104. }
  105.  
  106. function addTaskUtilities(historyResult, callback) {
  107. $.ajax({
  108. method: 'GET',
  109. dataType: 'json',
  110. url: window.location.origin + urls.changesetLinkedWorkItems.replace('{}', historyResult.find('a.ms-Link')[0].getAttribute('href').match(/\d+$/)[0]) + urls.apiVersion,
  111. success: function(data) {
  112. var idArray = []
  113. if (data !== undefined && data.count > 0) {
  114. idArray = data.value.map(function(el) {
  115. return el.id
  116. })
  117. }
  118. callback.call(historyResult, idArray)
  119. createTaskContainer(historyResult, idArray)
  120. }
  121. })
  122. }
  123.  
  124. function createTaskContainer(historyResult, idArray) {
  125. // makes a positioned div in the right place to hold Task info
  126. var container = $('<div class="ijg-tasks-container">')
  127. historyResult.append(container)
  128. idArray.forEach(function(taskId) {
  129. var $task = $('<div class=ijg-task-link>').data('ijgTaskId', taskId)
  130. var $link = $('<a target="_blank">')
  131. .text(taskId)
  132. .prop('href', window.location.origin + '/' + window.location.pathname.split('/')[1] + '/_workitems?id=' + taskId)
  133. container.append($task.append($link))
  134. })
  135. if (container[0].scrollHeight > container[0].offsetHeight) { // broken
  136. container.addClass('is-overflow')
  137. }
  138. }
  139.  
  140. function addChangesetIdCopyUtilities(pageTitle) {
  141. var $pageTitle = $(pageTitle)
  142.  
  143. if ($pageTitle.hasClass('added')) {
  144. return
  145. }
  146. $pageTitle.addClass('added')
  147.  
  148. var id = $pageTitle.text().replace('Changeset ', '')
  149. var $copyLinkInput = $('<input value="' + id + '">').addClass('ijg-copy-changeset-page-link')
  150.  
  151. $pageTitle.after($copyLinkInput)
  152. }
  153.  
  154. function highlightHistoryResult(e) {
  155. var changeset = $(this).data('changeList')
  156. var changesetId = changeset.changesetId
  157. var tasks
  158. var mainHistoryResult = $('.result-details .change-info .ijg-changeset-id[data-ijg-changeset-id=' + changesetId + ']').closest('.ms-DetailsRow')
  159. var matchingChangesets = $('span.ijg-changeset-id[data-ijg-changeset-id="' + changesetId + '"]')
  160.  
  161. if (matchingChangesets.size() > 1) {
  162. matchingChangesets.each(function() {
  163. var matchingChangesetId = $(this)
  164. matchingChangesetId.css('color', 'red').closest('.ms-DetailsRow').css('background-color', 'beige')
  165. })
  166. mainHistoryResult.css('background-color', '#D1D1A9')
  167. }
  168. }
  169. function unhighlightHistoryResult(e) {
  170. $('span.ijg-changeset-id').css('color', '').closest('.ms-DetailsRow').css('background-color', '')
  171. }
  172.  
  173. function displayResult($cursorContainer) {
  174. var cursorClass = 'ijg-check'
  175.  
  176. $cursorContainer.addClass(cursorClass)
  177. window.setTimeout(function() {
  178. $cursorContainer.removeClass(cursorClass)
  179. }, 1750)
  180. setGreenCheckCursor()
  181. }
  182.  
  183. /**
  184. If `historyResult` is a jQuery object, expect it to contain changelist data.
  185. If it is a string, expect it to be a selector string that contains the full commit message.
  186. */
  187. function createCommitMessage(historyResult, changesetId) {
  188. var optMessage = historyResult.find('a.ms-Link').text().trim()
  189.  
  190. if (optMessage.match(mergedChangesetRegex)) {
  191. // a changeset that's already merged to QA should merge to Release
  192. optMessage = optMessage.replace(mergedChangesetRegex, '(merge c' + changesetId + ' to Release)')
  193. } else {
  194. optMessage = '(merge c' + changesetId + ' to QA) ' + optMessage
  195. }
  196. return optMessage
  197. }
  198.  
  199.  
  200. ;(function addStyles () {
  201. var styles = '\
  202. .ms-DetailsRow {\
  203. position: relative;\
  204. }\
  205. .ms-DetailsRow .ms-DetailsRow-cell {\
  206. width: 100% !important;\
  207. }\
  208. .ms-DetailsRow-fields {\
  209. width: calc(100% - 58px);\
  210. }\
  211. .avatar-image-card {\
  212. display: flex;\
  213. }\
  214. span.ijg-changeset-id { \
  215. border-bottom: 1px dotted #ccc; \
  216. } \
  217. div > span.ijg-changeset-id { \
  218. cursor: default; \
  219. } \
  220. .ijg-copyButtons { \
  221. margin-left: 13px;\
  222. position: static;\
  223. } \
  224. .result-details { \
  225. padding-left: 276px;\
  226. } \
  227. input.ijg-copy-changeset-page-link {\
  228. cursor: pointer;\
  229. text-align: center;\
  230. width: 80px;\
  231. margin: 0 16px;\
  232. border: 1px solid #ccc;\
  233. vertical-align: middle; \
  234. }\
  235. .change-link-container { \
  236. display: inline-block; \
  237. }\
  238. .ijg-tasks-container {\
  239. top: 0; \
  240. right: 0;\
  241. height: 100%;\
  242. overflow-y: auto;\
  243. padding: 4px 8px 4px;\
  244. position: absolute;\
  245. }\
  246. .ijg-tasks-container.is-overflow {\
  247. border-bottom: 2px dashed red;\
  248. }\
  249. .ijg-tasks-container.is-overflow:hover {\
  250. border: 1px solid;\
  251. overflow: visible;\
  252. background-color: white;\
  253. max-height: initial;\
  254. z-index: 1;\
  255. }\
  256. .ijg-check {\
  257. cursor: url(data:image/svg+xml;utf8;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pgo8IS0tIEdlbmVyYXRvcjogQWRvYmUgSWxsdXN0cmF0b3IgMTYuMC4wLCBTVkcgRXhwb3J0IFBsdWctSW4gLiBTVkcgVmVyc2lvbjogNi4wMCBCdWlsZCAwKSAgLS0+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0iQ2FwYV8xIiB4PSIwcHgiIHk9IjBweCIgd2lkdGg9IjI0cHgiIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDQxNS41ODIgNDE1LjU4MiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgNDE1LjU4MiA0MTUuNTgyOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnPgoJPHBhdGggZD0iTTQxMS40Nyw5Ni40MjZsLTQ2LjMxOS00Ni4zMmMtNS40ODItNS40ODItMTQuMzcxLTUuNDgyLTE5Ljg1MywwTDE1Mi4zNDgsMjQzLjA1OGwtODIuMDY2LTgyLjA2NCAgIGMtNS40OC01LjQ4Mi0xNC4zNy01LjQ4Mi0xOS44NTEsMGwtNDYuMzE5LDQ2LjMyYy01LjQ4Miw1LjQ4MS01LjQ4MiwxNC4zNywwLDE5Ljg1MmwxMzguMzExLDEzOC4zMSAgIGMyLjc0MSwyLjc0Miw2LjMzNCw0LjExMiw5LjkyNiw0LjExMmMzLjU5MywwLDcuMTg2LTEuMzcsOS45MjYtNC4xMTJMNDExLjQ3LDExNi4yNzdjMi42MzMtMi42MzIsNC4xMTEtNi4yMDMsNC4xMTEtOS45MjUgICBDNDE1LjU4MiwxMDIuNjI4LDQxNC4xMDMsOTkuMDU5LDQxMS40Nyw5Ni40MjZ6IiBmaWxsPSIjMmQ5ZTFlIi8+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPC9zdmc+Cg==), auto !important;\
  258. }\
  259. button.ijg-copyButton {\
  260. margin-left: 8px;\
  261. margin-top: 10px;\
  262. padding: 2px 6px;\
  263. font-size: 12px;\
  264. }\
  265. .ijg-copyButton--extended {\
  266. vertical-align: top;\
  267. position: absolute;\
  268. }\
  269. .ijg-copyButton--extended + .ijg-copyButton {\
  270. margin-left: 72px;\
  271. }\
  272. .comments-indicator-container {\
  273. display: table-cell !important;\
  274. width: 28px;\
  275. }'
  276.  
  277. var animationStyles = '\
  278. button.ijg-copyButton {\
  279. transition: box-shadow 100ms, background-color 250ms 100ms linear, width 400ms, opacity 400ms, padding 400ms;\
  280. }\
  281. .fade {\
  282. opacity: 0 !important;\
  283. width: 0 !important;\
  284. padding: 2px 0 !important;\
  285. margin-left: 0 !important;\
  286. }\
  287. .offset .fade {\
  288. opacity: 1 !important;\
  289. width: 41px !important;\
  290. padding: 2px 6px !important;\
  291. margin-left: 8px !important;\
  292. }\
  293. .offset .result-details {\
  294. transition: padding-left 400ms -35ms;\
  295. }'
  296.  
  297. GM_addStyle(styles)
  298. //GM_addStyle(animationStyles)
  299. })()
  300.  
  301.  
  302.  
  303.  
  304. function waitForKeyElements(
  305. // CC BY-NC-SA 4.0. Author: BrockA
  306. selectorTxt,
  307. actionFunction,
  308. bWaitOnce
  309. ) {
  310. var targetNodes, btargetsFound;
  311.  
  312. targetNodes = $(selectorTxt);
  313. if (targetNodes && targetNodes.length > 0) {
  314. btargetsFound = true;
  315. /*--- Found target node(s). Go through each and act if they
  316. are new.
  317. */
  318. targetNodes.each(function() {
  319. var jThis = $(this);
  320. var alreadyFound = jThis.data('alreadyFound') || false;
  321.  
  322. if (!alreadyFound) {
  323. //--- Call the payload function.
  324. var cancelFound = actionFunction(jThis);
  325. if (cancelFound)
  326. btargetsFound = false;
  327. else
  328. jThis.data('alreadyFound', true);
  329. }
  330. });
  331. } else {
  332. btargetsFound = false;
  333. }
  334.  
  335. //--- Get the timer-control variable for this selector.
  336. var controlObj = waitForKeyElements.controlObj || {};
  337. var controlKey = selectorTxt.replace(/[^\w]/g, "_");
  338. var timeControl = controlObj[controlKey];
  339.  
  340. //--- Now set or clear the timer as appropriate.
  341. if (btargetsFound && bWaitOnce && timeControl) {
  342. //--- The only condition where we need to clear the timer.
  343. clearInterval(timeControl);
  344. delete controlObj[controlKey]
  345. } else {
  346. //--- Set a timer, if needed.
  347. if (!timeControl) {
  348. timeControl = setInterval(function() {
  349. waitForKeyElements(selectorTxt,
  350. actionFunction,
  351. bWaitOnce
  352. );
  353. },
  354. 300
  355. );
  356. controlObj[controlKey] = timeControl;
  357. }
  358. }
  359. waitForKeyElements.controlObj = controlObj;
  360. }
  361.  
  362. function setGreenCheckCursor() {
  363. /// from https://bugs.chromium.org/p/chromium/issues/detail?id=26723#c87
  364. if (document.body.style.cursor != cursorUrl) {
  365. var wkch = document.createElement("div");
  366. wkch.style.overflow = "hidden";
  367. wkch.style.position = "absolute";
  368. wkch.style.left = "0px";
  369. wkch.style.top = "0px";
  370. wkch.style.width = "100%";
  371. wkch.style.height = "100%";
  372. var wkch2 = document.createElement("div");
  373. wkch2.style.width = "200%";
  374. wkch2.style.height = "200%";
  375. wkch.appendChild(wkch2);
  376. document.body.appendChild(wkch);
  377. document.body.style.cursor = cursorUrl;
  378. wkch.scrollLeft = 1;
  379. wkch.scrollLeft = 0;
  380. document.body.removeChild(wkch);
  381. }
  382. }