Danger JS: Helping Paste Focus on Paste

Developer automating team's PR checklist with Danger JS
October 18, 2022
Written by
Twilion
Reviewed by
Twilion
Twilion
Twilion
Twilion

There’s always a checklist. Whether you’re writing code or reviewing it, every repo has a checklist of what should be included in a PR – and sometimes what shouldn’t. But what happens when that list becomes unwieldy as a project grows? Automating important parts of that checklist is vital to efficiency and consistency. That’s where Danger JS comes in.

Danger JS allows you to create custom rules about what your team’s PRs should look like. Its flexibility makes replacing many types of error-prone tasks possible. In the Danger team’s words:

You can:

  • Enforce CHANGELOGs
  • Enforce links to Trello/JIRA in PR/MR bodies
  • Enforce using descriptive labels
  • Look out for common anti-patterns
  • Highlight interesting build artifacts
  • Give warnings when specific files change

Danger provides the glue to let you build out the rules specific to your team's culture, offering useful metadata and a comprehensive plugin system to share common issues.

Twilio’s Paste Design System helps us build accessible, cohesive, and high-quality customer experiences. However, as our design system grows, so does the possibility of stray errors. So how does Paste take advantage of the power of Danger? Although we’ve written a few rules, our first – and most powerful – has been a rule to enforce accurate versioning for our package updates. Let me show you how the team works with Danger to keep our packages versioned accurately with the least amount of effort!

Our Danger JS rules, explained

Enforce versioning (missing Changesets check)

        For our versioning and changelog generating tool, Changesets, to work best, every PR that modifies one of our public packages should include a Changeset file. We got tired of telling each other “Oy! This needs a Changeset” in reviews, so now we let Danger do it for us!

Our process is (if you prefer, you can follow along in the source here):

1. Compare the list of changes in the PR to Paste’s public packages (the ones that need versioning)

const publicPackages = getPublicPackages(packageList);
const publicPackagePaths = getPackagePaths(publicPackages);

const changeList = [...danger.git.modified_files, ...danger.git.created_files];

/** Modified files that belong to public packages */
const modifiedPublicPackageFiles = getPublicPackageFilesFromFiles(changeList, publicPackagePaths);
/** List of public packages that have changes in src files, that will need to be published */
const publicPackagesWithUnpublishedSourceChanges = getUnpublishedPackageNames(
  modifiedPublicPackageFiles,
  publicPackages
);

/** Modified Changeset files */
const modifiedChangeSetFiles = getChangesetsFromFiles(changeList);

Using our package crawling utilities and a danger object typed to Danger’s DangerDSLType, we can determine which of our packages need a Changeset associated with them. We also check to see if any Changeset files have been added or changed.

2. If changes come from public packages, does any Changeset file include it?

export const getMissingPackagesFromChangesets = (changesets: string[], packagesWithChanges: string[]) => {
  const packagesInChangesets: string[] = [];
  changesets.forEach((filePath) => {
    const fileContent = fs.readFileSync(filePath).toString();
    packagesWithChanges.forEach((packageName) => {
      if (fileContent.match(packageName)) {
        packagesInChangesets.push(packageName);
      }
    });
  });
  return difference(packagesWithChanges, packagesInChangesets);
};

We wrote a getMissingPackagesFromChangesets utility that uses the two pertinent pieces of information, our modified Changeset files and our modified, relevant packages, and returns an array of packages that have gone rogue (have been changed but do not have an associated Changeset file).

3. If we have a rogue package, our Danger check will fail

/**
  * Fail when a published package is changed with no changeset
*/
if (modifiedPublicPackageFiles.length > 0) {
  const missingPackages = getMissingPackagesFromChangesets(
    modifiedChangeSetFiles,
    publicPackagesWithUnpublishedSourceChanges
  );
  const idea = 'edit an existing changeset or run `yarn changeset` to create one';
  if (missingPackages.length > 0) {
    missingPackages.forEach((packageName: string) => {
      fail(`Looks like ${packageName} was not included in a changeset - *${idea}*`);
    });
  }
}

If anything is returned from our getMissingPackagesFromChangesets utility, Danger will fail and output the specific reason for failing into a HTML table. To be as user friendly as possible, our plugin includes the package name in this message, and an idea on how to fix it.

That’s it! Since we have implemented this Danger rule, we haven't had issues with missing Changeset files, or having to bug anyone to add one. It also inspired us to write more, like the next one about external dependency versioning.

Pin external dependencies

        We use external dependencies in our repo, but to protect our work from unexpected changes, we pin them. How do we make a rule that enforces this?

Our process (follow along in the source here):

1. Find the package.json files that need inspecting

const packageJSONsChanged = getPackJsonsFromFiles(
  [...danger.git.modified_files, ...danger.git.created_files]
);

Again, Danger lets us know which files were modified and created in the last commit. We run through this list and filter for package.json files.

2. Search package.json files for unpinned external dependencies

/**
 * Finds a list of external dependencies that are unpinned, whilst filtering out internal packages.
 *
 * @param {Record<string, string>} deps
 * @return {*}  {string[]}
 */
export const getUnpinnedExternalDeps = (deps: Record<string, string>): string[] => {
  if (deps) {
    return (
      Object.keys(deps)
        // we don't want internal packages
        .filter((dep) => !dep.includes('@twilio-paste'))
        // we want anything that has a ^
        .filter((dep) => deps[dep].startsWith('^'))
    );
  } else {
    return [];
  }
};

After excluding Paste dependencies, we look for any starting with the ^ character, which indicates that this version accepts a range of versions, which we don’t want.

3. If we have an unpinned external package dependency, our Danger check will fail

/**
 * Warn when user forgets to pin an external dep
*/
if (packageJSONsChanged.length > 0) {
  const unpinnedExternalDeps = getUnPinnedExternalDepsFromPackageJSONFiles(packageJSONsChanged);

  if (unpinnedExternalDeps.length > 0) {
    fail(
      `There are some package.json files in this PR that contain unpinned external package libraries. Please pin your external package libraries by removing the ^ from the beginning of the version number. See: ${JSON.stringify(
        unpinnedExternalDeps
      )}`
    );
  }
}

If anything is returned from our function and is an unpinned external dependency, Danger will fail and indicate both the problem and its solution. All set!

The Danger effect

As Twilio’s design systems team has grown, we’ve benefitted immensely from Danger JS automatically alerting us to simple but impactful errors, such as missing versioning information and unpinned external dependencies. Since we have implemented these Danger rules, they’ve allowed us to focus on solving design system problems, as opposed to human memory hiccups. And we keep finding new places to create useful rules all the time! Check out our .danger folder to see them all 😎.

Like how we’ve used our tools? Learn more about our design system, tooling, and tracking from our post on insights and metrics!

Glorilí Alejandro is a UX Engineer for Twilio’s Paste design system. She is passionate about creating a more human centered world through thoughtful design and craftsmanship. She also stress bakes, and is always looking for her next, great dessert. Reach her at galejandro [at] twilio.com.