您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
util lib for TMS related scripts
此脚本不应直接安装,它是供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://updategreasyfork.deno.dev/scripts/486123/1324712/TMS_Library.js
// ==UserScript== // @name TMS_Library // @namespace http://tampermonkey.net/ // @version 1.1.0 // @description util lib for TMS related scripts // @author bliushtein // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @grant GM_xmlhttpRequest // ==/UserScript== class Constants { static DESIGN_GAP = "DesignGap"; static CROSS_STREAM_GAP = "CrossStreamGap"; static OVERHEAD_CATEGORIES = [Constants.DESIGN_GAP, Constants.CROSS_STREAM_GAP]; static DEV_STORY = "DevStory"; static DESIGN_STORY = "DesignChapter"; static BA_COMMUNICATION = "BACommunication"; static CROSS_STREAM_COMMUNICATION = "CrossStreamCommunication"; static DEV_TEST = "DevTest"; static IMPLEMENTATION_CATEGORY = "Implementation"; static TASK_CATEGORIES = [Constants.BA_COMMUNICATION, Constants.CROSS_STREAM_COMMUNICATION, Constants.DEV_TEST, Constants.IMPLEMENTATION_CATEGORY]; static LINK_TYPE_IMPLEMENTATION = "10300"; static ISSUE_TYPE_STORY = "17"; static ISSUE_TYPE_EPIC = "16"; static ISSUE_TYPE_TASK = "15"; static ISSUE_TYPE_DEV_TASK = "9"; static DU_PROJECT_ID = "37307"; } function delay(milliseconds) { return new Promise(resolve => { setTimeout(resolve, milliseconds); }); } class TmsApi { static TMS_URL = "https://tms.netcracker.com"; static ISSUE_LINK_PREFIX = TmsApi.TMS_URL + `/browse/`; static REST_API_URL_PREFIX = TmsApi.TMS_URL + `/rest/api/latest`; static sendRequest(url, method = 'GET', body = null) { console.log(url, method, body); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: method, timeout: 5000, onerror: reject, ontimeout: reject, onload: resolve, headers: { Accept: 'application/json', 'Content-Type': 'application/json', // If a user agent is not passed - a POST request fails with 403 error 'User-Agent': 'Any', }, data: body, url: url, }); }).then(response => { if ([200, 201].indexOf(response.status) !== -1) { if (response.responseText == null) { return {}; } return JSON.parse(response.responseText); } throw new Error(response.status + ' ' + response.statusText + ' ' + response.responseText); }); } static async getIssue(key, fields = ["issuelinks","timetracking","components","issuetype","labels","status"]) { return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issue/${key}?fields=${fields.join(",")}`); } static async getIssues(keys, fields = ["timetracking", "components", "issuetype", "labels", "priority", "customfield_10200", "customfield_10201", "customfield_10006", "summary", "status", "issuelinks"]) { if (keys.length == 0) { return {issues: []}; } return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/search?jql=issuekey IN (${keys.join(",")})&fields=${fields.join(",")}`); } static async getSubtasks(keys, fields = ["timetracking", "components", "issuetype", "labels", "status", "parent"]) { if (keys.length == 0) { return {issues: []}; } return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/search?jql=parent IN (${keys.join(",")})&fields=${fields.join(",")}`); } static async createIssueLink(issue1, issue2, linkType) { const request = { type: {id: linkType}, inwardIssue: {key: issue1}, outwardIssue: {key: issue2} }; await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issueLink`, "POST", JSON.stringify(request)); } static async createDevStoryFromDesignStory(designStory, epicKey, assignee = null) { const request = { fields : { priority: {id: designStory.fields.priority.id}, labels: [Constants.DEV_STORY], assignee: {name: assignee}, components: [{id: designStory.fields.components[0].id}], customfield_10200: designStory.fields.customfield_10200, //external issue link customfield_10201: designStory.fields.customfield_10201, //external issue key issuetype: {id: Constants.ISSUE_TYPE_STORY}, project: {id: Constants.DU_PROJECT_ID}, customfield_10006: designStory.fields.customfield_10006, //Epic Link summary: designStory.fields.summary.replace("[BA]", "[DEV]") } }; const issue_info = await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issue`, "POST", JSON.stringify(request)); await TmsApi.createIssueLink(issue_info.key, designStory.key, Constants.LINK_TYPE_IMPLEMENTATION); await TmsApi.createIssueLink(issue_info.key, epicKey, Constants.LINK_TYPE_IMPLEMENTATION); return issue_info.key; } static async getRelatedStories(keys, fields = ["timetracking", "components", "issuetype", "labels", "priority", "customfield_10200", "customfield_10201", "customfield_10006", "summary", "status", "issuelinks"]) { if (keys.length == 0) { return {issues: []}; } const keysStr = keys.join(","); 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(",")}`); } static getLinkToIssue(key) { return TmsApi.ISSUE_LINK_PREFIX + key; } } class TmsTask { #errors; #issueInfo; #component; #category; #overheadCategory; constructor(issueInfo) { this.#errors = []; this.#issueInfo = issueInfo; if (issueInfo.fields.components == null) { this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should have 1 component. Actual amount = 0`}); } else if (issueInfo.fields.components.length != 1) { this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should have 1 component. Actual amount = ${issueInfo.fields.components.length}`}); } if (issueInfo.fields.components == null || issueInfo.fields.components.length == 0) { this.#component = null; } else { this.#component = issueInfo.fields.components[0].name; } const categoryLabels = this.#issueInfo.fields.labels.filter(label => Constants.TASK_CATEGORIES.includes(label)); if (categoryLabels.length > 1) { this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should't have more then one task category. Actual amount = ${categoryLabels.length}`}); this.#category = categoryLabels[0]; } else if (categoryLabels.length == 1) { this.#category = categoryLabels[0]; } else { this.#category = Constants.IMPLEMENTATION_CATEGORY; } const overheadCategoryLabels = this.#issueInfo.fields.labels.filter(label => Constants.OVERHEAD_CATEGORIES.includes(label)); if (overheadCategoryLabels.length > 1) { this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should't have more then one overhead category. Actual amount = ${categoryLabels.length}`}); this.#overheadCategory = overheadCategoryLabels[0]; } else if (overheadCategoryLabels.length == 1) { this.#overheadCategory = overheadCategoryLabels[0]; } else { this.#overheadCategory = null; } } get errors() { return this.#errors; } get key() { return this.#issueInfo.key; } get component() { return this.#component; } get category() { return this.#category; } get overheadCategory() { return this.#overheadCategory; } get parent() { if (this.#issueInfo.fields.parent == null) { return null; } else { return this.#issueInfo.fields.parent.key; } } get originalEstimate() { if (this.#issueInfo.fields.timetracking == null || this.#issueInfo.fields.timetracking.originalEstimateSeconds == null) { return 0; } else { return this.#issueInfo.fields.timetracking.originalEstimateSeconds; } } get loggedTime() { if (this.#issueInfo.fields.timetracking == null || this.#issueInfo.fields.timetracking.timeSpentSeconds == null) { return 0; } else { return this.#issueInfo.fields.timetracking.timeSpentSeconds; } } get type() { return this.#issueInfo.fields.issuetype.id; } } class TmsStory { #errors; #stotyType; #issueInfo; #isDevStory; #isDesignStory; #component; #subtasks; #subtasksFilled; #timeTracking; #timeTrackingFilled; constructor(issueInfo) { this.#issueInfo = issueInfo; this.#isDevStory = this.hasLabel(Constants.DEV_STORY); this.#isDesignStory = this.hasLabel(Constants.DESIGN_STORY); this.#errors = []; this.#timeTrackingFilled = false; this.#subtasksFilled = false; if (issueInfo.fields.components == null) { this.#errors.push({ issue: this.key, message: `Story ${issueInfo.key} should have 1 component. Actual amount = 0`}); } else if (issueInfo.fields.components.length != 1) { this.#errors.push({ issue: this.key, message: `Story ${issueInfo.key} should have 1 component. Actual amount = ${issueInfo.fields.components.length}`}); } if (issueInfo.fields.components == null || issueInfo.fields.components.length == 0) { this.#component = null; } else { this.#component = issueInfo.fields.components[0].name; } } get errors() { return this.#errors; } get key() { return this.#issueInfo.key; } get epicKey() { return this.#issueInfo.fields.customfield_10006; } get isDevStory() { return this.#isDevStory; } get isDesignStory() { return this.#isDesignStory; } get component() { return this.#component; } get issueInfo() {//TODO avoid direct usage of json return this.#issueInfo; } get timeTracking() { if (this.#timeTrackingFilled) { return this.#timeTracking; } else { throw new Error(`Time tracking info for ${this.key} is not calculated yet`); } } get subtasks() { if (this.#subtasksFilled) { return this.#subtasks; } else { throw new Error(`Time subtasks for ${this.key} are not filled yet`); } } fillSubtasks(subtasks) { this.#subtasks = []; for (const taskInfo of subtasks) { const task = new TmsTask(taskInfo); if (task.parent != this.key) { continue; } this.#subtasks.push(task); } this.#subtasksFilled = true; if (!this.isDevStory) { this.#timeTrackingFilled = false; return; } this.#timeTracking = new TimeTracking(); let originalEstimate = 0; for (const task of this.#subtasks) { if (task.type != Constants.ISSUE_TYPE_DEV_TASK && task.type != Constants.ISSUE_TYPE_TASK) { continue; } if (task.loggedTime > 0) { this.#timeTracking.logTime(task.category, task.loggedTime); } if (task.overheadCategory == null) { originalEstimate += task.originalEstimate; } } this.#timeTracking.originalEstimate = originalEstimate; this.#timeTrackingFilled = true; } get status() { return this.#issueInfo.fields.status.name; } hasLabel(label) { return this.#issueInfo.fields.labels.includes(label); } getAllErrors() { const errors = [... this.#errors]; if (this.#subtasksFilled) { for (const subtask of this.#subtasks) { errors.push(...subtask.errors); } } return errors; } } class ComponentDetails { #devStoryExists; #designStoryExists; #devStory; #designStory; constructor() { this.#devStoryExists = false; this.#designStoryExists = false; this.#devStory = null; this.#designStory = null; } get devStoryExists() { return this.#devStoryExists; } get designStoryExists() { return this.#designStoryExists; } get devStory() { return this.#devStory; } set devStory(story) { if (this.#devStoryExists) { throw new Error("Dev story is already filled"); } this.#devStoryExists = true; this.#devStory = story; } get designStory() { return this.#designStory; } set designStory(story) { if (this.#designStoryExists) { throw new Error("Design story is already filled"); } this.#designStoryExists = true; this.#designStory = story; } setStory(story, type) { if (type == Constants.DEV_STORY) { this.devStory = story; } else if (type == Constants.DESIGN_STORY) { this.designStory = story; } } isStoryExists(type) { if (type == Constants.DEV_STORY) { return this.devStoryExists; } else if (type == Constants.DESIGN_STORY) { return this.designStoryExists; } } } class TimeTracking { #originalEstimate; #loggedTimeByCategory; constructor() { this.#originalEstimate = null; this.#loggedTimeByCategory = {}; } get originalEstimate() { return this.#originalEstimate; } set originalEstimate(estimate) { if (estimate >= 0) { this.#originalEstimate = estimate; } else { throw new Error(`Original Estimate should be positive. Current value = ${estimate}`); } } getExistingCategories() { return Object.keys(this.#loggedTimeByCategory); } getLoggedTimeByCategory(category) { return this.#loggedTimeByCategory[category]; } logTime(category, time) { if (time >= 0) { if (this.#loggedTimeByCategory[category] == null) { this.#loggedTimeByCategory[category] = 0; } this.#loggedTimeByCategory[category] += time; } else { throw new Error(`Logged time should be positive. Current value = ${time}`); } } get total() { let total = 0; for (const category of this.getExistingCategories()) { total += this.#loggedTimeByCategory[category]; } return total; } } class TmsEpic { #errors; #issueInfo; #components; #componentsDetails; #componentsDetailsFilled; #linkedStoryKeys; constructor(issueInfo, relatedStories = null) { this.#issueInfo = issueInfo; this.#errors = []; this.#components = []; this.#componentsDetails = {}; this.#componentsDetailsFilled = false; this.#linkedStoryKeys = []; if (issueInfo.fields.components != null) { for (const comp of issueInfo.fields.components) { this.#componentsDetails[comp.name] = new ComponentDetails(); this.#components.push(comp.name); } } for (const link of issueInfo.fields.issuelinks) { if (link.type.id == Constants.LINK_TYPE_IMPLEMENTATION && link.inwardIssue != null && link.inwardIssue.fields.issuetype.id == Constants.ISSUE_TYPE_STORY) { this.#linkedStoryKeys.push(link.inwardIssue.key); } } if (relatedStories != null) { this.fillComponentsDetails(relatedStories); } } get errors() { return this.#errors; } get key() { return this.#issueInfo.key; } get components() { return this.#components.slice(); } get componentsDetails() { if (!this.#componentsDetailsFilled) { throw new Error(`Components details for epic ${this.key} are not calculated yet`); } return this.#componentsDetails;//Need to clone object } isRelatedStory(story) { if (story.component == null) { return false; } if (story.epicKey == this.key) { return true; } return this.#linkedStoryKeys.includes(story.key); } fillComponentsDetails(relatedStories) { if (this.#componentsDetailsFilled) { throw new Error(`Components details for epic ${this.key} are already calculated`); } for (const story of relatedStories) { if (this.isRelatedStory(story)) { if (!this.#components.includes(story.component)) { this.#componentsDetails[story.component] = new ComponentDetails(); this.#errors.push({issue: this.key, message: `Component ${story.component} of linked story ${story.key} is not added in epic`}); } const componentDetails = this.#componentsDetails[story.component]; if (story.isDevStory) { if (componentDetails.devStoryExists) { this.#errors.push({issue: this.key, message: `More than one dev story with component ${story.component} is linked to epic`}); } else { componentDetails.devStory = story; } } if (story.isDesignStory) { if (componentDetails.designStoryExists) { this.#errors.push({issue: this.key, message: `More than one design story with component ${story.component} is linked to epic`}); } else { componentDetails.designStory = story; } } } } this.#componentsDetailsFilled = true; } fillSubtasks(subtasks) { if (!this.#componentsDetailsFilled) { throw new Error(`Components details for epic ${this.key} are not calculated yet`); } for (const component of Object.keys(this.#componentsDetails)) { const componentDetails = this.#componentsDetails[component]; if (componentDetails.devStoryExists) { componentDetails.devStory.fillSubtasks(subtasks); } } } getAllErrors() { const errors = [... this.#errors]; if (this.#componentsDetailsFilled) { for (const component of Object.keys(this.#componentsDetails)) { const componentDetails = this.#componentsDetails[component]; if (componentDetails.devStoryExists) { errors.push(...componentDetails.devStory.getAllErrors()); } if (componentDetails.designStoryExists) { errors.push(...componentDetails.designStory.getAllErrors()); } } } return errors; } }