Repository: opsdroid/opsdroid

Issue:
Add GitHub Gist support
We should add support for [GitHub Gists](https://gist.github.com/) as a remote skill source. This would make sense for single file skills and would be particularly useful when coupled with Jupyter Notebooks (#571). There is a plugin for Jupyter which publishes a notebook as a Gist, so you could then reference it in your opsdroid config to load it in.


Discussion:

Resolving patch:
diff --git a/opsdroid/helper.py b/opsdroid/helper.py
--- a/opsdroid/helper.py
+++ b/opsdroid/helper.py
@@ -111,3 +111,21 @@ def convert_ipynb_to_script(notebook_path, output_path):
         script, _ = PythonExporter().from_notebook_node(notebook)
         with open(output_path, 'w') as output_path_handle:
             output_path_handle.write(script)
+
+
+def extract_gist_id(gist_string):
+    """Extract the gist ID from a url.
+
+    Will also work if simply passed an ID.
+
+    Args:
+        gist_string (str): Gist URL.
+
+    Returns:
+        string: The gist ID.
+
+    Examples:
+        gist_string : Gist url 'https://gist.github.com/{user}/{id}'.
+
+    """
+    return gist_string.split("/")[-1]
diff --git a/opsdroid/loader.py b/opsdroid/loader.py
--- a/opsdroid/loader.py
+++ b/opsdroid/loader.py
@@ -1,18 +1,23 @@
 """Class for loading in modules to OpsDroid."""
 
+import importlib
+import importlib.util
+import json
 import logging
 import os
-import sys
+import re
 import shutil
 import subprocess
-import importlib
-import importlib.util
-import re
+import sys
+import tempfile
+import urllib.request
 from collections import Mapping
+
 import yaml
+
 from opsdroid.helper import (
     move_config_to_appdir, file_is_ipython_notebook,
-    convert_ipynb_to_script)
+    convert_ipynb_to_script, extract_gist_id)
 from opsdroid.const import (
     DEFAULT_GIT_URL, MODULES_DIRECTORY, DEFAULT_MODULES_PATH,
     DEFAULT_MODULE_BRANCH, DEFAULT_CONFIG_PATH, EXAMPLE_CONFIG_FILE,
@@ -343,6 +348,8 @@ def _install_module(self, config):
 
         if self._is_local_module(config):
             self._install_local_module(config)
+        elif self._is_gist_module(config):
+            self._install_gist_module(config)
         else:
             self._install_git_module(config)
 
@@ -374,6 +381,10 @@ def _is_module_installed(config):
     def _is_local_module(config):
         return "path" in config
 
+    @staticmethod
+    def _is_gist_module(config):
+        return "gist" in config
+
     def _install_module_dependencies(self, config):
         if config.get('no-dep', False):
             _LOGGER.debug(_("'no-dep' set in configuration, skipping the "
@@ -440,3 +451,33 @@ def _install_local_module(config):
         if not installed:
             _LOGGER.error("Failed to install from %s",
                           str(config["path"]))
+
+    def _install_gist_module(self, config):
+        gist_id = extract_gist_id(config['gist'])
+
+        # Get the content of the gist
+        req = urllib.request.Request(
+            "https://api.github.com/gists/{}".format(gist_id))
+        cont = json.loads(urllib.request.urlopen(req).read().decode('utf-8'))
+        python_files = [cont["files"][file] for file in cont["files"]
+                        if '.ipynb' in cont["files"][file]["filename"]
+                        or '.py' in cont["files"][file]["filename"]]
+
+        # We only support one skill file in a gist for now.
+        #
+        # TODO: Add support for mutliple files. Could be particularly
+        # useful for including a requirements.txt file.
+        skill_content = python_files[0]["content"]
+        extension = os.path.splitext(python_files[0]["filename"])[1]
+
+        with tempfile.NamedTemporaryFile('w',
+                                         delete=False,
+                                         suffix=extension) as skill_file:
+            skill_file.write(skill_content)
+            skill_file.flush()
+
+            # Set the path in the config
+            config["path"] = skill_file.name
+
+            # Run local install
+            self._install_local_module(config)


Test patch:
diff --git a/tests/test_helper.py b/tests/test_helper.py
--- a/tests/test_helper.py
+++ b/tests/test_helper.py
@@ -6,7 +6,7 @@
 
 from opsdroid.helper import (
     del_rw, move_config_to_appdir, file_is_ipython_notebook,
-    convert_ipynb_to_script)
+    convert_ipynb_to_script, extract_gist_id)
 
 
 class TestHelper(unittest.TestCase):
@@ -47,3 +47,14 @@ def test_convert_ipynb_to_script(self):
                 mode='w', delete=False) as output_file:
             convert_ipynb_to_script(notebook_path, output_file.name)
             self.assertTrue(os.path.getsize(output_file.name) > 0)
+
+    def test_extract_gist_id(self):
+        self.assertEqual(
+            extract_gist_id(
+                "https://gist.github.com/jacobtomlinson/"
+                "c9852fa17d3463acc14dca1217d911f6"),
+            "c9852fa17d3463acc14dca1217d911f6")
+
+        self.assertEqual(
+            extract_gist_id("c9852fa17d3463acc14dca1217d911f6"),
+            "c9852fa17d3463acc14dca1217d911f6")
diff --git a/tests/test_loader.py b/tests/test_loader.py
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -403,6 +403,19 @@ def test_install_specific_local_git_module(self):
                                          config["branch"])
         shutil.rmtree(repo_path, onerror=del_rw)
 
+    def test_install_gist_module(self):
+        opsdroid, loader = self.setup()
+        config = {"name": "ping",
+                  "type": "skill",
+                  "install_path": os.path.join(
+                      self._tmp_dir, "test_gist_module_file"),
+                  "gist": "https://gist.github.com/jacobtomlinson/"
+                          "c9852fa17d3463acc14dca1217d911f6"}
+
+        with mock.patch.object(loader, '_install_gist_module') as mockgist:
+            loader._install_module(config)
+            self.assertTrue(mockgist.called)
+
     def test_install_specific_local_path_module(self):
         opsdroid, loader = self.setup()
         repo_path = os.path.join(self._tmp_dir, "testrepo")
@@ -525,3 +538,29 @@ def test_update_existing_git_module(self):
             mockpull.assert_called_with(config["install_path"])
 
         shutil.rmtree(config["install_path"], onerror=del_rw)
+
+    def test_install_gist_module_file(self):
+        opsdroid, loader = self.setup()
+        config = {"name": "ping",
+                  "type": "skill",
+                  "install_path": os.path.join(
+                      self._tmp_dir, "test_gist_module_file"),
+                  "gist": "https://gist.github.com/jacobtomlinson/"
+                          "6dd35e0f62d6b779d3d0d140f338d3e5"}
+        loader._install_gist_module(config)
+        self.assertTrue(os.path.isfile(os.path.join(
+            config["install_path"], "__init__.py")))
+        shutil.rmtree(config["install_path"], onerror=del_rw)
+
+    def test_install_gist_module_notebook(self):
+        opsdroid, loader = self.setup()
+        config = {"name": "ping",
+                  "type": "skill",
+                  "install_path": os.path.join(
+                      self._tmp_dir, "test_gist_module_file"),
+                  "gist": "https://gist.github.com/jacobtomlinson/"
+                          "c9852fa17d3463acc14dca1217d911f6"}
+        loader._install_gist_module(config)
+        self.assertTrue(os.path.isfile(os.path.join(
+            config["install_path"], "__init__.py")))
+        shutil.rmtree(config["install_path"], onerror=del_rw)