TFS 2017 Changeset History Helper

Changeset reference utilities

目前为 2017-01-19 提交的版本,查看 最新版本

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