Greasy Fork is available in English.

TMS_Library

util lib for TMS related scripts

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyfork.org/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;
    }
}