TMS_Library

util lib for TMS related scripts

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greatest.deepsurf.us/scripts/486123/1324712/TMS_Library.js

  1. // ==UserScript==
  2. // @name TMS_Library
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.1.0
  5. // @description util lib for TMS related scripts
  6. // @author bliushtein
  7. // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
  8. // @grant GM_xmlhttpRequest
  9. // ==/UserScript==
  10.  
  11. class Constants {
  12. static DESIGN_GAP = "DesignGap";
  13. static CROSS_STREAM_GAP = "CrossStreamGap";
  14. static OVERHEAD_CATEGORIES = [Constants.DESIGN_GAP, Constants.CROSS_STREAM_GAP];
  15. static DEV_STORY = "DevStory";
  16. static DESIGN_STORY = "DesignChapter";
  17. static BA_COMMUNICATION = "BACommunication";
  18. static CROSS_STREAM_COMMUNICATION = "CrossStreamCommunication";
  19. static DEV_TEST = "DevTest";
  20. static IMPLEMENTATION_CATEGORY = "Implementation";
  21. static TASK_CATEGORIES = [Constants.BA_COMMUNICATION, Constants.CROSS_STREAM_COMMUNICATION, Constants.DEV_TEST, Constants.IMPLEMENTATION_CATEGORY];
  22. static LINK_TYPE_IMPLEMENTATION = "10300";
  23. static ISSUE_TYPE_STORY = "17";
  24. static ISSUE_TYPE_EPIC = "16";
  25. static ISSUE_TYPE_TASK = "15";
  26. static ISSUE_TYPE_DEV_TASK = "9";
  27. static DU_PROJECT_ID = "37307";
  28. }
  29.  
  30. function delay(milliseconds) {
  31. return new Promise(resolve => {
  32. setTimeout(resolve, milliseconds);
  33. });
  34. }
  35.  
  36. class TmsApi {
  37. static TMS_URL = "https://tms.netcracker.com";
  38. static ISSUE_LINK_PREFIX = TmsApi.TMS_URL + `/browse/`;
  39. static REST_API_URL_PREFIX = TmsApi.TMS_URL + `/rest/api/latest`;
  40.  
  41. static sendRequest(url, method = 'GET', body = null) {
  42. console.log(url, method, body);
  43. return new Promise((resolve, reject) => {
  44. GM_xmlhttpRequest({
  45. method: method,
  46. timeout: 5000,
  47. onerror: reject,
  48. ontimeout: reject,
  49. onload: resolve,
  50. headers: {
  51. Accept: 'application/json',
  52. 'Content-Type': 'application/json',
  53. // If a user agent is not passed - a POST request fails with 403 error
  54. 'User-Agent': 'Any',
  55. },
  56. data: body,
  57. url: url,
  58. });
  59. }).then(response => {
  60. if ([200, 201].indexOf(response.status) !== -1) {
  61. if (response.responseText == null) {
  62. return {};
  63. }
  64. return JSON.parse(response.responseText);
  65. }
  66. throw new Error(response.status + ' ' + response.statusText + ' ' + response.responseText);
  67. });
  68. }
  69.  
  70. static async getIssue(key, fields = ["issuelinks","timetracking","components","issuetype","labels","status"]) {
  71. return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issue/${key}?fields=${fields.join(",")}`);
  72. }
  73.  
  74. static async getIssues(keys, fields = ["timetracking", "components", "issuetype", "labels", "priority", "customfield_10200", "customfield_10201", "customfield_10006", "summary", "status", "issuelinks"]) {
  75. if (keys.length == 0) {
  76. return {issues: []};
  77. }
  78. return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/search?jql=issuekey IN (${keys.join(",")})&fields=${fields.join(",")}`);
  79. }
  80.  
  81. static async getSubtasks(keys, fields = ["timetracking", "components", "issuetype", "labels", "status", "parent"]) {
  82. if (keys.length == 0) {
  83. return {issues: []};
  84. }
  85. return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/search?jql=parent IN (${keys.join(",")})&fields=${fields.join(",")}`);
  86. }
  87.  
  88. static async createIssueLink(issue1, issue2, linkType) {
  89. const request = {
  90. type: {id: linkType},
  91. inwardIssue: {key: issue1},
  92. outwardIssue: {key: issue2}
  93. };
  94. await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issueLink`, "POST", JSON.stringify(request));
  95. }
  96.  
  97. static async createDevStoryFromDesignStory(designStory, epicKey, assignee = null) {
  98. const request = {
  99. fields : {
  100. priority: {id: designStory.fields.priority.id},
  101. labels: [Constants.DEV_STORY],
  102. assignee: {name: assignee},
  103. components: [{id: designStory.fields.components[0].id}],
  104. customfield_10200: designStory.fields.customfield_10200, //external issue link
  105. customfield_10201: designStory.fields.customfield_10201, //external issue key
  106. issuetype: {id: Constants.ISSUE_TYPE_STORY},
  107. project: {id: Constants.DU_PROJECT_ID},
  108. customfield_10006: designStory.fields.customfield_10006, //Epic Link
  109. summary: designStory.fields.summary.replace("[BA]", "[DEV]")
  110. }
  111. };
  112. const issue_info = await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issue`, "POST", JSON.stringify(request));
  113. await TmsApi.createIssueLink(issue_info.key, designStory.key, Constants.LINK_TYPE_IMPLEMENTATION);
  114. await TmsApi.createIssueLink(issue_info.key, epicKey, Constants.LINK_TYPE_IMPLEMENTATION);
  115. return issue_info.key;
  116. }
  117.  
  118. static async getRelatedStories(keys, fields = ["timetracking", "components", "issuetype", "labels", "priority", "customfield_10200", "customfield_10201", "customfield_10006", "summary", "status", "issuelinks"]) {
  119. if (keys.length == 0) {
  120. return {issues: []};
  121. }
  122. const keysStr = keys.join(",");
  123. return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/search?jql=(issue in linkedIssuesOf("issuekey in (${keysStr})", "is implemented by") or parent in (${keysStr})) and type = Story&fields=${fields.join(",")}`);
  124. }
  125.  
  126. static getLinkToIssue(key) {
  127. return TmsApi.ISSUE_LINK_PREFIX + key;
  128. }
  129. }
  130.  
  131. class TmsTask {
  132. #errors;
  133. #issueInfo;
  134. #component;
  135. #category;
  136. #overheadCategory;
  137.  
  138. constructor(issueInfo) {
  139. this.#errors = [];
  140. this.#issueInfo = issueInfo;
  141. if (issueInfo.fields.components == null) {
  142. this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should have 1 component. Actual amount = 0`});
  143. } else if (issueInfo.fields.components.length != 1) {
  144. this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should have 1 component. Actual amount = ${issueInfo.fields.components.length}`});
  145. }
  146. if (issueInfo.fields.components == null || issueInfo.fields.components.length == 0) {
  147. this.#component = null;
  148. } else {
  149. this.#component = issueInfo.fields.components[0].name;
  150. }
  151. const categoryLabels = this.#issueInfo.fields.labels.filter(label => Constants.TASK_CATEGORIES.includes(label));
  152. if (categoryLabels.length > 1) {
  153. this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should't have more then one task category. Actual amount = ${categoryLabels.length}`});
  154. this.#category = categoryLabels[0];
  155. } else if (categoryLabels.length == 1) {
  156. this.#category = categoryLabels[0];
  157. } else {
  158. this.#category = Constants.IMPLEMENTATION_CATEGORY;
  159. }
  160. const overheadCategoryLabels = this.#issueInfo.fields.labels.filter(label => Constants.OVERHEAD_CATEGORIES.includes(label));
  161. if (overheadCategoryLabels.length > 1) {
  162. this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should't have more then one overhead category. Actual amount = ${categoryLabels.length}`});
  163. this.#overheadCategory = overheadCategoryLabels[0];
  164. } else if (overheadCategoryLabels.length == 1) {
  165. this.#overheadCategory = overheadCategoryLabels[0];
  166. } else {
  167. this.#overheadCategory = null;
  168. }
  169. }
  170.  
  171. get errors() {
  172. return this.#errors;
  173. }
  174.  
  175. get key() {
  176. return this.#issueInfo.key;
  177. }
  178.  
  179. get component() {
  180. return this.#component;
  181. }
  182.  
  183. get category() {
  184. return this.#category;
  185. }
  186.  
  187. get overheadCategory() {
  188. return this.#overheadCategory;
  189. }
  190.  
  191. get parent() {
  192. if (this.#issueInfo.fields.parent == null) {
  193. return null;
  194. } else {
  195. return this.#issueInfo.fields.parent.key;
  196. }
  197. }
  198.  
  199. get originalEstimate() {
  200. if (this.#issueInfo.fields.timetracking == null || this.#issueInfo.fields.timetracking.originalEstimateSeconds == null) {
  201. return 0;
  202. } else {
  203. return this.#issueInfo.fields.timetracking.originalEstimateSeconds;
  204. }
  205. }
  206.  
  207. get loggedTime() {
  208. if (this.#issueInfo.fields.timetracking == null || this.#issueInfo.fields.timetracking.timeSpentSeconds == null) {
  209. return 0;
  210. } else {
  211. return this.#issueInfo.fields.timetracking.timeSpentSeconds;
  212. }
  213. }
  214.  
  215. get type() {
  216. return this.#issueInfo.fields.issuetype.id;
  217. }
  218.  
  219. }
  220.  
  221. class TmsStory {
  222. #errors;
  223. #stotyType;
  224. #issueInfo;
  225. #isDevStory;
  226. #isDesignStory;
  227. #component;
  228. #subtasks;
  229. #subtasksFilled;
  230. #timeTracking;
  231. #timeTrackingFilled;
  232.  
  233. constructor(issueInfo) {
  234. this.#issueInfo = issueInfo;
  235. this.#isDevStory = this.hasLabel(Constants.DEV_STORY);
  236. this.#isDesignStory = this.hasLabel(Constants.DESIGN_STORY);
  237. this.#errors = [];
  238. this.#timeTrackingFilled = false;
  239. this.#subtasksFilled = false;
  240. if (issueInfo.fields.components == null) {
  241. this.#errors.push({ issue: this.key, message: `Story ${issueInfo.key} should have 1 component. Actual amount = 0`});
  242. } else if (issueInfo.fields.components.length != 1) {
  243. this.#errors.push({ issue: this.key, message: `Story ${issueInfo.key} should have 1 component. Actual amount = ${issueInfo.fields.components.length}`});
  244. }
  245. if (issueInfo.fields.components == null || issueInfo.fields.components.length == 0) {
  246. this.#component = null;
  247. } else {
  248. this.#component = issueInfo.fields.components[0].name;
  249. }
  250. }
  251.  
  252. get errors() {
  253. return this.#errors;
  254. }
  255.  
  256. get key() {
  257. return this.#issueInfo.key;
  258. }
  259.  
  260. get epicKey() {
  261. return this.#issueInfo.fields.customfield_10006;
  262. }
  263.  
  264. get isDevStory() {
  265. return this.#isDevStory;
  266. }
  267.  
  268. get isDesignStory() {
  269. return this.#isDesignStory;
  270. }
  271.  
  272. get component() {
  273. return this.#component;
  274. }
  275.  
  276. get issueInfo() {//TODO avoid direct usage of json
  277. return this.#issueInfo;
  278. }
  279.  
  280. get timeTracking() {
  281. if (this.#timeTrackingFilled) {
  282. return this.#timeTracking;
  283. } else {
  284. throw new Error(`Time tracking info for ${this.key} is not calculated yet`);
  285. }
  286. }
  287.  
  288. get subtasks() {
  289. if (this.#subtasksFilled) {
  290. return this.#subtasks;
  291. } else {
  292. throw new Error(`Time subtasks for ${this.key} are not filled yet`);
  293. }
  294. }
  295.  
  296. fillSubtasks(subtasks) {
  297. this.#subtasks = [];
  298. for (const taskInfo of subtasks) {
  299. const task = new TmsTask(taskInfo);
  300. if (task.parent != this.key) {
  301. continue;
  302. }
  303. this.#subtasks.push(task);
  304. }
  305. this.#subtasksFilled = true;
  306. if (!this.isDevStory) {
  307. this.#timeTrackingFilled = false;
  308. return;
  309. }
  310. this.#timeTracking = new TimeTracking();
  311. let originalEstimate = 0;
  312. for (const task of this.#subtasks) {
  313. if (task.type != Constants.ISSUE_TYPE_DEV_TASK && task.type != Constants.ISSUE_TYPE_TASK) {
  314. continue;
  315. }
  316. if (task.loggedTime > 0) {
  317. this.#timeTracking.logTime(task.category, task.loggedTime);
  318. }
  319. if (task.overheadCategory == null) {
  320. originalEstimate += task.originalEstimate;
  321. }
  322. }
  323. this.#timeTracking.originalEstimate = originalEstimate;
  324. this.#timeTrackingFilled = true;
  325. }
  326.  
  327. get status() {
  328. return this.#issueInfo.fields.status.name;
  329. }
  330.  
  331. hasLabel(label) {
  332. return this.#issueInfo.fields.labels.includes(label);
  333. }
  334.  
  335. getAllErrors() {
  336. const errors = [... this.#errors];
  337. if (this.#subtasksFilled) {
  338. for (const subtask of this.#subtasks) {
  339. errors.push(...subtask.errors);
  340. }
  341. }
  342. return errors;
  343. }
  344. }
  345.  
  346. class ComponentDetails {
  347. #devStoryExists;
  348. #designStoryExists;
  349. #devStory;
  350. #designStory;
  351.  
  352. constructor() {
  353. this.#devStoryExists = false;
  354. this.#designStoryExists = false;
  355. this.#devStory = null;
  356. this.#designStory = null;
  357. }
  358.  
  359. get devStoryExists() {
  360. return this.#devStoryExists;
  361. }
  362.  
  363. get designStoryExists() {
  364. return this.#designStoryExists;
  365. }
  366.  
  367. get devStory() {
  368. return this.#devStory;
  369. }
  370.  
  371. set devStory(story) {
  372. if (this.#devStoryExists) {
  373. throw new Error("Dev story is already filled");
  374. }
  375. this.#devStoryExists = true;
  376. this.#devStory = story;
  377. }
  378.  
  379. get designStory() {
  380. return this.#designStory;
  381. }
  382.  
  383. set designStory(story) {
  384. if (this.#designStoryExists) {
  385. throw new Error("Design story is already filled");
  386. }
  387. this.#designStoryExists = true;
  388. this.#designStory = story;
  389. }
  390.  
  391. setStory(story, type) {
  392. if (type == Constants.DEV_STORY) {
  393. this.devStory = story;
  394. } else if (type == Constants.DESIGN_STORY) {
  395. this.designStory = story;
  396. }
  397. }
  398.  
  399. isStoryExists(type) {
  400. if (type == Constants.DEV_STORY) {
  401. return this.devStoryExists;
  402. } else if (type == Constants.DESIGN_STORY) {
  403. return this.designStoryExists;
  404. }
  405. }
  406.  
  407. }
  408.  
  409. class TimeTracking {
  410. #originalEstimate;
  411. #loggedTimeByCategory;
  412. constructor() {
  413. this.#originalEstimate = null;
  414. this.#loggedTimeByCategory = {};
  415. }
  416.  
  417. get originalEstimate() {
  418. return this.#originalEstimate;
  419. }
  420.  
  421. set originalEstimate(estimate) {
  422. if (estimate >= 0) {
  423. this.#originalEstimate = estimate;
  424. } else {
  425. throw new Error(`Original Estimate should be positive. Current value = ${estimate}`);
  426. }
  427. }
  428.  
  429. getExistingCategories() {
  430. return Object.keys(this.#loggedTimeByCategory);
  431. }
  432.  
  433. getLoggedTimeByCategory(category) {
  434. return this.#loggedTimeByCategory[category];
  435. }
  436.  
  437. logTime(category, time) {
  438. if (time >= 0) {
  439. if (this.#loggedTimeByCategory[category] == null) {
  440. this.#loggedTimeByCategory[category] = 0;
  441. }
  442. this.#loggedTimeByCategory[category] += time;
  443. } else {
  444. throw new Error(`Logged time should be positive. Current value = ${time}`);
  445. }
  446. }
  447.  
  448. get total() {
  449. let total = 0;
  450. for (const category of this.getExistingCategories()) {
  451. total += this.#loggedTimeByCategory[category];
  452. }
  453. return total;
  454. }
  455. }
  456.  
  457. class TmsEpic {
  458. #errors;
  459. #issueInfo;
  460. #components;
  461. #componentsDetails;
  462. #componentsDetailsFilled;
  463. #linkedStoryKeys;
  464. constructor(issueInfo, relatedStories = null) {
  465. this.#issueInfo = issueInfo;
  466. this.#errors = [];
  467. this.#components = [];
  468. this.#componentsDetails = {};
  469. this.#componentsDetailsFilled = false;
  470. this.#linkedStoryKeys = [];
  471. if (issueInfo.fields.components != null) {
  472. for (const comp of issueInfo.fields.components) {
  473. this.#componentsDetails[comp.name] = new ComponentDetails();
  474. this.#components.push(comp.name);
  475. }
  476. }
  477. for (const link of issueInfo.fields.issuelinks) {
  478. if (link.type.id == Constants.LINK_TYPE_IMPLEMENTATION && link.inwardIssue != null && link.inwardIssue.fields.issuetype.id == Constants.ISSUE_TYPE_STORY) {
  479. this.#linkedStoryKeys.push(link.inwardIssue.key);
  480. }
  481. }
  482. if (relatedStories != null) {
  483. this.fillComponentsDetails(relatedStories);
  484. }
  485. }
  486.  
  487. get errors() {
  488. return this.#errors;
  489. }
  490.  
  491. get key() {
  492. return this.#issueInfo.key;
  493. }
  494.  
  495. get components() {
  496. return this.#components.slice();
  497. }
  498.  
  499. get componentsDetails() {
  500. if (!this.#componentsDetailsFilled) {
  501. throw new Error(`Components details for epic ${this.key} are not calculated yet`);
  502. }
  503. return this.#componentsDetails;//Need to clone object
  504. }
  505.  
  506. isRelatedStory(story) {
  507. if (story.component == null) {
  508. return false;
  509. }
  510. if (story.epicKey == this.key) {
  511. return true;
  512. }
  513. return this.#linkedStoryKeys.includes(story.key);
  514. }
  515.  
  516. fillComponentsDetails(relatedStories) {
  517. if (this.#componentsDetailsFilled) {
  518. throw new Error(`Components details for epic ${this.key} are already calculated`);
  519. }
  520. for (const story of relatedStories) {
  521. if (this.isRelatedStory(story)) {
  522. if (!this.#components.includes(story.component)) {
  523. this.#componentsDetails[story.component] = new ComponentDetails();
  524. this.#errors.push({issue: this.key, message: `Component ${story.component} of linked story ${story.key} is not added in epic`});
  525. }
  526. const componentDetails = this.#componentsDetails[story.component];
  527. if (story.isDevStory) {
  528. if (componentDetails.devStoryExists) {
  529. this.#errors.push({issue: this.key, message: `More than one dev story with component ${story.component} is linked to epic`});
  530. } else {
  531. componentDetails.devStory = story;
  532. }
  533. }
  534. if (story.isDesignStory) {
  535. if (componentDetails.designStoryExists) {
  536. this.#errors.push({issue: this.key, message: `More than one design story with component ${story.component} is linked to epic`});
  537. } else {
  538. componentDetails.designStory = story;
  539. }
  540. }
  541. }
  542. }
  543. this.#componentsDetailsFilled = true;
  544. }
  545.  
  546. fillSubtasks(subtasks) {
  547. if (!this.#componentsDetailsFilled) {
  548. throw new Error(`Components details for epic ${this.key} are not calculated yet`);
  549. }
  550. for (const component of Object.keys(this.#componentsDetails)) {
  551. const componentDetails = this.#componentsDetails[component];
  552. if (componentDetails.devStoryExists) {
  553. componentDetails.devStory.fillSubtasks(subtasks);
  554. }
  555. }
  556. }
  557.  
  558. getAllErrors() {
  559. const errors = [... this.#errors];
  560. if (this.#componentsDetailsFilled) {
  561. for (const component of Object.keys(this.#componentsDetails)) {
  562. const componentDetails = this.#componentsDetails[component];
  563. if (componentDetails.devStoryExists) {
  564. errors.push(...componentDetails.devStory.getAllErrors());
  565. }
  566. if (componentDetails.designStoryExists) {
  567. errors.push(...componentDetails.designStory.getAllErrors());
  568. }
  569. }
  570. }
  571. return errors;
  572. }
  573. }