Greasy Fork is available in English.

Gitlab Issues Track

Savior of bug track in Gitlab issue!

// ==UserScript==
// @name         Gitlab Issues Track
// @namespace    http://tampermonkey.net/
// @homepage     https://github.com/Priestch/savior
// @version      0.3.15
// @description  Savior of bug track in Gitlab issue!
// @author       Priestch
// @match        https://gitpd.paodingai.com/*/issues/*
// @grant        GM_addStyle
// @grant        unsafeWindow
// ==/UserScript==

(function () {
  'use strict';

  const ADMIN_KEY = 'SUPER_TEST_USERS';
  const EXPORT_FORMAT = 'GITLAB_ISSUE_EXPORT_FILENAME_FORMAT';
  const issueHelper = {
    addTestUser(username) {
      const users = localStorage.getItem(ADMIN_KEY) || [];
      users.push(username);

      localStorage.setItem(ADMIN_KEY, users);
    },
    setTestUsers(usernames) {
      localStorage.setItem(ADMIN_KEY, usernames);
    },
    getTestUsers() {
      return localStorage.getItem(ADMIN_KEY) || ['王美丽', '焦隽峰'];
    },
    setExportFormat(format) {
      localStorage.setItem(EXPORT_FORMAT, format);
    },
    getExportFormat() {
      return localStorage.getItem(EXPORT_FORMAT) || '${projectName}_${issue.id}.csv';
    }
  };

  const TEST_USERS = issueHelper.getTestUsers();

  function exportToCsv(filename, rows) {
    const processRow = function (row) {
      let finalVal = '';
      for (let j = 0; j < row.length; j++) {
        const isEmpty = row[j] === null || row[j] === undefined;
        let innerValue = isEmpty ? '' : row[j].toString();
        if (row[j] instanceof Date) {
          innerValue = row[j].toLocaleString();
        }
        let result = innerValue.replace(/"/g, '""');
        if (result.search(/("|,|\n)/g) >= 0)
          result = '"' + result + '"';
        if (j > 0)
          finalVal += ',';
        finalVal += result;
      }
      return finalVal + '\n';
    };

    let csvFile = '';
    for (let i = 0; i < rows.length; i++) {
      csvFile += processRow(rows[i]);
    }

    const blob = new Blob([csvFile], { type: 'text/csv;charset=utf-8;' });
    if (navigator.msSaveBlob) { // IE 10+
      navigator.msSaveBlob(blob, filename);
    } else {
      const link = document.createElement('a');
      if (link.download !== undefined) { // feature detection
        // Browsers that support HTML5 download attribute
        const url = URL.createObjectURL(blob);
        link.setAttribute('href', url);
        link.setAttribute('download', filename);
        link.click();
      }
    }
  }


  function formatTask(task) {
    return [
      'author=> ' + task.author,
      'checked=> ' + task.checked,
      'priority=> ' + task.priority,
      'link=> ' + task.link,
    ].join('; ');
  }

  function filterTasksByPriority(tasks, priority) {
    return tasks.filter(function (task) {
      return task.priority === priority;
    });
  }

  function generateBugReport() {
    const tasks = collectTasks();
    const done = tasks.filter(function (task) {
      return task.checked;
    });
    const left = tasks.filter(function (task) {
      return !task.checked;
    });
    const totalReport = [
      'total=> ' + tasks.length,
      'done=> ' + done.length,
      'left=> ' + left.length,
    ].join('; ');
    console.log('Summary:', totalReport);
    console.log();

    const ALevel = filterTasksByPriority(left, 'A');
    const BLevel = filterTasksByPriority(left, 'B');
    const CLevel = filterTasksByPriority(left, 'C');
    const DLevel = filterTasksByPriority(left, 'D');

    const leftReport = [
      'A=> ' + ALevel.length,
      'B=> ' + BLevel.length,
      'C=> ' + CLevel.length,
      'D=> ' + DLevel.length,
    ].join('; ');
    console.log('Left:', leftReport);

    for (let i = 0; i < left.length; i++) {
      console.log(formatTask(left[i]));
    }
  }

  function createTask(domWrapper) {
    return {
      author: '',
      link: '',
      checked: false,
      description: '',
      priority: 'C',
      domWrapper,
      id: '',
      replies: [],
      confirmChecked: false
    };
  }

  function parseTask(taskContainer) {
    const timelineContent = taskContainer.querySelector('.timeline-entry-inner .timeline-content');
    const commentWrapper = timelineContent.querySelector('.timeline-discussion-body');
    const taskDomList = commentWrapper.querySelectorAll('.note-body .task-list');
    if (taskDomList.length === 0) {
      return null
    }
    const task = createTask(timelineContent);
    task.author = timelineContent.querySelector('.note-header .note-header-author-name').textContent;
    task.link = parseLink(timelineContent);
    if (taskDomList.length > 1) {
      console.error(formatTask(task));
    }
    const taskItem = taskDomList[0].querySelector('.task-list-item');
    const taskInput = taskItem.querySelector('input');
    task.checked = taskInput.checked;
    task.description = taskInput.nextSibling.textContent.trim();
    const idMatchResult = task.description.match(/^(\d+)\.?/);
    if (idMatchResult) {
      task.id = idMatchResult[1];
      task.description = task.description.replace(/^(\d+)\./, '').trim();
    }
    const priorityPattern = /([ABCD]).*bug/;
    const matchResult = commentWrapper.querySelector('.note-body').textContent.match(priorityPattern);
    if (matchResult) {
      task.priority = matchResult[1];
    }
    addReplies(task);
    if (confirmedByTestUser(task)) {
      task.confirmChecked = true;
    }
    return task;
  }

  function addReplies(task) {
    let replyList = task.domWrapper.querySelectorAll('.notes > .timeline-entry.note.note-wrapper');
    task.replies = Array.from(replyList).map(getReply);
  }

  function parseLink(timelineContent) {
    const actions = timelineContent.querySelector('.note-header .note-actions .more-actions.dropdown');
    const actionList = actions.querySelectorAll('li .js-btn-copy-note-link');
    return actionList[0].dataset.clipboardText
  }

  function collectTasks() {
    const noteList = document.querySelectorAll('#notes-list > .note:not(.system-note)');
    const filtered = Array.from(noteList).filter((item) => item.querySelector('.timeline-entry-inner .timeline-content'));
    const tasks = [];

    for (let i = 0; i < filtered.length; i++) {
      const taskContainer = filtered[i];
      let task;
      try {
        task = parseTask(taskContainer);
        if (task) {
          tasks.push(task);
        }
      } catch (e) {
        console.error(e);
        console.log('Error occurred when parseTask: ', taskContainer);
        continue
      }
    }
    return tasks;
  }

  function getReply(replayDom) {
    let noteContentSelector = '.timeline-entry-inner .timeline-content .timeline-discussion-body .note-body .note-text';
    let noteHeaderSelector = '.timeline-entry-inner .timeline-content .note-header';
    let noteHeaderDom = replayDom.querySelector(noteHeaderSelector);
    return {
      author: noteHeaderDom.querySelector('.note-header-author-name').textContent.trim(),
      content: replayDom.querySelector(noteContentSelector).textContent
    }
  }

  function confirmedByTestUser(task) {
    if (task.replies.length > 0) {
      let lastIndex = task.replies.length - 1;
      let reply = task.replies[lastIndex];
      return TEST_USERS.includes(reply.author) && reply.content === '验证已修复'
    } else {
      return false
    }
  }

  function collapseGitlabNotes() {
    const tasks = collectTasks();
    for (let i = 0; i < tasks.length; i++) {
      const task = tasks[i];
      if (task.checked || task.confirmChecked) {
        task.domWrapper.classList.add('collapse-item')
      }
      if (task.priority === 'A') {
        task.domWrapper.classList.add('highest-level-bug');
      }
    }
  }

  function scrollToNote(noteID) {
    if (noteID) {
      document.getElementById(noteID).scrollIntoView({ block: 'center' });
    }
  }

  function scrollToNoteInURL(result) {
    if (result) {
      scrollToNote(result[1]);
    }
  }

  function scrollToClipboardNote() {
    navigator.clipboard.readText().then(clipText => {
      if (clipText.startsWith('http')) {
      let url = new URL(clipText);
      if (url.hash) {
        const noteID = url.hash.replace('#', '');
        scrollToNote(noteID);
      }
    }
    });
  }

  function scrollToUrlNote(){
    const URLNote = window.location.hash.match(/#(note_\d+)/);
    if (URLNote) {
      scrollToNoteInURL(URLNote);
    }
  }

  function createMenuItem(content, title, handler) {
    let button = document.createElement('button');
    button.textContent = content;
    button.setAttribute('title', title);
    button.addEventListener('click', handler);
    return button
  }

  function padStart(string, length, pad) {
    const s = String(string);
    if (!s || s.length >= length) return string;
    return `${Array((length + 1) - s.length).join(pad)}${string}`;
  }

  function parseIssueTitle() {
    const titleElement = document.querySelector('.detail-page-description .title');
    return titleElement ? titleElement.textContent : '';
  }

  function parseContext() {
    let prefix = window.location.protocol + '//' + window.location.hostname + '/';
    const parts = window.location.href.replace(prefix, '').split('/');
    const name = parts[1];
    const nameParts = name.split('_');
    const now = new Date();
    const year = now.getFullYear();
    const month = padStart(now.getMonth() + 1, 2, '0');
    const day = padStart(`${now.getDate()}`, 2, '0');
    return {
      group: parts[0],
      projectName: name.startsWith('docs_') ? nameParts[1] : name,
      issue: {
        id: parts[parts.length - 1].split('#')[0],
        title: parseIssueTitle(),
      },
      year,
      month,
      day,
    };
  }

  function getValue(path, context) {
    const parts = path.split('.');
    let value = context;
    parts.forEach((part) => {
      value = value[part]
    })

    return value;
  }

  function formatFilename(context, format) {
    const matches = format.match(/\$\{.+?\}/g);
    let filename = format;
    matches.forEach((matchStr) => {
      const result = matchStr.match(/\$\{(?<path>.+)\}/);
      const path = result.groups.path;
      filename = filename.replace(matchStr, getValue(path, context))
    })

    return filename;
  }

  function generateFilename(format) {
    const context = parseContext();
    return formatFilename(context, format);
  }

  function exportAsCSV() {
    const tasks = collectTasks();
    console.log(tasks);
    const rows = [];
    const keys = ['id', 'description', 'checked', 'priority', 'author', 'link'];  // from task key
    rows.push(keys);
    for (let i = 0; i < tasks.length; i++) {
      const task = tasks[i];
      const row = [];
      for (let j = 0; j < keys.length; j++) {
        const key = keys[j];
        if (key !== 'checked') {
          row.push(task[key]);
        } else {
          row.push(task['checked'] || task['confirmChecked'])
        }
      }
      rows.push(row);
    }
    let filename = generateFilename(issueHelper.getExportFormat());
    exportToCsv(filename, rows)
  }

  function createMenu() {
    const descContainer = document.querySelector('.detail-page-description');
    const fixMenu = document.createElement('div');
    fixMenu.classList.add("issue-sticky-header", "gl-fixed");

    const saviorBox = document.createElement('div');
    saviorBox.classList.add("issue-sticky-header-text", "gl-mx-auto", "savior");

    const menuDom = document.createElement('div');
    menuDom.classList.add('savior-menu');
    const menuItems = [
      createMenuItem('导出', '导出CSV', exportAsCSV),
      createMenuItem('折叠', '折叠评论', collapseGitlabNotes),
      createMenuItem('跳转', '跳转至剪切版中的URL', scrollToClipboardNote),
      createMenuItem('Find', '跳转到URL锚点位置', scrollToUrlNote),
    ];
    for (let i = 0; i < menuItems.length; i++) {
      const menuItem = menuItems[i];
      menuDom.appendChild(menuItem);
    }
    saviorBox.appendChild(menuDom);
    fixMenu.appendChild(saviorBox);
    descContainer.appendChild(fixMenu);
  }

  GM_addStyle(`
  .notes .note .timeline-content.collapse-item {
    height: 100px;
    background-color: #67c23a;
    overflow: hidden;
  }
  
  .notes .note .timeline-content.collapse-item * {
    background-color: #67c23a;
  }

  .notes-list .note .timeline-content.highest-level-bug:not(.collapse-item) {
    background: #f56c6c;
  }
  
  .savior {
    position: relative;
  }

  .savior-menu {
    top: 56px;
    left: 100%;
    position: absolute;
    width: 46px;
    display: inline-flex;
    flex-direction: column;
    padding: 0;
    font-size: 12px;
  }

  .savior-menu button {
    outline: none;
    background-color: #e0e1e2;
    color: #0009;
    padding: 5px 10px;
    border: none;
    box-shadow: 0 0 0 1px transparent inset, 0 0 0 0 rgba(34,36,38,.15) inset;
  }

  .savior-menu button:hover {
    background-color: #cacbcd;
    color: #000c;
  }
  `);

  createMenu();

  // const intervalKey = 'MAX_MUTATION_INTERVAL';
  // const customInterval = localStorage.getItem(intervalKey);
  // const mutationInterval = customInterval ? parseInt(customInterval) : 10 * 1e3;
  const URLMatchResult = window.location.hash.match(/#(note_\d+)/);
  if (URLMatchResult) {
    /**
     * It seems Gitlab can jump to right note in URL, it took so long!
     */

    // let timeoutID = null;
    // let observer;
    // function handleMutations(records) {
    //   records.forEach((record) => {
    //     if (timeoutID) {
    //       clearTimeout(timeoutID);
    //     }
    //     timeoutID = setTimeout(function() {
    //       requestAnimationFrame(() => {
    //         scrollToNoteInURL(URLMatchResult);
    //         observer.disconnect();
    //       })
    //     }, mutationInterval);
    //   });
    // }
    //
    // observer = new MutationObserver(handleMutations);
    // const nodeList = document.querySelector('#notes-list')
    // observer.observe(nodeList, { subtree: true, childList: true, attributes: true });
  }

  unsafeWindow.$issueHelper = issueHelper;
})();