🏠 Home 

TFS 2017 Changeset History Helper

Changeset reference utilities


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