From 982896d5d10fe65886ef42bf987468ff649d5c91 Mon Sep 17 00:00:00 2001
From: Sergio Zharinov <zharinov@users.noreply.github.com>
Date: Fri, 4 Oct 2019 11:13:14 +0400
Subject: [PATCH] feat: Elixir support (#4496)

---
 Dockerfile                                    |  30 ++++
 docs/usage/configuration-options.md           |  13 ++
 lib/config/definitions.ts                     |  13 ++
 lib/datasource/hex/index.ts                   |  97 ++++++------
 lib/manager/index.ts                          |   2 +
 lib/manager/mix/artifacts.ts                  | 105 +++++++++++++
 lib/manager/mix/extract.ts                    |  66 +++++++++
 lib/manager/mix/index.ts                      |   5 +
 lib/manager/mix/readme.md                     | 140 ++++++++++++++++++
 lib/manager/mix/update.ts                     |  24 +++
 lib/versioning/hex/index.ts                   |  10 +-
 lib/versioning/hex/readme.md                  |   4 +-
 renovate-schema.json                          |  10 ++
 .../__snapshots__/validation.spec.ts.snap     |   2 +-
 test/datasource/hex.spec.ts                   |  27 +++-
 .../mix/__snapshots__/artifacts.spec.ts.snap  |  23 +++
 .../mix/__snapshots__/extract.spec.ts.snap    |  75 ++++++++++
 test/manager/mix/_fixtures/mix.exs            |  29 ++++
 test/manager/mix/artifacts.spec.ts            |  71 +++++++++
 test/manager/mix/extract.spec.ts              |  20 +++
 test/manager/mix/update.spec.ts               |  50 +++++++
 test/versioning/hex.spec.ts                   |  83 ++++++-----
 .../extract/__snapshots__/index.spec.js.snap  |   3 +
 23 files changed, 813 insertions(+), 89 deletions(-)
 create mode 100644 lib/manager/mix/artifacts.ts
 create mode 100644 lib/manager/mix/extract.ts
 create mode 100644 lib/manager/mix/index.ts
 create mode 100644 lib/manager/mix/readme.md
 create mode 100644 lib/manager/mix/update.ts
 create mode 100644 test/manager/mix/__snapshots__/artifacts.spec.ts.snap
 create mode 100644 test/manager/mix/__snapshots__/extract.spec.ts.snap
 create mode 100644 test/manager/mix/_fixtures/mix.exs
 create mode 100644 test/manager/mix/artifacts.spec.ts
 create mode 100644 test/manager/mix/extract.spec.ts
 create mode 100644 test/manager/mix/update.spec.ts

diff --git a/Dockerfile b/Dockerfile
index c6974efbb1..23babc89cc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -74,6 +74,31 @@ RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \
 
 ## END copy Node.js
 
+# Erlang
+
+RUN cd /tmp && \
+    curl https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb -o erlang-solutions_1.0_all.deb && \
+    dpkg -i erlang-solutions_1.0_all.deb && \
+    rm -f erlang-solutions_1.0_all.deb
+
+ENV ERLANG_VERSION=22.0.2-1
+
+RUN apt-get update && \
+    apt-cache policy esl-erlang && \
+    apt-get install -y esl-erlang=1:$ERLANG_VERSION && \
+    apt-get clean
+
+# Elixir
+
+ENV ELIXIR_VERSION 1.8.2
+
+RUN curl -L https://github.com/elixir-lang/elixir/releases/download/v${ELIXIR_VERSION}/Precompiled.zip -o Precompiled.zip && \
+    mkdir -p /opt/elixir-${ELIXIR_VERSION}/ && \
+    unzip Precompiled.zip -d /opt/elixir-${ELIXIR_VERSION}/ && \
+    rm Precompiled.zip
+
+ENV PATH $PATH:/opt/elixir-${ELIXIR_VERSION}/bin
+
 # PHP Composer
 
 RUN apt-get update && apt-get install -y php-cli php-mbstring && apt-get clean
@@ -143,6 +168,11 @@ RUN set -ex ;\
   curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain none -y ; \
   rustup toolchain install 1.36.0
 
+# Mix and Rebar
+
+RUN mix local.hex --force
+RUN mix local.rebar --force
+
 # Pipenv
 
 ENV PATH="/home/ubuntu/.local/bin:$PATH"
diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 033828b269..2fc98c87d7 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -552,6 +552,19 @@ Set enabled to `true` to enable meteor package updating.
 
 Add to this object if you wish to define rules that apply only to minor updates.
 
+## mix
+
+Elixir support is in beta stage.
+It should be explicitly enabled in configuration file:
+
+```json
+{
+  "mix": {
+    "enabled": true
+  }
+}
+```
+
 ## node
 
 Using this configuration option allows you to apply common configuration and policies across all Node.js version updates even if managed by different package managers (`npm`, `yarn`, etc.).
diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts
index e69d38c85f..182ac2af95 100644
--- a/lib/config/definitions.ts
+++ b/lib/config/definitions.ts
@@ -1421,6 +1421,19 @@ const options: RenovateOptions[] = [
     },
     mergeable: true,
   },
+  {
+    name: 'mix',
+    releaseStatus: 'beta',
+    description: 'Configuration object for Mix module renovation',
+    stage: 'repository',
+    type: 'object',
+    default: {
+      enabled: false,
+      fileMatch: ['(^|/)mix\\.exs$'],
+      versionScheme: 'hex',
+    },
+    mergeable: true,
+  },
   {
     name: 'rust',
     releaseStatus: 'unpublished',
diff --git a/lib/datasource/hex/index.ts b/lib/datasource/hex/index.ts
index 2ad134f203..85cfa46d4f 100644
--- a/lib/datasource/hex/index.ts
+++ b/lib/datasource/hex/index.ts
@@ -2,69 +2,80 @@ import { logger } from '../../logger';
 import got from '../../util/got';
 import { ReleaseResult, PkgReleaseConfig } from '../common';
 
-function getHostOpts() {
-  return {
-    json: true,
-    hostType: 'hex',
-  };
-}
-
 interface HexRelease {
   html_url: string;
   meta?: { links?: Record<string, string> };
-  name?: string;
   releases?: { version: string }[];
 }
 
 export async function getPkgReleases({
   lookupName,
 }: Partial<PkgReleaseConfig>): Promise<ReleaseResult | null> {
-  const hexUrl = `https://hex.pm/api/packages/${lookupName}`;
+  // istanbul ignore if
+  if (!lookupName) {
+    logger.warn('hex lookup failure: No lookupName');
+    return null;
+  }
+
+  // Get dependency name from lookupName.
+  // If the dependency is private lookupName contains organization name as following:
+  // depName:organizationName
+  // depName is used to pass it in hex dep url
+  // organizationName is used for accessing to private deps
+  const depName = lookupName.split(':')[0];
+  const hexUrl = `https://hex.pm/api/packages/${depName}`;
   try {
-    const opts = getHostOpts();
-    const res: HexRelease = (await got(hexUrl, {
+    const response = await got(hexUrl, {
       json: true,
-      ...opts,
-    })).body;
-    if (!(res && res.releases && res.name)) {
-      logger.warn({ lookupName }, `Received invalid hex package data`);
+      hostType: 'hex',
+    });
+
+    const hexRelease: HexRelease = response.body;
+
+    if (!hexRelease) {
+      logger.warn({ depName }, `Invalid response body`);
+      return null;
+    }
+
+    const { releases = [], html_url: homepage, meta } = hexRelease;
+
+    if (releases.length === 0) {
+      logger.info(`No versions found for ${depName} (${hexUrl})`); // prettier-ignore
       return null;
     }
+
     const result: ReleaseResult = {
-      releases: [],
+      releases: releases.map(({ version }) => ({ version })),
     };
-    if (res.releases) {
-      result.releases = res.releases.map(version => ({
-        version: version.version,
-      }));
+
+    if (homepage) {
+      result.homepage = homepage;
     }
-    if (res.meta && res.meta.links) {
-      result.sourceUrl = res.meta.links.Github;
+
+    if (meta && meta.links && meta.links.Github) {
+      result.sourceUrl = hexRelease.meta.links.Github;
     }
-    result.homepage = res.html_url;
+
     return result;
   } catch (err) {
-    if (err.statusCode === 401) {
-      logger.info({ lookupName }, `Authorization failure: not authorized`);
-      logger.debug(
-        {
-          err,
-        },
-        'Authorization error'
-      );
-      return null;
+    const errorData = { depName, err };
+
+    if (
+      err.statusCode === 429 ||
+      (err.statusCode >= 500 && err.statusCode < 600)
+    ) {
+      logger.warn({ lookupName, err }, `hex.pm registry failure`);
+      throw new Error('registry-failure');
     }
-    if (err.statusCode === 404) {
-      logger.info({ lookupName }, `Dependency lookup failure: not found`);
-      logger.debug(
-        {
-          err,
-        },
-        'Package lookup error'
-      );
-      return null;
+
+    if (err.statusCode === 401) {
+      logger.debug(errorData, 'Authorization error');
+    } else if (err.statusCode === 404) {
+      logger.debug(errorData, 'Package lookup error');
+    } else {
+      logger.warn(errorData, 'hex lookup failure: Unknown error');
     }
-    logger.warn({ err, lookupName }, 'hex lookup failure: Unknown error');
-    return null;
   }
+
+  return null;
 }
diff --git a/lib/manager/index.ts b/lib/manager/index.ts
index d6f49eff05..6e02db23e9 100644
--- a/lib/manager/index.ts
+++ b/lib/manager/index.ts
@@ -32,6 +32,7 @@ const managerList = [
   'leiningen',
   'maven',
   'meteor',
+  'mix',
   'npm',
   'nuget',
   'nvm',
@@ -56,6 +57,7 @@ const languageList = [
   'dart',
   'docker',
   'dotnet',
+  'elixir',
   'golang',
   'js',
   'node',
diff --git a/lib/manager/mix/artifacts.ts b/lib/manager/mix/artifacts.ts
new file mode 100644
index 0000000000..7728a5c402
--- /dev/null
+++ b/lib/manager/mix/artifacts.ts
@@ -0,0 +1,105 @@
+import upath from 'upath';
+import fs from 'fs-extra';
+import { hrtime } from 'process';
+import { platform } from '../../platform';
+import { exec } from '../../util/exec';
+import { logger } from '../../logger';
+import { UpdateArtifactsConfig, UpdateArtifactsResult } from '../common';
+
+export async function updateArtifacts(
+  packageFileName: string,
+  updatedDeps: string[],
+  newPackageFileContent: string,
+  config: UpdateArtifactsConfig
+): Promise<UpdateArtifactsResult[] | null> {
+  await logger.debug(`mix.getArtifacts(${packageFileName})`);
+  if (updatedDeps.length < 1) {
+    logger.debug('No updated mix deps - returning null');
+    return null;
+  }
+
+  const cwd = config.localDir;
+  if (!cwd) {
+    logger.debug('No local dir specified');
+    return null;
+  }
+
+  const lockFileName = 'mix.lock';
+  try {
+    const localPackageFileName = upath.join(cwd, packageFileName);
+    await fs.outputFile(localPackageFileName, newPackageFileContent);
+  } catch (err) {
+    logger.warn({ err }, 'mix.exs could not be written');
+    return [
+      {
+        lockFileError: {
+          lockFile: lockFileName,
+          stderr: err.message,
+        },
+      },
+    ];
+  }
+
+  const existingLockFileContent = await platform.getFile(lockFileName);
+  if (!existingLockFileContent) {
+    logger.debug('No mix.lock found');
+    return null;
+  }
+
+  const cmdParts =
+    config.binarySource === 'docker'
+      ? [
+          'docker',
+          'run',
+          '--rm',
+          `-v ${cwd}:${cwd}`,
+          `-w ${cwd}`,
+          'renovate/mix mix',
+        ]
+      : ['mix'];
+  cmdParts.push('deps.update');
+
+  const startTime = hrtime();
+  /* istanbul ignore next */
+  try {
+    const command = [...cmdParts, ...updatedDeps].join(' ');
+    const { stdout, stderr } = await exec(command, { cwd });
+    logger.debug(stdout);
+    if (stderr) logger.warn('error: ' + stderr);
+  } catch (err) {
+    logger.warn(
+      { err, message: err.message },
+      'Failed to update Mix lock file'
+    );
+
+    return [
+      {
+        lockFileError: {
+          lockFile: lockFileName,
+          stderr: err.message,
+        },
+      },
+    ];
+  }
+
+  const duration = hrtime(startTime);
+  const seconds = Math.round(duration[0] + duration[1] / 1e9);
+  logger.info({ seconds, type: 'mix.lock' }, 'Updated lockfile');
+  logger.debug('Returning updated mix.lock');
+
+  const localLockFileName = upath.join(cwd, lockFileName);
+  const newMixLockContent = await fs.readFile(localLockFileName, 'utf8');
+  if (existingLockFileContent === newMixLockContent) {
+    logger.debug('mix.lock is unchanged');
+    return null;
+  }
+
+  return [
+    {
+      file: {
+        name: lockFileName,
+        contents: newMixLockContent,
+      },
+    },
+  ];
+}
diff --git a/lib/manager/mix/extract.ts b/lib/manager/mix/extract.ts
new file mode 100644
index 0000000000..2fa61084c3
--- /dev/null
+++ b/lib/manager/mix/extract.ts
@@ -0,0 +1,66 @@
+import { isValid } from '../../versioning/hex';
+import { logger } from '../../logger';
+import { PackageDependency, PackageFile } from '../common';
+
+const depSectionRegExp = /defp\s+deps.*do/g;
+const depMatchRegExp = /{:(\w+),\s*([^:"]+)?:?\s*"([^"]+)",?\s*(organization: "(.*)")?.*}/gm;
+
+export function extractPackageFile(content: string): PackageFile {
+  logger.trace('mix.extractPackageFile()');
+  const deps: PackageDependency[] = [];
+  const contentArr = content.split('\n');
+
+  for (let lineNumber = 0; lineNumber < contentArr.length; lineNumber += 1) {
+    if (contentArr[lineNumber].match(depSectionRegExp)) {
+      logger.trace(`Matched dep section on line ${lineNumber}`);
+      let depBuffer = '';
+      do {
+        depBuffer += contentArr[lineNumber] + '\n';
+        lineNumber += 1;
+      } while (!contentArr[lineNumber].includes('end'));
+      let depMatch: RegExpMatchArray;
+      do {
+        depMatch = depMatchRegExp.exec(depBuffer);
+        if (depMatch) {
+          const depName = depMatch[1];
+          const datasource = depMatch[2];
+          const currentValue = depMatch[3];
+          const organization = depMatch[5];
+
+          const dep: PackageDependency = {
+            depName,
+            currentValue,
+            managerData: {},
+          };
+
+          dep.datasource = datasource || 'hex';
+
+          if (dep.datasource === 'hex') {
+            dep.currentValue = currentValue;
+            dep.lookupName = depName;
+          }
+
+          if (organization) {
+            dep.lookupName += ':' + organization;
+          }
+
+          if (!isValid(currentValue)) {
+            dep.skipReason = 'unsupported-version';
+          }
+
+          if (dep.datasource !== 'hex') {
+            dep.skipReason = 'non-hex depTypes';
+          }
+
+          // Find dep's line number
+          for (let i = 0; i < contentArr.length; i += 1)
+            if (contentArr[i].includes(`:${depName},`))
+              dep.managerData.lineNumber = i;
+
+          deps.push(dep);
+        }
+      } while (depMatch);
+    }
+  }
+  return { deps };
+}
diff --git a/lib/manager/mix/index.ts b/lib/manager/mix/index.ts
new file mode 100644
index 0000000000..ed11a06c48
--- /dev/null
+++ b/lib/manager/mix/index.ts
@@ -0,0 +1,5 @@
+export { extractPackageFile } from './extract';
+export { updateDependency } from './update';
+export { updateArtifacts } from './artifacts';
+
+export const language = 'elixir';
diff --git a/lib/manager/mix/readme.md b/lib/manager/mix/readme.md
new file mode 100644
index 0000000000..9842682168
--- /dev/null
+++ b/lib/manager/mix/readme.md
@@ -0,0 +1,140 @@
+## Overview
+
+#### Name of package manager
+
+Mix
+
+#### Implementation status
+
+Implemented
+
+#### What language does this support?
+
+Elixir
+
+#### Does that language have other (competing?) package managers?
+
+Mix is main Elixir build tool
+
+## Package File Detection
+
+#### What type of package files and names does it use?
+
+mix.exs
+
+#### What [fileMatch](https://docs.renovatebot.com/configuration-options/#filematch) pattern(s) should be used?
+
+File names are static
+
+#### Is it likely that many users would need to extend this pattern for custom file names?
+
+No, file names are static
+
+#### Is the fileMatch pattern likely to get many "false hits" for files that have nothing to do with package management?
+
+No
+
+## Parsing and Extraction
+
+#### Can package files have "local" links to each other that need to be resolved?
+
+No
+
+#### Is there reason why package files need to be parsed together (in serial) instead of independently?
+
+No
+
+#### What format/syntax is the package file in? e.g. JSON, TOML, custom?
+
+Custom
+
+#### How do you suggest parsing the file? Using an off-the-shelf parser, using regex, or can it be custom-parsed line by line?
+
+RegExp
+
+#### Does the package file structure distinguish between different "types" of dependencies? e.g. production dependencies, dev dependencies, etc?
+
+No
+
+#### List all the sources/syntaxes of dependencies that can be extracted:
+
+    ```
+    defp deps() do
+        [
+            {:ecto, "~> 2.0"},
+            {:postgrex, "~> 0.8.1"},
+            {:cowboy, github: "ninenines/cowboy"},
+        ]
+    ```
+
+#### Describe which types of dependencies above are supported and which will be implemented in future:
+
+All that are mentioned
+
+## Versioning
+
+#### What versioning scheme do the package files use?
+
+SemVer 2.0 schema
+
+#### Does this versioning scheme support range constraints, e.g. `^1.0.0` or `1.x`?
+
+Yes, ([doc link](https://hexdocs.pm/elixir/Version.html))
+
+#### Is this package manager used for applications, libraries, or both? If both, is there a way to tell which is which?
+
+There are only modules that can be used as apps and libs
+
+#### If ranges are supported, are there any cases when Renovate should pin ranges to exact versions if rangeStrategy=auto?
+
+Supported following ranges of hex datasource:
+
+- ~> 1.0.0/ ~>1.0
+- and/or ranges
+
+## Lookup
+
+#### Is a new datasource required? Provide details
+
+Implemented ([here]https://github.com/renovatebot/renovate/issues/3043())
+
+#### Will users need the capability to specify a custom host/registry to look up? Can it be found within the package files, or within other files inside the repository, or would it require Renovate configuration?
+
+Mix supports dependencies hosted as git repositories:
+
+```
+ {:cowboy, github: "ninenines/cowboy"},
+```
+
+#### Do the package files contain any "constraints" on the parent language (e.g. supports only v3.x of Python) or platform (Linux, Windows, etc) that should be used in the lookup procedure?
+
+No
+
+#### Will users need the ability to configure language or other constraints using Renovate config?
+
+No
+
+## Artifacts
+
+#### Are lock files or checksum files used? Mandatory?
+
+Lock files are mix.lock and they are mandatory
+
+#### If so, what tool and exact commands should be used if updating 1 or more package versions in a dependency file?
+
+([mix deps.update](https://hexdocs.pm/mix/master/Mix.Tasks.Deps.Update.html))
+
+#### If applicable, describe how the tool maintains a cache and if it can be controlled via CLI or env? Do you recommend the cache be kept or disabled/ignored?
+
+Mix doesn't cache deps only put compiled files via mix deps.compile. Build files can be deleted via mix deps.clean --build
+
+#### If applicable, what command should be used to generate a lock file from scratch if you already have a package file? This will be used for "lock file maintenance".
+
+mix deps.get - gets dependencies and creates/updates .lock file
+mix deps.uplock (--all) - deletes dependency in .lock file
+
+## Other
+
+Pm files can contain comments that can make a line with dep ignored
+
+#### Is there anything else to know about this package manager?
diff --git a/lib/manager/mix/update.ts b/lib/manager/mix/update.ts
new file mode 100644
index 0000000000..c4922fb0ee
--- /dev/null
+++ b/lib/manager/mix/update.ts
@@ -0,0 +1,24 @@
+import { logger } from '../../logger';
+import { Upgrade } from '../common';
+
+export function updateDependency(
+  fileContent: string,
+  upgrade: Upgrade
+): string | null {
+  logger.debug(`mix.updateDependency: ${upgrade.newValue}`);
+
+  const lines = fileContent.split('\n');
+  const lineToChange = lines[upgrade.managerData.lineNumber];
+
+  if (!lineToChange.includes(upgrade.depName)) return null;
+
+  const newLine = lineToChange.replace(/"(.*?)"/, `"${upgrade.newValue}"`);
+
+  if (newLine === lineToChange) {
+    logger.debug('No changes necessary');
+    return fileContent;
+  }
+
+  lines[upgrade.managerData.lineNumber] = newLine;
+  return lines.join('\n');
+}
diff --git a/lib/versioning/hex/index.ts b/lib/versioning/hex/index.ts
index 1af0860fe2..a6e396fb93 100644
--- a/lib/versioning/hex/index.ts
+++ b/lib/versioning/hex/index.ts
@@ -58,16 +58,20 @@ const getNewValue = (
     toVersion
   );
   newSemver = npm2hex(newSemver);
-  if (currentValue.match(/~>\s*(\d+\.\d+)$/))
+  if (currentValue.match(/~>\s*(\d+\.\d+)$/)) {
     newSemver = newSemver.replace(
-      /\^\s*(\d+\.\d+)/,
+      /\^\s*(\d+\.\d+(\.\d)?)/,
       (_str, p1) => '~> ' + p1.slice(0, -2)
     );
-  else newSemver = newSemver.replace(/~\s*(\d+\.\d+\.\d)/, '~> $1');
+  } else {
+    newSemver = newSemver.replace(/~\s*(\d+\.\d+\.\d)/, '~> $1');
+  }
 
   return newSemver;
 };
 
+export { isValid };
+
 export const api: VersioningApi = {
   ...npm,
   isLessThanRange,
diff --git a/lib/versioning/hex/readme.md b/lib/versioning/hex/readme.md
index 4e496b8f0f..23ee8f6eba 100644
--- a/lib/versioning/hex/readme.md
+++ b/lib/versioning/hex/readme.md
@@ -8,11 +8,11 @@ This versioning syntax is used for Elixir and Erlang hex dependencies
 
 ## What type of versioning is used?
 
-Hex versions are base on [Semantic Versioning 2.0](https://semver.org)
+Hex versions are based on [Semantic Versioning 2.0](https://semver.org)
 
 ## Are ranges supported? How?
 
-Hex supports a subset of npm's range syntax.
+Hex supports a [subset of npm range syntax](https://hexdocs.pm/elixir/Version.html).
 
 ## Range Strategy support
 
diff --git a/renovate-schema.json b/renovate-schema.json
index db9cb98a95..a79b70fa12 100644
--- a/renovate-schema.json
+++ b/renovate-schema.json
@@ -916,6 +916,16 @@
       },
       "$ref": "#"
     },
+    "mix": {
+      "description": "Configuration object for Mix module renovation",
+      "type": "object",
+      "default": {
+        "enabled": false,
+        "fileMatch": ["(^|/)mix\\.exs$"],
+        "versionScheme": "hex"
+      },
+      "$ref": "#"
+    },
     "rust": {
       "description": "Configuration option for Rust package management.",
       "type": "object",
diff --git a/test/config/__snapshots__/validation.spec.ts.snap b/test/config/__snapshots__/validation.spec.ts.snap
index 811d4ee9dc..3fa102606a 100644
--- a/test/config/__snapshots__/validation.spec.ts.snap
+++ b/test/config/__snapshots__/validation.spec.ts.snap
@@ -87,7 +87,7 @@ Array [
     "depName": "Configuration Error",
     "message": "packageRules:
         You have included an unsupported manager in a package rule. Your list: foo.
-        Supported managers are: (ansible, bazel, buildkite, bundler, cargo, circleci, composer, deps-edn, docker-compose, dockerfile, droneci, github-actions, gitlabci, gitlabci-include, gomod, gradle, gradle-wrapper, homebrew, kubernetes, leiningen, maven, meteor, npm, nuget, nvm, pip_requirements, pip_setup, pipenv, poetry, pub, sbt, swift, terraform, travis, ruby-version).",
+        Supported managers are: (ansible, bazel, buildkite, bundler, cargo, circleci, composer, deps-edn, docker-compose, dockerfile, droneci, github-actions, gitlabci, gitlabci-include, gomod, gradle, gradle-wrapper, homebrew, kubernetes, leiningen, maven, meteor, mix, npm, nuget, nvm, pip_requirements, pip_setup, pipenv, poetry, pub, sbt, swift, terraform, travis, ruby-version).",
   },
 ]
 `;
diff --git a/test/datasource/hex.spec.ts b/test/datasource/hex.spec.ts
index cb5aacc3a7..60fed3c182 100644
--- a/test/datasource/hex.spec.ts
+++ b/test/datasource/hex.spec.ts
@@ -27,7 +27,12 @@ describe('datasource/hex', () => {
       ).toBeNull();
     });
     it('returns null for missing fields', async () => {
-      got.mockReturnValueOnce({ res: {} });
+      got.mockReturnValueOnce({});
+      expect(
+        await getPkgReleases({ lookupName: 'non_existent_package' })
+      ).toBeNull();
+
+      got.mockReturnValueOnce({ body: {} });
       expect(
         await getPkgReleases({ lookupName: 'non_existent_package' })
       ).toBeNull();
@@ -48,6 +53,26 @@ describe('datasource/hex', () => {
       );
       expect(await getPkgReleases({ lookupName: 'some_package' })).toBeNull();
     });
+    it('throws for 429', async () => {
+      got.mockImplementationOnce(() =>
+        Promise.reject({
+          statusCode: 429,
+        })
+      );
+      await expect(
+        getPkgReleases({ lookupName: 'some_crate' })
+      ).rejects.toThrowError('registry-failure');
+    });
+    it('throws for 5xx', async () => {
+      got.mockImplementationOnce(() =>
+        Promise.reject({
+          statusCode: 502,
+        })
+      );
+      await expect(
+        getPkgReleases({ lookupName: 'some_crate' })
+      ).rejects.toThrowError('registry-failure');
+    });
     it('returns null for unknown error', async () => {
       got.mockImplementationOnce(() => {
         throw new Error();
diff --git a/test/manager/mix/__snapshots__/artifacts.spec.ts.snap b/test/manager/mix/__snapshots__/artifacts.spec.ts.snap
new file mode 100644
index 0000000000..ea5a8db318
--- /dev/null
+++ b/test/manager/mix/__snapshots__/artifacts.spec.ts.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`.updateArtifacts() catches errors 1`] = `
+Array [
+  Object {
+    "lockFileError": Object {
+      "lockFile": "mix.lock",
+      "stderr": "not found",
+    },
+  },
+]
+`;
+
+exports[`.updateArtifacts() returns updated mix.lock 1`] = `
+Array [
+  Object {
+    "file": Object {
+      "contents": "New mix.lock",
+      "name": "mix.lock",
+    },
+  },
+]
+`;
diff --git a/test/manager/mix/__snapshots__/extract.spec.ts.snap b/test/manager/mix/__snapshots__/extract.spec.ts.snap
new file mode 100644
index 0000000000..ea0aecc3cf
--- /dev/null
+++ b/test/manager/mix/__snapshots__/extract.spec.ts.snap
@@ -0,0 +1,75 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`lib/manager/mix/extract extractPackageFile() extracts all dependencies 1`] = `
+Array [
+  Object {
+    "currentValue": "~> 0.8.1",
+    "datasource": "hex",
+    "depName": "postgrex",
+    "lookupName": "postgrex",
+    "managerData": Object {
+      "lineNumber": 18,
+    },
+  },
+  Object {
+    "currentValue": ">2.1.0 or <=3.0.0",
+    "datasource": "hex",
+    "depName": "ecto",
+    "lookupName": "ecto",
+    "managerData": Object {
+      "lineNumber": 19,
+    },
+  },
+  Object {
+    "currentValue": "ninenines/cowboy",
+    "datasource": "github",
+    "depName": "cowboy",
+    "managerData": Object {
+      "lineNumber": 20,
+    },
+    "skipReason": "non-hex depTypes",
+  },
+  Object {
+    "currentValue": "~> 1.0",
+    "datasource": "hex",
+    "depName": "secret",
+    "lookupName": "secret:acme",
+    "managerData": Object {
+      "lineNumber": 21,
+    },
+  },
+  Object {
+    "currentValue": ">2.1.0 and <=3.0.0",
+    "datasource": "hex",
+    "depName": "ex_doc",
+    "lookupName": "ex_doc",
+    "managerData": Object {
+      "lineNumber": 22,
+    },
+  },
+  Object {
+    "currentValue": ">= 1.0.0",
+    "datasource": "hex",
+    "depName": "jason",
+    "lookupName": "jason",
+    "managerData": Object {
+      "lineNumber": 24,
+    },
+  },
+  Object {
+    "currentValue": "~> 1.0",
+    "datasource": "hex",
+    "depName": "jason",
+    "lookupName": "jason",
+    "managerData": Object {
+      "lineNumber": 24,
+    },
+  },
+]
+`;
+
+exports[`lib/manager/mix/extract extractPackageFile() returns empty for invalid dependency file 1`] = `
+Object {
+  "deps": Array [],
+}
+`;
diff --git a/test/manager/mix/_fixtures/mix.exs b/test/manager/mix/_fixtures/mix.exs
new file mode 100644
index 0000000000..1d3194f3c8
--- /dev/null
+++ b/test/manager/mix/_fixtures/mix.exs
@@ -0,0 +1,29 @@
+defmodule MyProject.MixProject do
+  use Mix.Project
+
+  def project() do
+    [
+      app: :my_project,
+      version: "0.0.1",
+      elixir: "~> 1.0",
+      deps: deps(),
+    ]
+  end
+
+  def application() do
+    []
+  end
+
+  defp deps() do
+    [
+      {:postgrex, "~> 0.8.1"},
+      {:ecto, ">2.1.0 or <=3.0.0"},
+      {:cowboy, github: "ninenines/cowboy"},
+      {:secret, "~> 1.0", organization: "acme"},
+      {:ex_doc, ">2.1.0 and <=3.0.0"},
+      {:jason, ">= 1.0.0"},
+      {:jason, "~> 1.0", 
+        optional: true},
+    ]
+  end
+end
\ No newline at end of file
diff --git a/test/manager/mix/artifacts.spec.ts b/test/manager/mix/artifacts.spec.ts
new file mode 100644
index 0000000000..e62f1b4ce1
--- /dev/null
+++ b/test/manager/mix/artifacts.spec.ts
@@ -0,0 +1,71 @@
+import _fs from 'fs-extra';
+import { exec as _exec } from '../../../lib/util/exec';
+import { platform as _platform } from '../../../lib/platform';
+import { updateArtifacts } from '../../../lib/manager/mix';
+
+const fs: any = _fs;
+const exec: any = _exec;
+const platform: any = _platform;
+
+jest.mock('fs-extra');
+jest.mock('../../../lib/util/exec');
+jest.mock('../../../lib/platform');
+
+const config = {
+  localDir: '/tmp/github/some/repo',
+};
+
+describe('.updateArtifacts()', () => {
+  beforeEach(() => {
+    jest.resetAllMocks();
+  });
+  it('returns null if no mix.lock found', async () => {
+    expect(await updateArtifacts('mix.exs', ['plug'], '', config)).toBeNull();
+  });
+  it('returns null if no updatedDeps were provided', async () => {
+    expect(await updateArtifacts('mix.exs', [], '', config)).toBeNull();
+  });
+  it('returns null if no local directory found', async () => {
+    const noLocalDirConfig = {
+      localDir: null,
+    };
+    expect(
+      await updateArtifacts('mix.exs', ['plug'], '', noLocalDirConfig)
+    ).toBeNull();
+  });
+  it('returns null if updatedDeps is empty', async () => {
+    expect(await updateArtifacts('mix.exs', ['plug'], '', config)).toBeNull();
+  });
+  it('returns null if unchanged', async () => {
+    platform.getFile.mockReturnValueOnce('Current mix.lock');
+    exec.mockReturnValue({
+      stdout: '',
+      stderr: '',
+    });
+    fs.readFile.mockReturnValueOnce('Current mix.lock');
+    expect(await updateArtifacts('mix.exs', ['plug'], '', config)).toBeNull();
+  });
+  it('returns updated mix.lock', async () => {
+    platform.getFile.mockReturnValueOnce('Old mix.lock');
+    exec.mockReturnValue({
+      stdout: '',
+      stderr: '',
+    });
+    fs.readFile.mockImplementationOnce(() => 'New mix.lock');
+    expect(
+      await updateArtifacts('mix.exs', ['plug'], '{}', {
+        ...config,
+        binarySource: 'docker',
+      })
+    ).toMatchSnapshot();
+  });
+  it('catches errors', async () => {
+    platform.getFile.mockReturnValueOnce('Current mix.lock');
+    fs.outputFile.mockImplementationOnce(() => {
+      throw new Error('not found');
+    });
+    expect(
+      await updateArtifacts('mix.exs', ['plug'], '{}', config)
+    ).toMatchSnapshot();
+  });
+});
diff --git a/test/manager/mix/extract.spec.ts b/test/manager/mix/extract.spec.ts
new file mode 100644
index 0000000000..ea4ccd93e0
--- /dev/null
+++ b/test/manager/mix/extract.spec.ts
@@ -0,0 +1,20 @@
+import fs from 'fs-extra';
+import path from 'path';
+import { extractPackageFile } from '../../../lib/manager/mix';
+
+const sample = fs.readFileSync(
+  path.resolve(__dirname, './_fixtures/mix.exs'),
+  'utf-8'
+);
+
+describe('lib/manager/mix/extract', () => {
+  describe('extractPackageFile()', () => {
+    it('returns empty for invalid dependency file', () => {
+      expect(extractPackageFile('nothing here')).toMatchSnapshot();
+    });
+    it('extracts all dependencies', () => {
+      const res = extractPackageFile(sample).deps;
+      expect(res).toMatchSnapshot();
+    });
+  });
+});
diff --git a/test/manager/mix/update.spec.ts b/test/manager/mix/update.spec.ts
new file mode 100644
index 0000000000..3c7bdd48d8
--- /dev/null
+++ b/test/manager/mix/update.spec.ts
@@ -0,0 +1,50 @@
+import fs from 'fs-extra';
+import path from 'path';
+import { updateDependency } from '../../../lib/manager/mix';
+
+const sample = fs.readFileSync(
+  path.resolve(__dirname, './_fixtures/mix.exs'),
+  'utf-8'
+);
+
+describe('lib/manager/mix/update', () => {
+  describe('updateDependency', () => {
+    it('replaces existing value', () => {
+      const upgrade = {
+        depName: 'postgrex',
+        managerData: { lineNumber: 18 },
+        newValue: '~> 0.8.2',
+      };
+      const res = updateDependency(sample, upgrade);
+      expect(res).not.toEqual(sample);
+      expect(res.includes(upgrade.newValue)).toBe(true);
+    });
+    it('return the same', () => {
+      const upgrade = {
+        depName: 'postgrex',
+        managerData: { lineNumber: 18 },
+        newValue: '~> 0.8.1',
+      };
+      const res = updateDependency(sample, upgrade);
+      expect(res).toEqual(sample);
+    });
+    it('returns null if wrong line', () => {
+      const upgrade = {
+        depName: 'postgrex',
+        managerData: { lineNumber: 19 },
+        newValue: '~> 0.8.2',
+      };
+      const res = updateDependency(sample, upgrade);
+      expect(res).toBeNull();
+    });
+    it('returns null for unsupported depType', () => {
+      const upgrade = {
+        depName: 'cowboy',
+        managerData: { lineNumber: 19 },
+        newValue: '~> 0.8.2',
+      };
+      const res = updateDependency(sample, upgrade);
+      expect(res).toBeNull();
+    });
+  });
+});
diff --git a/test/versioning/hex.spec.ts b/test/versioning/hex.spec.ts
index c86d93f4e3..6a218de89f 100644
--- a/test/versioning/hex.spec.ts
+++ b/test/versioning/hex.spec.ts
@@ -1,115 +1,120 @@
-import { api as semver } from '../../lib/versioning/hex';
+import { api as hexScheme } from '../../lib/versioning/hex';
 
 describe('lib/versioning/hex', () => {
-  describe('semver.matches()', () => {
+  describe('hexScheme.matches()', () => {
     it('handles tilde greater than', () => {
-      expect(semver.matches('4.2.0', '~> 4.0')).toBe(true);
-      expect(semver.matches('2.1.0', '~> 2.0.0')).toBe(false);
-      expect(semver.matches('2.0.0', '>= 2.0.0 and < 2.1.0')).toBe(true);
-      expect(semver.matches('2.1.0', '== 2.0.0 or < 2.1.0')).toBe(false);
+      expect(hexScheme.matches('4.2.0', '~> 4.0')).toBe(true);
+      expect(hexScheme.matches('2.1.0', '~> 2.0.0')).toBe(false);
+      expect(hexScheme.matches('2.0.0', '>= 2.0.0 and < 2.1.0')).toBe(true);
+      expect(hexScheme.matches('2.1.0', '== 2.0.0 or < 2.1.0')).toBe(false);
     });
   });
   it('handles tilde greater than', () => {
     expect(
-      semver.maxSatisfyingVersion(
+      hexScheme.maxSatisfyingVersion(
         ['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0'],
         '~> 4.0'
       )
     ).toBe('4.2.0');
     expect(
-      semver.maxSatisfyingVersion(
+      hexScheme.maxSatisfyingVersion(
         ['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0'],
         '~> 4.0.0'
       )
     ).toBe('4.0.0');
   });
-  describe('semver.isValid()', () => {
+  describe('hexScheme.isValid()', () => {
     it('handles and', () => {
-      expect(semver.isValid('>= 1.0.0 and <= 2.0.0')).toBeTruthy();
+      expect(hexScheme.isValid('>= 1.0.0 and <= 2.0.0')).toBeTruthy();
     });
     it('handles or', () => {
-      expect(semver.isValid('>= 1.0.0 or <= 2.0.0')).toBeTruthy();
+      expect(hexScheme.isValid('>= 1.0.0 or <= 2.0.0')).toBeTruthy();
     });
     it('handles !=', () => {
-      expect(semver.isValid('!= 1.0.0')).toBeTruthy();
+      expect(hexScheme.isValid('!= 1.0.0')).toBeTruthy();
     });
   });
-  describe('semver.isLessThanRange()', () => {
+  describe('hexScheme.isLessThanRange()', () => {
     it('handles and', () => {
-      expect(semver.isLessThanRange('0.1.0', '>= 1.0.0 and <= 2.0.0')).toBe(
+      expect(hexScheme.isLessThanRange('0.1.0', '>= 1.0.0 and <= 2.0.0')).toBe(
         true
       );
-      expect(semver.isLessThanRange('1.9.0', '>= 1.0.0 and <= 2.0.0')).toBe(
+      expect(hexScheme.isLessThanRange('1.9.0', '>= 1.0.0 and <= 2.0.0')).toBe(
         false
       );
     });
     it('handles or', () => {
-      expect(semver.isLessThanRange('0.9.0', '>= 1.0.0 or >= 2.0.0')).toBe(
+      expect(hexScheme.isLessThanRange('0.9.0', '>= 1.0.0 or >= 2.0.0')).toBe(
         true
       );
-      expect(semver.isLessThanRange('1.9.0', '>= 1.0.0 or >= 2.0.0')).toBe(
+      expect(hexScheme.isLessThanRange('1.9.0', '>= 1.0.0 or >= 2.0.0')).toBe(
         false
       );
     });
   });
-  describe('semver.minSatisfyingVersion()', () => {
+  describe('hexScheme.minSatisfyingVersion()', () => {
     it('handles tilde greater than', () => {
       expect(
-        semver.minSatisfyingVersion(
+        hexScheme.minSatisfyingVersion(
           ['0.4.0', '0.5.0', '4.2.0', '5.0.0'],
           '~> 4.0'
         )
       ).toBe('4.2.0');
       expect(
-        semver.minSatisfyingVersion(
+        hexScheme.minSatisfyingVersion(
           ['0.4.0', '0.5.0', '4.2.0', '5.0.0'],
           '~> 4.0.0'
         )
       ).toBeNull();
     });
   });
-  describe('semver.getNewValue()', () => {
+  describe('hexScheme.getNewValue()', () => {
     it('handles tilde greater than', () => {
-      expect(semver.getNewValue('~> 1.2', 'replace', '1.2.3', '2.0.7')).toEqual(
-        '~> 2.0'
-      );
-      expect(semver.getNewValue('~> 1.2', 'pin', '1.2.3', '2.0.7')).toEqual(
+      expect(
+        hexScheme.getNewValue('~> 1.2', 'replace', '1.2.3', '2.0.7')
+      ).toEqual('~> 2.0');
+      expect(hexScheme.getNewValue('~> 1.2', 'pin', '1.2.3', '2.0.7')).toEqual(
         '2.0.7'
       );
-      expect(semver.getNewValue('~> 1.2', 'bump', '1.2.3', '2.0.7')).toEqual(
+      expect(hexScheme.getNewValue('~> 1.2', 'bump', '1.2.3', '2.0.7')).toEqual(
         '~> 2'
       );
       expect(
-        semver.getNewValue('~> 1.2.0', 'replace', '1.2.3', '2.0.7')
+        hexScheme.getNewValue('~> 1.2.0', 'replace', '1.2.3', '2.0.7')
       ).toEqual('~> 2.0.0');
-      expect(semver.getNewValue('~> 1.2.0', 'pin', '1.2.3', '2.0.7')).toEqual(
-        '2.0.7'
-      );
-      expect(semver.getNewValue('~> 1.2.0', 'bump', '1.2.3', '2.0.7')).toEqual(
-        '~> 2.0.7'
-      );
+      expect(
+        hexScheme.getNewValue('~> 1.2.0', 'pin', '1.2.3', '2.0.7')
+      ).toEqual('2.0.7');
+      expect(
+        hexScheme.getNewValue('~> 1.2.0', 'bump', '1.2.3', '2.0.7')
+      ).toEqual('~> 2.0.7');
     });
   });
   it('handles and', () => {
     expect(
-      semver.getNewValue('>= 1.0.0 and <= 2.0.0', 'widen', '1.2.3', '2.0.7')
+      hexScheme.getNewValue('>= 1.0.0 and <= 2.0.0', 'widen', '1.2.3', '2.0.7')
     ).toEqual('>= 1.0.0 and <= 2.0.7');
     expect(
-      semver.getNewValue('>= 1.0.0 and <= 2.0.0', 'replace', '1.2.3', '2.0.7')
+      hexScheme.getNewValue(
+        '>= 1.0.0 and <= 2.0.0',
+        'replace',
+        '1.2.3',
+        '2.0.7'
+      )
     ).toEqual('<= 2.0.7');
     expect(
-      semver.getNewValue('>= 1.0.0 and <= 2.0.0', 'pin', '1.2.3', '2.0.7')
+      hexScheme.getNewValue('>= 1.0.0 and <= 2.0.0', 'pin', '1.2.3', '2.0.7')
     ).toEqual('2.0.7');
   });
   it('handles or', () => {
     expect(
-      semver.getNewValue('>= 1.0.0 or <= 2.0.0', 'widen', '1.2.3', '2.0.7')
+      hexScheme.getNewValue('>= 1.0.0 or <= 2.0.0', 'widen', '1.2.3', '2.0.7')
     ).toEqual('>= 1.0.0 or <= 2.0.7');
     expect(
-      semver.getNewValue('>= 1.0.0 or <= 2.0.0', 'replace', '1.2.3', '2.0.7')
+      hexScheme.getNewValue('>= 1.0.0 or <= 2.0.0', 'replace', '1.2.3', '2.0.7')
     ).toEqual('<= 2.0.7');
     expect(
-      semver.getNewValue('>= 1.0.0 or <= 2.0.0', 'pin', '1.2.3', '2.0.7')
+      hexScheme.getNewValue('>= 1.0.0 or <= 2.0.0', 'pin', '1.2.3', '2.0.7')
     ).toEqual('2.0.7');
   });
 });
diff --git a/test/workers/repository/extract/__snapshots__/index.spec.js.snap b/test/workers/repository/extract/__snapshots__/index.spec.js.snap
index e5b3d0b453..ece6795070 100644
--- a/test/workers/repository/extract/__snapshots__/index.spec.js.snap
+++ b/test/workers/repository/extract/__snapshots__/index.spec.js.snap
@@ -68,6 +68,9 @@ Object {
   "meteor": Array [
     Object {},
   ],
+  "mix": Array [
+    Object {},
+  ],
   "npm": Array [
     Object {},
   ],
-- 
GitLab