GitHub Work List

Image preview of GitHub Work List plugin.

github_work_list.1m.mjs

Edit
Open on GitHub
#!/usr/bin/env /usr/local/bin/node
// jshint asi:true
// jshint esversion: 11
// jshint module:true
// jshint node:true
// <xbar.title>GitHub Work List</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, authored and review requested pull requests. Also shows any branches pushed that haven't had pull requests created yet.
// Each Pull in the dropdown is grouped by project and displays `"Title"; "Mergeable Status"`, clicking on an Pull opens it in the browser</xbar.desc>
// <xbar.dependencies>node.js</xbar.dependencies>
// <xbar.image>https://i.imgur.com/SwvRjft.png</xbar.image>

// <xbar.var>string(GITHUB_API_TOKEN=""): Github token with repo:status scope which is required for checks</xbar.var>
// <xbar.var>string(GITHUB_HOST="github.com"): The domain your instance is hosted on. Leave the default if using github.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
 * @see   BitBar Node Module Docs     https://github.com/sindresorhus/xbar
 */

const private_token = process.env.GITHUB_API_TOKEN;
const font_size = process.env.FONT_SIZE;
// <xbar.var>boolean(MENU_BAR_SHOW_NEW_BRANCH=true): show count and icon 📝 of new branches pushed without a Pull icon in the menubar (note that this basically is from when the plugin is installed, not all branches)</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_REQUESTED_REVIEWER=true): show count and icon 🔎 of requested reviews in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_DRAFT=true): show count and icon 🛠️ of Draft pulls in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_UNMERGABLE=true): show count and icon ⛔ of unmergable Pulls in the menubar - Unmergable means the head ref is out of date or the merge commit cannot be cleanly created</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_BLOCKED=true): show count and icon 🚧 of Pulls that are blocked in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_APPROVED=true): show count and icon ❇️ of Pulls with Mergeable and passing commit status in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_UNSTABLE=true): show count and icon ⚠️ of Pulls that are mergeable with non-passing commit status in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_HAS_HOOKS=true): show count and icon 🚀 of Pulls that are mergeable with passing commit status and pre-receive hooks in the menubar</xbar.var>
// <xbar.var>boolean(MENU_BAR_SHOW_UNKNOWN=true): show count and icon 💬 of pulls that have an unknown mergable state 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_BRANCH === "true" ? "new" : false,
  process.env.MENU_BAR_SHOW_REQUESTED_REVIEWER === "true" ? "reviewer" : false,
  process.env.MENU_BAR_SHOW_DRAFT === "true" ? "draft" : false,
  process.env.MENU_BAR_SHOW_UNMERGABLE === "true" ? "unmergable" : false,
  process.env.MENU_BAR_SHOW_BLOCKED === "true" ? "blocked" : false,
  process.env.MENU_BAR_SHOW_APPROVED === "true" ? "approved" : false,
  process.env.MENU_BAR_SHOW_UNSTABLE === "true" ? "failed" : false,
  process.env.MENU_BAR_SHOW_HAS_HOOKS === "true" ? "ciRunning" : false,
  process.env.MENU_BAR_SHOW_UNKNOWN === "true" ? "pending" : 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. :)  //
///////////////////////////////////////////////////////////////////////
import fs from "fs";
import path from "path";
import childProcess from "child_process";

const [node, thisFilePath, ...args] = process.argv;
const SETTINGS_FILE_PATH = `${thisFilePath.replace(
  /\/(?!.*\/)/gi,
  "/."
)}.settings.json`;

(async () => {
  let _settings;

  async function updateSettings() {
    if (!_settings) return;
    await fs.writeFileSync(SETTINGS_FILE_PATH, JSON.stringify(_settings));
  }
  /**
   * @returns {Promise<{ username: string, currentBranches: {id: string, repoId: string, repo: string, branch: string, description: string, url: string}[], ignoredBranches: {id: string, repoId: string, ref: string}[]}>}
   */
  async function getSettings() {
    if (_settings) return _settings;
    if (fs.existsSync(SETTINGS_FILE_PATH)) {
      _settings = JSON.parse(fs.readFileSync(SETTINGS_FILE_PATH)) || {};
    } else {
      _settings = {};
    }
    if (!_settings.username) {
      const myUser = await octokit.rest.users.getAuthenticated();
      _settings.username = myUser.data.login;
    }
    if (!_settings.currentBranches) {
      _settings.currentBranches = [];
    }
    if (!_settings.ignoredBranches) {
      _settings.ignoredBranches = [];
    }
    return _settings;
  }
  if (args.length) {
    const [command, ...params] = args;
    switch (command) {
      case "remove_branch":
        const settings = await getSettings();
        const branchPredicate = (branch) => branch.id === params[0];
        const branchToRemove = settings.currentBranches.find(branchPredicate);
        if (!branchToRemove) return;
        const index = settings.currentBranches.findIndex(branchPredicate);
        if (index !== -1) {
          settings.currentBranches.splice(index, 1);
        }
        if (!settings.ignoredBranches.find(branchPredicate)) {
          settings.ignoredBranches.push({
            id: branchToRemove.id,
            repoId: branchToRemove.repoId,
            branch: branchToRemove.branch,
          });
        }
        await updateSettings();
        return;
      default:
        console.error(`Unknown command ${args.join(" ")}`);
        return;
    }
  }
  const xbar = await installAndRequire("@sindresorhus/xbar");
  const { Octokit } = await installAndRequire("octokit");

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

  const githubIconBase64 =
    textColor === "black"
      ? "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEqADAAQAAAABAAAAEgAAAACaqbJVAAABlklEQVQ4EZWUOS9EURiGrzViC4JILBmTiE4iljEaNBRKChGVUqMRiUIUConwD/wACrWIimQqGjIKIaGgtMYe2/OOOXLOzTXLmzwz51vO9t3vXs9zdYT5BBsQhRbohQHohnoYgV34gk3IAUd5WJ9wAtfwnYJLYmfwDOXgqAZLk0ehDFZhFrogDH2wAhNQAIvwAlXgSNfQcecc7//GNqFXqLZT8jGO4Qaa7UCK8RAx3WDazqnF0OoLtjPNWDXVxufKy9UPKgIF4jIylB7MIYSUbxb6YKz6NMiZhVrJvbPzSzDUP3u2M824jrhqtO7PW04Gxv2BALsQ3xLoep0Bce8Ap3ZRZ7eDOtlcX082BMOwD1pEnW3iiWZTp/aDdloDLSbeQa+GNAaqpfxvMAXO69GE4xRU7EGQ1JyaoHevGCSdTi2yBRUQqEq8F/AAMzAJWmgejLT7PcSMw/7XvaVbaIMdUBGNX6c00sKSc51fl1UoHDpNBHS6DshKfxW3Zj0yvkra+kzYUrH1ADKWjt8Dpb4ZYexGny9h/gAjnlV3yrzolgAAAABJRU5ErkJggg=="
      : "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEqADAAQAAAABAAAAEgAAAACaqbJVAAABvklEQVQ4EZWUvS8EQRjGb91FBIUTkYuvLCI6iZDzUZ2GQqkRiUap0UhERJSXSPgL9CjUIioSFQ2hIqGg9XFBzuXu1u/Zvdnbw+XOk/x23nnn2dmZ3Xc2FArIcZwr+IB9GIM+GIcJiEM7zMAJ5OEALE3hXhSQCNNk4A5aoBnK6YmBNLRBzLKsVE3AqRvVXwMbtmEF4tALCdiCeeiGXdBCIlAUK9I2tNzVYrZ8hO8I0qDVe6ITgRt4Bj2tovBNgbTkm+m0gmbf8JMVArxh0IPvZTXvqI5YL/tayWrEC87huwRbfjNRljgPHUr+Q/14X30/y2sA1c+pn6wQ4I2BtFdiJbHpph1nrmTgjw6+WkhCDoZ/WUhegKTKHgRVsrt9Wn1ZG6bhHDSJKtt7PQQ98AgJ0JN2wChDoIJU5c9CtjDwRbsIKkhPdLrgFlSMk8rSqjglnb36Qk6rU4kcQpN3948rA1F4gBQswwJI68ZKbMEbnJlcsHXPCTXxgmGAgWNIgjk/KglXeLA4iovb8Ybcq6mjkE4wjJCNwlDAU1XoT2TcTPZOrN+E9Ok1/lWFq19NdWIL2scoNAbvoK8v3BnMmfgbpQWDYNC92VIAAAAASUVORK5CYII=";
  const octokit = new Octokit({
    userAgent: "xbar/github_work-list - node " + process.version,
    auth: private_token,
  });

  const statusIconMap = {
    reviewer: "🔎",
    unmergable: "⛔",
    failed: "⚠️",
    ciRunning: "🚀",
    pending: "💬",
    approved: "❇️",
    draft: "🛠️",
    blocked: "🚧",
    new: "📝",
  };

  async function searchPullRequests() {
    const { username } = await getSettings();
    const responses = await Promise.all([
      octokit.rest.search.issuesAndPullRequests({
        q: `is:open is:pr review-requested:${username} archived:false `,
        per_page: 20,
      }),
      octokit.rest.search.issuesAndPullRequests({
        q: `is:open is:pr assignee:${username} archived:false `,
        per_page: 20,
      }),
      octokit.rest.search.issuesAndPullRequests({
        q: `is:open is:pr author:${username} archived:false `,
        per_page: 20,
      }),
    ]);
    const uniqueResponses = [];
    responses.forEach((response) => {
      response.data.items.forEach((item) => {
        if (!uniqueResponses.find((uniqueItem) => uniqueItem.id === item.id)) {
          uniqueResponses.push(item);
        }
      });
    });
    return await Promise.all(
      uniqueResponses.slice(0, 20).map(async (item) => {
        const [owner, repo] = item.repository_url.split("/").slice(-2);
        const result = await octokit.graphql(
          `query($owner: String!, $repo: String!, $pull_number: Int!) {
            repository(owner: $owner, name:$repo) {
              pullRequest(number:$pull_number) {
                id
                baseRepository {
                  id
                  nameWithOwner
                }
                headRepository {
                  id
                  nameWithOwner
                }
                author {
                  login
                }
                assignees(first: 10) {
                  nodes {
                    login
                  }
                }
                mergeStateStatus
                reviewDecision
                isDraft
                title
                url
                headRefName
                bodyText
                commits(last: 1) {
                  nodes {
                    commit {
                      statusCheckRollup {
                        state
                      }
                    }
                  }
                }
              }
            }
          }`,
          {
            headers: {
              Accept: "application/vnd.github.merge-info-preview+json",
            },
            owner,
            repo,
            pull_number: item.number,
          }
        );
        const { pullRequest } = result.repository;
        const [{ commit: lastCommit }] = pullRequest.commits.nodes;
        const pull = {
          number: item.number,
          title: pullRequest.title,
          body: pullRequest.bodyText,
          url: pullRequest.url,
          headRef: pullRequest.headRefName,
          reviewDecision: pullRequest.reviewDecision,
          mergeStateStatus: pullRequest.mergeStateStatus,
          author: pullRequest.author.login,
          assignees: pullRequest.assignees.nodes.map((node) => node.login),
          repoName: pullRequest.baseRepository.nameWithOwner,
          repoId: pullRequest.headRepository.id,
          isDraft: pullRequest.isDraft,
        };
        if (lastCommit.statusCheckRollup) {
          const { state } = lastCommit.statusCheckRollup;
          switch (state) {
            case "ERROR":
            case "FAILURE":
              pull.latestCheck = "failed";
              break;
            case "EXPECTED":
            case "PENDING":
              pull.latestCheck = "running";
              break;
            case "SUCCESS":
              pull.latestCheck = "passed";
              break;
          }
        }
        return pull;
      })
    );
  }

  async function updateCurrentBranches() {
    const { ignoredBranches, currentBranches, username } = await getSettings();
    const activities =
      await octokit.rest.activity.listEventsForAuthenticatedUser({
        username,
      });
    for (let i = activities.data.length - 1; i >= 0; i -= 1) {
      const activity = activities.data[i];
      const findPredicate = (event) =>
        event.repoId === activity.repo.id &&
        event.branch === activity.payload.ref;
      if (
        activity.type === "CreateEvent" &&
        activity.payload.ref_type === "branch"
      ) {
        if (
          ignoredBranches.find(
            (branch) =>
              branch.id === activity.id && branch.repoId === activity.repo.id
          )
        )
          continue;
        const oldBranchIndex = currentBranches.findIndex(
          (branch) => branch.id === activity.id
        );
        const branch = {
          id: activity.id,
          repoId: activity.repo.id,
          repo: activity.repo.full_name || activity.repo.name,
          branch: activity.payload.ref,
          description: activity.payload.description,
          url: `https://github.com/${activity.repo.name}/compare/${activity.payload.ref}?expand=1`,
        };
        if (oldBranchIndex >= 0) {
          currentBranches.splice(oldBranchIndex, 1, branch);
        } else {
          currentBranches.push(branch);
        }
      } else if (
        activity.type === "DeleteEvent" &&
        activity.payload.ref_type === "branch"
      ) {
        const branchIndex = currentBranches.findIndex(findPredicate);
        if (branchIndex >= 0) {
          currentBranches.splice(branchIndex, 1);
        }
        const ignoredBranchIndex = ignoredBranches.findIndex(findPredicate);
        if (ignoredBranchIndex >= 0) {
          ignoredBranches.splice(ignoredBranchIndex, 1);
        }
      } else if (
        activity.type === "PullRequestEvent" &&
        activity.payload.action === "opened"
      ) {
        const branch = currentBranches.find(
          (event) =>
            event.repoId === activity.payload.pull_request.head.repo.id &&
            event.branch === activity.payload.pull_request.head.ref
        );
        if (branch) {
          currentBranches.splice(currentBranches.indexOf(branch), 1);
        }
      }
    }
    return currentBranches;
  }

  function get(object, keyPath) {
    const keys = keyPath.split(".");
    let result = object;
    while (keys.length && typeof result === "object") {
      const currentKey = keys.shift();
      result = result[currentKey];
    }
    return result;
  }
  function createMapByKey(items, keyPath) {
    const result = {};
    items.forEach((item) => {
      const key = get(item, keyPath);
      result[key] = result[key] || [];
      result[key].push(item);
    });
    return result;
  }

  async function getPulls() {
    const { username } = await getSettings();

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

    const [currentBranches, pullRequestSearch] = await Promise.all([
      updateCurrentBranches(),
      searchPullRequests(),
    ]);
    if (currentBranches.length === 0 && pullRequestSearch.length === 0) {
      // Hide when no open MRs
      return xbar([
        {
          image: githubIconBase64,
          text: `0 Pulls`,
          color: textColor,
          dropdown: true,
        },
      ]);
    }
    const currentBranchesByProject = createMapByKey(currentBranches, "repo");
    const pullRequestsByProject = createMapByKey(pullRequestSearch, "repoName");
    const projectIds = Object.keys(currentBranchesByProject);
    Object.keys(pullRequestsByProject).forEach((projectId) => {
      if (projectIds.indexOf(projectId) === -1) {
        projectIds.push(projectId);
      }
    });
    function getProjectName(projectId) {
      const projectBranches = currentBranchesByProject[projectId] || [];
      const pulls = pullRequestsByProject[projectId] || [];
      if (projectBranches.length) {
        return projectBranches[0].repo;
      }
      return pulls[0].repoName;
    }
    projectIds.sort((projectIdA, projectIdB) => {
      const nameA = getProjectName(projectIdA);
      const nameB = getProjectName(projectIdB);
      if (nameA < nameB) return -1;
      if (nameA > nameB) return 1;
      return 0;
    });
    const content = [];
    projectIds.forEach((projectId) => {
      const projectBranches = currentBranchesByProject[projectId] || [];
      const pulls = pullRequestsByProject[projectId] || [];
      content.push(xbar.separator);
      content.push({
        text: getProjectName(projectId),
        size: process.env.PROJECT_FONT_SIZE,
      });
      projectBranches.forEach((branch) => {
        incrementStatusCount("new");
        const submenu = (
          branch.description
            ? [
                {
                  text: "Description",
                  size: process.env.PROJECT_FONT_SIZE,
                },
                {
                  text: branch.description,
                  size: font_size,
                },
              ]
            : []
        ).concat([
          {
            text: "Remove from list",
            size: font_size,
            shell: thisFilePath,
            param1: `remove_branch`,
            param2: branch.id,
            terminal: false,
          },
        ]);
        content.push({
          text: `${statusIconMap.new}: ${branch.branch}`,
          href: branch.url,
          size: font_size,
          submenu,
        });
      });
      pulls.forEach((pull) => {
        let icon;
        if (
          pull.author !== username &&
          !pull.assignees.find((assignee) => assignee === username)
        ) {
          icon = "reviewer";
        } else if (pull.isDraft) {
          icon = "draft";
        } else {
          icon = (() => {
            switch (pull.mergeStateStatus) {
              case "BEHIND": // The head ref is out of date.
              case "DIRTY": // The merge commit cannot be cleanly created.
                return "unmergable";
              case "CLEAN": // Mergeable and passing commit status.
                return "approved";
              case "DRAFT": // The merge is blocked due to the pull request being a draft.
                return "draft";
              case "HAS_HOOKS": // Mergeable with passing commit status and pre-receive hooks.
                return "ciRunning";
              case "UNSTABLE": // Mergeable with non-passing commit status.
                if (pull.latestCheck === "running") return "ciRunning";
                return "failed";
              case "UNKNOWN": // The state cannot currently be determined.
              case "BLOCKED": // The merge is blocked.
              default:
                if (pull.reviewDecision === "CHANGES_REQUESTED")
                  return "blocked";
                switch (pull.latestCheck) {
                  case "running":
                    return "ciRunning";
                  case "failed":
                    return "failed";
                }
                return "pending";
            }
          })();
        }
        incrementStatusCount(icon);
        let submenu = [
          {
            text: "Branch",
            size: process.env.PROJECT_FONT_SIZE,
          },
          {
            text: pull.headRef,
            size: font_size,
          },
        ];
        if (pull.body) {
          submenu = submenu.concat([
            {
              text: "Description",
              size: process.env.PROJECT_FONT_SIZE,
            },
            { text: pull.body, size: font_size },
          ]);
        }
        content.push({
          text: `#${pull.number} ${pull.title} ${statusIconMap[icon]}`,
          href: pull.url,
          size: font_size,
          submenu: submenu,
        });
      });
    });
    const statusString = MENU_BAR_ORDER.map((key) => {
      if (statusCount[key]) {
        return `${statusIconMap[key]}[${statusCount[key]}]`;
      }
    })
      .filter((s) => !!s)
      .join(", ");
    content.unshift({
      image: githubIconBase64,
      text: ` ${statusString}`,
      color: textColor,
      dropdown: true,
    });

    // Update settings file for speed later
    await updateSettings();

    function sanitizeText(items) {
      return items.map((element) => {
        if (typeof element !== "object") return element;
        let { text, submenu, ...result } = element;
        if (text) result.text = text.replace(/\|/gi, "┃");
        if (submenu) result.submenu = sanitizeText(submenu);
        return result;
      });
    }
    try {
      await xbar.default(sanitizeText(content));
    } catch (error) {
      console.log(JSON.stringify(content, null, 2));
      throw error;
    }
  }

  getPulls().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
 */
async function globalRequire(packageName) {
  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.main || packageMeta.files[0]);

  return await import(main);
}

async function installAndRequire(module) {
  try {
    return await import(module);
  } catch (e) {
    try {
      return await globalRequire(module);
    } catch (e) {
      installModule(module);

      // Not catching error if one is thrown.
      return await globalRequire(module);
    }
  }
}

/**
 * Installs node module if it doesn't exit.)
 */
function installModule(module) {
  // Allows one to run the npm command as if on the command line.
  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
  const npm = npmBin();

  // The install command
  const cmd = npm + " install -g " + module;

  console.log("Installing the " + module + " 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 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();
}