GitLab Merge Requests

Image preview of GitLab Merge Requests plugin.

gitlab_merge_requests.1m.js

Edit
Open on GitHub
#!/usr/bin/env /usr/local/bin/node
// jshint asi:true
// jshint esversion: 8
// <xbar.title>GitLab Merge Requests</xbar.title>
// <xbar.version>v1.0</xbar.version>
// <xbar.author>Simeon Cheeseman</xbar.author>
// <xbar.author.github>simeonc</xbar.author.github>
// <xbar.desc>List of your assigned and created merge requests.
// The menu bar shows a count of MRs requiring your approval (🔎), WIP MRs (🛠️), Un-mergable MRs (⛔), MRs with failed pipelines (⚠️), MRs with unresolved discussions (🚧), MRs with running pipeline (🚀), MRs pending approval (💬) and Approved/Ready to Merge (❇️).
// Each MR in the dropdown is grouped by project and displays `<Unmergable> "Title"; "Pipeline Status" : "Approval Status"`, clicking on an MR opens it in the browser</xbar.desc>
// <xbar.dependencies>node.js</xbar.dependencies>
// <xbar.image>https://i.imgur.com/t0TtQXO.png</xbar.image>

// <xbar.var>string(GITLAB_API_TOKEN=""): Gitlab token with read_user and read_api scopes. https://gitlab.com/profile/personal_access_tokens</xbar.var>
// <xbar.var>string(GITLAB_HOST="gitlab.com"): The domain your instance is hosted on. Leave the default if using gitlab.com</xbar.var>
// <xbar.var>number(FONT_SIZE=15): Font size of the MR title and status</xbar.var>
// <xbar.var>number(PROJECT_FONT_SIZE=13): Font size of the project heading</xbar.var>
// <xbar.var>select(DARK_MODE="auto"): Choose dark mode settings [auto, dark, light]</xbar.var>

/**
 * Information
 *
 * This was inspired by the work of Shelton Koskie (@eightygrit), who made the
 * first version of the "GITLAB Projects" for API v4.
 *
 * @see   GitLab API Documentation    https://docs.gitlab.com/ee/api/README.html
 * @see   Create GitLab Access Token  https://gitlab.com/profile/personal_access_tokens
 * @see   BitBar Node Module Docs     https://github.com/sindresorhus/bitbar
 */

const gitlab_domain = process.env.GITLAB_HOST;
const private_token = process.env.GITLAB_API_TOKEN;
const font_size = process.env.FONT_SIZE;

// <xbar.var>boolean(MENU_BAR_SHOW_NEW_MR=true): Show new MR count and icon in the menubar, these are branches that have been pushed today but have no connected MR</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_REQUESTED_REVIEWER=true): show requested reviews count and icon in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_WIP=true): show Work In Progress/Draft count and icon in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_UNMERGABLE=true): show Unmergable MR count and icon in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_UNRESOLVED_DISCUSSION=true): show MRs with unresolved discussion count and icon in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_APPROVED=true): show Approved with passing CI MR count and icon in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_CI_FAILED=true): show Failed pipeline count and icon in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_CI_RUNNING=true): show Pipeline running count and icon in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_CI_PENDING=true): show Pending pipeline count and icon in the menubar</xbar.var>

/**
 * Change this array to choose which statuses show and in what order on the menu bar.
 *
 * @type {string[]}
 */
const MENU_BAR_ORDER = [
  process.env.MENU_BAR_SHOW_NEW_MR === "true" ? "newMR" : false,
  process.env.MENU_BAR_SHOW_REQUESTED_REVIEWER === "true" ? "reviewer" : false,
  process.env.MENU_BAR_SHOW_WIP === "true" ? "wip" : false,
  process.env.MENU_BAR_SHOW_UNMERGABLE === "true" ? "unmergable" : false,
  process.env.MENU_BAR_SHOW_CI_FAILED === "true" ? "failed" : false,
  process.env.MENU_BAR_SHOW_UNRESOLVED === "true" ? "unresolved" : false,
  process.env.MENU_BAR_SHOW_CI_RUNNING === "true" ? "ciRunning" : false,
  process.env.MENU_BAR_SHOW_CI_PENDING === "true" ? "pending" : false,
  process.env.MENU_BAR_SHOW_APPROVED === "true" ? "approved" : false,
].filter((k) => !!k);

/**
 * MAX length of the title string in the toolbar
 *
 * @type {number}
 */
const MAX_LENGTH = 60;

/////////////////////////////////////////////////////////////////////////
// Do not edit below this line unless you know what you're doing. :)  //
///////////////////////////////////////////////////////////////////////
const fs = require("fs");
const path = require("path");
const SETTINGS_FILE_PATH = path.resolve(
  __dirname,
  "./.gitlab_merge_requests.settings.json"
);

let bitbar;

// Verify bitbar node module is available or try to install it globally.
try {
  bitbar = require("bitbar");
} catch (e) {
  try {
    bitbar = globalRequire("bitbar");
  } catch (e) {
    installBitbarModule();

    // Not catching error if one is thrown.
    bitbar = globalRequire("bitbar");
  }
}

const textColor = (() => {
  switch (process.env.DARK_MODE) {
    case "dark":
      return "white";
    case "light":
      return "black";
    case "auto":
    default:
      return bitbar.darkMode ? "white" : "black";
  }
})();

// Converts the gitlab status to emoji
// see https://emojipedia.org/ for customisation
function stateIcon(status) {
  return {
    created: "💤",
    pending: "💤",
    running: "🚀",
    failed: "⚠️",
    success: "✅",
    skipped: "🚀",
    manual: "⏯️",
  }[status];
}

const gitlabIconBase64 =
  "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";

function request(path) {
  const httpTransport = require("https");
  const responseEncoding = "utf8";
  const httpOptions = {
    hostname: gitlab_domain,
    port: "443",
    path,
    method: "GET",
    headers: { "PRIVATE-TOKEN": private_token },
  };
  httpOptions.headers["User-Agent"] =
    "bitbar/gitlab_projects - node " + process.version;

  return new Promise((resolve, reject) => {
    const request = httpTransport
      .request(httpOptions, (res) => {
        let responseBufs = [];
        let responseStr = "";

        res
          .on("data", (chunk) => {
            if (Buffer.isBuffer(chunk)) {
              responseBufs.push(chunk);
            } else {
              responseStr = responseStr + chunk;
            }
          })
          .on("end", () => {
            if (responseBufs.length > 0) {
              responseStr = Buffer.concat(responseBufs).toString(responseEncoding);
            }

            resolve(JSON.parse(responseStr));
          });
      })
      .setTimeout(0)
      .on("error", (error) => {
        reject(error);
      });
    request.write("");
    request.end();
  });
}

const cachedProjectNames = {};
async function registerProject(project_id) {
  if (!cachedProjectNames[project_id]) {
    const project = await request(`/api/v4/projects/${project_id}`);
    cachedProjectNames[project_id] = project.name_with_namespace;
  }
}

async function getSettings() {
  let settings = {};
  if (fs.existsSync(SETTINGS_FILE_PATH)) {
    settings = JSON.parse(fs.readFileSync(SETTINGS_FILE_PATH));
  }
  if (!settings.userId) {
    const myUser = await request("/api/v4/user");
    settings.userId = myUser.id;
    fs.writeFileSync(
      SETTINGS_FILE_PATH,
      JSON.stringify({
        settings,
      })
    );
  }
  return settings;
}

async function getMRs() {
  const { userId, todaysUUID, todaysExcludedEvents = [] } = await getSettings();

  const statusCount = {};
  function incrementStatusCount(key) {
    statusCount[key] = statusCount[key] || 0;
    statusCount[key] += 1;
  }

  const mergeRequestsByProjectId = {};

  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  const yesterdayParam = `${yesterday.getFullYear()}-${
    yesterday.getMonth() + 1
  }-${yesterday.getDate()}`;

  let excludedEvents = [];
  if (todaysUUID === yesterdayParam) {
    excludedEvents = todaysExcludedEvents;
  }

  const [todaysEvents, createdMRs, assignedMRs, approvalMRs] =
      await Promise.all([
        request(
            `/api/v4/users/${userId}/events?after=${yesterdayParam}&action=pushed`
        ),
        request("/api/v4/merge_requests?scope=created_by_me&state=opened"),
        request("/api/v4/merge_requests?scope=assigned_to_me&state=opened"),
        request(
            `/api/v4/merge_requests?scope=all&state=opened&approver_ids[]=${userId}`
        ),
        request(
            `/api/v4/merge_requests?scope=all&state=opened&reviewer_id[]=${userId}`
        ),
      ]);

  const allMRs = assignedMRs
    .concat(approvalMRs, createdMRs)
    .reduce((uniqueMRs, currentMR) => {
      if (!uniqueMRs.find(({ id }) => id === currentMR.id)) {
        uniqueMRs.push(currentMR);
      }
      return uniqueMRs;
    }, []);

  if (allMRs.length === 0) {
    // Hide when no open MRs
    return bitbar([
      {
        image: gitlabIconBase64,
        text: `0 MR`,
        color: textColor,
        dropdown: true,
      },
    ]);
  }

  await Promise.all(
    allMRs
      .map((mergeRequest) => {
        const { project_id, iid } = mergeRequest;
        return Promise.all([
          Promise.resolve(mergeRequest),
          request(
            `/api/v4/projects/${project_id}/merge_requests/${iid}/pipelines`
          ),
          request(
            `/api/v4/projects/${project_id}/merge_requests/${iid}/approval_state`
          ),
          registerProject(project_id),
        ]);
      })
      .map(async (promises) => {
        const [mergeRequest, pipelines, approvals] = await promises;

        const {
          web_url,
          merge_status,
          title,
          project_id,
          iid,
          blocking_discussions_resolved,
          work_in_progress,
          assignee,
          assignees,
          author,
          source_branch,
        } = mergeRequest;
        excludedEvents.push(`${project_id}_${source_branch}`);

        const isReviewer =
          (!author || author.id !== userId) &&
          (!assignee || assignee.id !== userId) &&
          (!assignees || !assignees.find(({ id }) => id === userId));

        mergeRequestsByProjectId[project_id] =
          mergeRequestsByProjectId[project_id] || [];

        let pipelineStatus = "";
        if (pipelines.length) {
          pipelineStatus = stateIcon(pipelines[0].status);
        }
        const isApproved = approvals.rules.reduce(
          (isApproved, { approved }) => isApproved && approved,
          true
        );
        const hasCurrentUserApproved = approvals.rules.reduce(
          (currentUserHasApproved, { approved_by }) =>
            currentUserHasApproved ||
            !!approved_by.find(({ id }) => id === userId),
          false
        );
        const canMerge = merge_status === "can_be_merged";
        if (
          isReviewer &&
          (work_in_progress ||
            !canMerge ||
            isApproved ||
            hasCurrentUserApproved)
        )
          return;
        let mergeStatus = "  ";
        if (!canMerge) {
          mergeStatus = "⛔ ";
        } else if (isReviewer) {
          mergeStatus = "🔎 ";
        }
        let approvalStatus = "💬";
        if (work_in_progress) {
          approvalStatus = "🛠️";
        } else if (!blocking_discussions_resolved) {
          approvalStatus = "🚧";
        } else if (isApproved) {
          approvalStatus = "❇️";
        }
        if (isReviewer) {
          incrementStatusCount("reviewer");
        } else if (work_in_progress) {
          incrementStatusCount("wip");
        } else if (merge_status !== "can_be_merged") {
          incrementStatusCount("unmergable");
        } else if (pipelines.length && pipelines[0].status === "failed") {
          incrementStatusCount("failed");
        } else if (!blocking_discussions_resolved) {
          incrementStatusCount("unresolved");
        } else if (pipelines.length && pipelines[0].status === "running") {
          incrementStatusCount("ciRunning");
        } else if (!isApproved) {
          incrementStatusCount("pending");
        } else {
          incrementStatusCount("approved");
        }

        let statusString = "";
        if (pipelineStatus || approvals.rules.length) {
          const statusStrings = [];
          if (pipelineStatus) statusStrings.push(pipelineStatus);
          if (approvals.rules.length) statusStrings.push(approvalStatus);
          statusString = `  ${statusStrings.join(" ")}`;
        }
        let trimmedTitle = title.substring(0, MAX_LENGTH);
        if (title.length > MAX_LENGTH) {
          trimmedTitle += "...";
        }
        mergeRequestsByProjectId[project_id].push(
          `${mergeStatus}!${iid}: ${trimmedTitle}${statusString} | href="${web_url}" size=${font_size}`
        );
      })
  );

  for (let i = 0; i < todaysEvents.length; i += 1) {
    const {
      project_id,
      push_data: { action, ref, ref_type, commit_to },
    } = todaysEvents[i];
    if (excludedEvents.indexOf(`${project_id}_${ref}`) !== -1) continue;
    if (!(action === "created" && ref_type === "branch")) continue;

    const mrCheck = await request(
      `/api/v4/projects/${project_id}/repository/commits/${commit_to}/merge_requests`
    );
    if (mrCheck.length) {
      excludedEvents.push(`${project_id}_${ref}`);
      continue;
    }
    await registerProject(project_id);
    const { web_url } = await request(`/api/v4/projects/${project_id}`);
    const params = [
      `merge_request%5Bsource_branch%5D=${ref}`,
      `merge_request%5Bsource_project_id%5D=${project_id}`,
      `merge_request%5Btarget_project_id%5D=${project_id}`,
    ];
    const createMRUrl = `${web_url}/-/merge_requests/new?${params.join("&")}`;
    mergeRequestsByProjectId[project_id] =
      mergeRequestsByProjectId[project_id] || [];
    mergeRequestsByProjectId[project_id].unshift(
      `📝: ${ref} | href="${createMRUrl}" size=${font_size}`
    );
    incrementStatusCount("newMR");
  }

  const content = [];

  const statusMap = {
    reviewer: "🔎",
    unmergable: "⛔",
    failed: "⚠️",
    ciRunning: "🚀",
    pending: "💬",
    approved: "❇️",
    wip: "🛠️",
    unresolved: "🚧",
    newMR: "📝",
  };
  const statusString = MENU_BAR_ORDER.map((key) => {
    if (statusCount[key]) {
      return `${statusMap[key]}[${statusCount[key]}]`;
    }
  })
    .filter((s) => !!s)
    .join(", ");

  content.push({
    image: gitlabIconBase64,
    text: `${statusString}`,
    color: textColor,
    dropdown: true,
  });

  Object.keys(mergeRequestsByProjectId).forEach((projectId) => {
    if (!mergeRequestsByProjectId[projectId].length) return;
    content.push(bitbar.separator);
    content.push({
      text: cachedProjectNames[projectId],
      size: process.env.PROJECT_FONT_SIZE,
    });
    mergeRequestsByProjectId[projectId].forEach((text) =>
      text ? content.push({ text }) : null
    );
  });

  // Update settings file for speed later
  fs.writeFileSync(
    SETTINGS_FILE_PATH,
    JSON.stringify({
      userId,
      todaysUUID: yesterdayParam,
      todaysExcludedEvents: excludedEvents.reduce((uniqueEvents, event) => {
        if (uniqueEvents.indexOf(event) === -1) {
          uniqueEvents.push(event);
        }
        return uniqueEvents;
      }, []),
    })
  );

  try {
    bitbar(content);
  } catch (error) {
    console.log(content);
    throw error;
  }
}

getMRs().catch((error) => {
  console.error(error);
});

// These utility functions taken from another plugin;
// https://github.com/matryer/xbar-plugins/blob/f1004a74a0d887b9655c71a1d52ba4e02b37fd77/Dev/Gitlab/gitlab_projects.js

/**
 * Sets up the ability to require global node packages.
 *
 * @return     {object}  Returns the required node package object
 */
function globalRequire(packageName) {
  const childProcess = require("child_process");
  const path = require("path");
  const fs = require("fs");
  const env = Object.assign({}, process.env);
  env.PATH = path.resolve("/usr/local/bin") + ":" + env.PATH;

  const globalNodeModulesDir =
    childProcess
      .execSync(npmBin() + " root -g", { env: env })
      .toString()
      .trim() + "/";
  let packageDir = path.join(globalNodeModulesDir, packageName, "/");

  //find package required by older versions of npm
  if (!fs.existsSync(packageDir)) {
    packageDir = path.join(
      globalNodeModulesDir,
      "npm/node_modules/",
      packageName
    );
  }

  // Package not found
  if (!fs.existsSync(packageDir)) {
    throw new Error("Cannot find global module '" + packageName + "'");
  }

  const packageMeta = JSON.parse(
    fs.readFileSync(path.join(packageDir, "package.json")).toString()
  );
  const main = path.join(packageDir, packageMeta.files[0]);

  return require(main);
}

/**
 * Installs Bitbar node module if it doesn't exits.
 *
 * @see    BitBar node module on github    https://github.com/sindresorhus/bitbar
 */
function installBitbarModule() {
  // Allows one to run the npm command as if on the command line.
  const childProcess = require("child_process");
  const execSync = childProcess.execSync;
  const path = require("path");

  const env = Object.assign({}, process.env);
  env.PATH = path.resolve("/usr/local/bin") + ":" + env.PATH;

  // Get the path to npm bin
  const npm = npmBin();

  // The install command
  const cmd = npm + " install -g bitbar@1";

  console.log("Installing the BitBar Node module...");

  execSync(cmd, {
    cwd: process.cwd(),
    env: env,
  })
    .toString("utf8")
    .trim();

  console.log("Installation complete.");
}

/**
 * Gets the path to your npm executable.
 *
 * @return  {string}  The full path to your npm executable
 */
function npmBin() {
  const path = require("path");
  const childProcess = require("child_process");
  const execSync = childProcess.execSync;
  const env = Object.assign({}, process.env);
  env.PATH = path.resolve("/usr/local/bin") + ":" + env.PATH;

  // Get the path to npm bin
  return execSync("which npm", { env: env }).toString("utf8").trim();
}