{"action":"created","comment":{"url":"https://api.github.com/repos/ansible/ansible/pulls/comments/173680319","pull_request_review_id":102907280,"id":173680319,"diff_hunk":"@@ -15,413 +16,975 @@\n DOCUMENTATION = '''\n ---\n module: osx_defaults\n-author: Franck Nijhof (@frenck)\n-short_description: osx_defaults allows users to read, write, and delete Mac OS X user defaults from Ansible\n+author: \"Franck Nijhof (@frenck), Etienne Desautels (@etienned)\"\n+short_description:\n+    Allows users to read, write, and delete Mac OS X preferences from Ansible.\n description:\n-  - osx_defaults allows users to read, write, and delete Mac OS X user defaults from Ansible scripts.\n-    Mac OS X applications and other programs use the defaults system to record user preferences and other\n-    information that must be maintained when the applications aren't running (such as default font for new\n-    documents, or the position of an Info panel).\n+  - osx_defaults allows users to read, write, and delete Mac OS X preferences\n+    from Ansible scripts.\n+    Mac OS X applications and other programs use the defaults system to record\n+    user preferences and other information that must be maintained when the\n+    applications aren't running (such as default font for new documents, or\n+    the position of an Info panel).\n version_added: \"2.0\"\n+requirements:\n+  - Python 2.6+.\n+  - OS X installed Foundation and CoreFoundation python modules (or from PyObjC).\n options:\n   domain:\n     description:\n-      - The domain is a domain name of the form com.companyname.appname.\n-    required: false\n+      - The I(domain) is a domain name of the form com.companyname.appname or\n+        I(NSGlobalDomain) for the Global Domain.\n     default: NSGlobalDomain\n   host:\n     description:\n       - The host on which the preference should apply. The special value \"currentHost\" corresponds to the\n         \"-currentHost\" switch of the defaults commandline tool.\n-    required: false\n     default: null\n     version_added: \"2.1\"\n+  any_user:\n+    description:\n+      - Set the preference for any user or only the current user.\n+    default: false\n+    version_added: \"2.5\"\n   key:\n     description:\n-      - The key of the user preference\n+      - The key of the preference. Nested values can be accessed by giving all\n+        keys and indexes separated by colons (:). Indexes are zero-based\n+        (see Notes for special cases).\n     required: true\n   type:\n     description:\n-      - The type of value to write.\n-    required: false\n-    default: string\n-    choices: [ \"array\", \"bool\", \"boolean\", \"date\", \"float\", \"int\", \"integer\", \"string\" ]\n+      - The type of the value to write. If unspecified, I(type) will be deduce mostly\n+        the same way YAML cast type (see Notes for special cases).\n+    default: null\n+    choices: [ \"array\", \"bool\", \"boolean\", \"data\", \"date\", \"dict\", \"float\", \"real\", \"int\", \"integer\", \"string\" ]\n   array_add:\n     description:\n       - Add new elements to the array for a key which has an array as its value.\n-    required: false\n     default: false\n     choices: [ \"true\", \"false\" ]\n   value:\n     description:\n-      - The value to write. Only required when state = present.\n-    required: false\n+      - The value to write. Only required when I(state = present).\n     default: null\n   state:\n     description:\n-      - The state of the user defaults\n-    required: false\n+      - The state of the preference.\n     default: present\n     choices: [ \"present\", \"absent\" ]\n notes:\n-    - Apple Mac caches defaults. You may need to logout and login to apply the changes.\n+    - OS X caches preferences aggressively. This module should take care of\n+      updating caches but in some cases you may need to logout and login to\n+      apply the changes.\n+    - Check mode can be use with this module.\n+    - Nested keys need to be quoted when use in abbreviated form\n+      (because they contains colons and that mess with the syntax).\n+    - All dates need to be quoted.\n+    - First level (not nested) quoted boolean, integers and floats are\n+      converted to boolean, integer and float unless type is specified.\n+    - List and dict can't be given as value in key=argument form.\n+    - Binary data need to be encoded in base64.\n '''\n \n EXAMPLES = '''\n+# Show debug menu in Safari. Specifying the type.\n - osx_defaults:\n     domain: com.apple.Safari\n     key: IncludeInternalDebugMenu\n     type: bool\n     value: true\n     state: present\n-\n+# Set Measurement unit to cm for all apps (NSGlobalDomain).\n - osx_defaults:\n     domain: NSGlobalDomain\n     key: AppleMeasurementUnits\n     type: string\n     value: Centimeters\n     state: present\n-\n+# Set clock visibility on screensaver for all users on current host.\n - osx_defaults:\n     domain: com.apple.screensaver\n     host: currentHost\n+    any_user: true\n     key: showClock\n     type: int\n     value: 1\n-\n+# Set Measurement unit to cm for all apps (NSGlobalDomain). No type specified.\n - osx_defaults:\n     key: AppleMeasurementUnits\n     type: string\n     value: Centimeters\n-\n+# Set AppleLanguages to a list of languages.\n - osx_defaults:\n     key: AppleLanguages\n-    type: array\n-    value:\n-      - en\n-      - nl\n-\n+    value: [\"en\", \"nl\"]\n+# Remove a key.\n - osx_defaults:\n     domain: com.geekchimp.macable\n     key: ExampleKeyToRemove\n     state: absent\n+# Set a date. Dates need to be quoted. Timezone can be omitted. Local timezone will be use in this case.\n+- osx_defaults:\n+    domain: com.geekchimp.macable\n+    key: SomeDate\n+    value: \"2002-12-15 02:59:43Z\"\n+# Set the nested key arrangeBy in abbreviated form (nested key need to be quoted).\n+- osx_defaults: { domain: com.apple.finder, key: 'DesktopViewSettings:IconViewSettings:arrangeBy', value: dateModified }\n+# Set ListViewSettings key to complex nested values.\n+- osx_defaults:\n+    domain: com.apple.finder\n+    key: ComputerViewSettings:ListViewSettings\n+    value:\n+        - iconSize: 16\n+        - sortColumn: name\n+        - textSize: 12\n+        - columns:\n+            - comments:\n+                - ascending: true\n '''\n \n+\n+from base64 import b64decode\n+import calendar\n+import contextlib\n import datetime\n+import os\n import re\n+import string\n \n from ansible.module_utils.basic import AnsibleModule\n-from ansible.module_utils.six import binary_type, text_type\n-\n-\n-# exceptions --------------------------------------------------------------- {{{\n-class OSXDefaultsException(Exception):\n-    pass\n-\n-\n-# /exceptions -------------------------------------------------------------- }}}\n-\n-# class MacDefaults -------------------------------------------------------- {{{\n-class OSXDefaults(object):\n-    \"\"\" Class to manage Mac OS user defaults \"\"\"\n+from ansible.module_utils.six import string_types, binary_type, text_type\n+from ansible.module_utils.six import integer_types\n+\n+try:\n+    import CoreFoundation\n+    import Foundation\n+    has_foundation = True\n+except ImportError:\n+    has_foundation = False\n+\n+\n+# Regular expression that match datetime formats. Should match mostly the\n+# same formats that YAML support.\n+RE_DATETIME = re.compile(r\"\"\"\n+    # year-month-day\n+    (?P<year>\\d{4})-(?P<month>[0-1]?[0-9])-(?P<day>[0-3]?[0-9])\n+    # start of optional time section\n+    (?:\n+        # time separator\n+        (?:\\ +|[Tt])\n+        # hour-minute-second\n+        (?P<hour>[0-2]?[0-9]):(?P<minute>[0-5][0-9]):(?P<second>[0-5][0-9])\n+        # optional microsecond\n+        (?:\\.(?P<microsecond>\\d*))?\n+        # optional timezone info\n+        (?P<timezone>\\ *Z|\\ *[-+][0-2]?[0-9](?::?[0-5][0-9])?)?\n+    )?$\n+\"\"\", re.VERBOSE)\n+\n+\n+def string_to_datetime(value):\n+    \"\"\"\n+    Convert a date and time string to a datetime object.\n+    String need to be similar to the Combined date and time ISO 8601 form\n+    (YYYY-MM-DDThh:mm:ss+00:00).\n+    Local timezone offset will be added to dates without timezone.\n+    \"\"\"\n+    if not isinstance(value, string_types):\n+        raise ValueError('Value need to be a string.')\n+\n+    match = RE_DATETIME.match(value)\n+    if match:\n+        timezone = match.group('timezone')\n+        offset = None\n+\n+        if timezone:\n+            timezone = timezone.lstrip()\n+            if timezone == 'Z':\n+                offset = 0\n+            else:\n+                if ':' in timezone:\n+                    hour, minute = timezone.split(':')\n+                elif len(timezone) > 3:\n+                    hour, minute = timezone[0:3], timezone[3:5]\n+                else:\n+                    hour, minute = timezone, 0\n+                sign, hour = hour[0], hour[1:]\n+                offset = ((int(hour) * 60) + int(minute)) * 60\n+                if sign == '-':\n+                    offset = -offset\n+\n+        local_datetime = datetime.datetime(\n+            int(match.group('year')),\n+            int(match.group('month')),\n+            int(match.group('day')),\n+            int(match.group('hour')) if match.group('hour') else 0,\n+            int(match.group('minute')) if match.group('minute') else 0,\n+            int(match.group('second')) if match.group('second') else 0,\n+            # Skip microsecond because they are unsupported in CF.\n+            0,\n+        )\n \n-    # init ---------------------------------------------------------------- {{{\n-    \"\"\" Initialize this module. Finds 'defaults' executable and preps the parameters \"\"\"\n+        class Offset(datetime.tzinfo):\n+            def __init__(self, local_datetime, offset=None):\n+                if offset is None:\n+                    # Get local timezone offset for the specified date.\n+                    timestamp = calendar.timegm(local_datetime.timetuple())\n+                    local_datetime = datetime.datetime.fromtimestamp(timestamp)\n+                    utc_datetime = datetime.datetime.utcfromtimestamp(timestamp)\n+                    self.__offset = local_datetime - utc_datetime\n+                else:\n+                    self.__offset = datetime.timedelta(0, offset)\n \n-    def __init__(self, **kwargs):\n+            def utcoffset(self, dt=None):\n+                return self.__offset\n \n-        # Initial var for storing current defaults value\n-        self.current_value = None\n+        timezone = Offset(local_datetime, offset)\n \n-        # Just set all given parameters\n-        for key, val in kwargs.items():\n-            setattr(self, key, val)\n+        return local_datetime.replace(tzinfo=timezone)\n \n-        # Try to find the defaults executable\n-        self.executable = self.module.get_bin_path(\n-            'defaults',\n-            required=False,\n-            opt_dirs=self.path.split(':'),\n-        )\n+    raise ValueError(\n+        'Invalid string format for datetime: `{0}`'.format(value)\n+    )\n \n-        if not self.executable:\n-            raise OSXDefaultsException(\"Unable to locate defaults executable.\")\n \n-        # When state is present, we require a parameter\n-        if self.state == \"present\" and self.value is None:\n-            raise OSXDefaultsException(\"Missing value parameter\")\n+def equivalent_types(value1, value2):\n+    \"\"\"\n+    Compare type of two values and return if they are equivalent or not.\n+\n+    Objective-C types are considered equivalent to their corresponding\n+    python types.\n+    For example, `objc.pyobjc_unicode` is equivalent to `unicode`.\n+    \"\"\"\n+    supported_types = (\n+        bool,\n+        integer_types,\n+        float,\n+        (text_type, binary_type),\n+        datetime.datetime,\n+        Data,\n+        list,\n+        dict,\n+    )\n+    for value_type in supported_types:\n+        if isinstance(value1, value_type) and isinstance(value2, value_type):\n+            return True\n+    return False\n \n-        # Ensure the value is the correct type\n-        self.value = self._convert_type(self.type, self.value)\n \n-    # /init --------------------------------------------------------------- }}}\n+class Data(binary_type):\n+    \"\"\"\n+    Object representing binary data.\n+    Instance should be initialize with binary data encoded in base64 codec.\n+    \"\"\"\n+    # List of all Base64 accepted characters.\n+    BASE64_CHARS = string.ascii_letters + string.digits + '+/='\n \n-    # tools --------------------------------------------------------------- {{{\n-    \"\"\" Converts value to given type \"\"\"\n+    # List of all text characters.\n+    TEXT_CHARS = bytearray(set([7, 8, 9, 10, 12, 13, 27]) | set(range(0x20, 0x100)) - set([0x7f]))\n \n-    def _convert_type(self, type, value):\n+    def __new__(cls, data):\n+        if isinstance(data, cls):\n+            return data\n \n-        if type == \"string\":\n-            return str(value)\n-        elif type in [\"bool\", \"boolean\"]:\n-            if isinstance(value, (binary_type, text_type)):\n-                value = value.lower()\n-            if value in [True, 1, \"true\", \"1\", \"yes\"]:\n-                return True\n-            elif value in [False, 0, \"false\", \"0\", \"no\"]:\n-                return False\n-            raise OSXDefaultsException(\"Invalid boolean value: {0}\".format(repr(value)))\n-        elif type == \"date\":\n+        # Try to convert unicode to ascii string.\n+        if isinstance(data, text_type):\n             try:\n-                return datetime.datetime.strptime(value.split(\"+\")[0].strip(), \"%Y-%m-%d %H:%M:%S\")\n-            except ValueError:\n-                raise OSXDefaultsException(\n-                    \"Invalid date value: {0}. Required format yyy-mm-dd hh:mm:ss.\".format(repr(value))\n-                )\n-        elif type in [\"int\", \"integer\"]:\n-            if not str(value).isdigit():\n-                raise OSXDefaultsException(\"Invalid integer value: {0}\".format(repr(value)))\n-            return int(value)\n-        elif type == \"float\":\n-            try:\n-                value = float(value)\n-            except ValueError:\n-                raise OSXDefaultsException(\"Invalid float value: {0}\".format(repr(value)))\n-            return value\n-        elif type == \"array\":\n-            if not isinstance(value, list):\n-                raise OSXDefaultsException(\"Invalid value. Expected value to be an array\")\n-            return value\n+                data = data.encode('ascii')\n+            except UnicodeEncodeError:\n+                pass\n \n-        raise OSXDefaultsException('Type is not supported: {0}'.format(type))\n+        # Check if data is a valid base64 string. Short strings are not\n+        # considered as binary.\n+        if isinstance(data, binary_type) and len(data) > 51 and not data.translate(None, cls.BASE64_CHARS):\n+            try:\n+                binary_data = b64decode(data)\n+            except TypeError:\n+                pass\n+            else:\n+                if cls.is_binary(binary_data):\n+                    return super(Data, cls).__new__(cls, data)\n \n-    \"\"\" Returns a normalized list of commandline arguments based on the \"host\" attribute \"\"\"\n+        raise ValueError('Unsupported data type.')\n \n-    def _host_args(self):\n-        if self.host is None:\n-            return []\n-        elif self.host == 'currentHost':\n-            return ['-currentHost']\n+    @classmethod\n+    def is_binary(cls, data):","path":"lib/ansible/modules/system/osx_defaults.py","position":409,"original_position":409,"commit_id":"10877132d81e1a3a5d8a0f882510367b246b56f3","original_commit_id":"10877132d81e1a3a5d8a0f882510367b246b56f3","user":{"login":"etienned","id":251190,"avatar_url":"https://avatars3.githubusercontent.com/u/251190?v=4","gravatar_id":"","url":"https://api.github.com/users/etienned","html_url":"https://github.com/etienned","followers_url":"https://api.github.com/users/etienned/followers","following_url":"https://api.github.com/users/etienned/following{/other_user}","gists_url":"https://api.github.com/users/etienned/gists{/gist_id}","starred_url":"https://api.github.com/users/etienned/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/etienned/subscriptions","organizations_url":"https://api.github.com/users/etienned/orgs","repos_url":"https://api.github.com/users/etienned/repos","events_url":"https://api.github.com/users/etienned/events{/privacy}","received_events_url":"https://api.github.com/users/etienned/received_events","type":"User","site_admin":false},"body":"This part is completely inspired by this:\r\nhttp://code.activestate.com/recipes/173220-test-if-a-file-or-string-is-text-or-binary/\r\n\r\nThe 512 chars are just an optimization because there's very little chance that binary data will not have a good proportion of non-text characters in the first 512 characters.\r\n\r\nFor the 30%, I just follow the reference. I can't justify this number.\r\n\r\nHere also, if someone have a better/simpler solution I'm all open. I did a lot of researches on this and it's the best solution I found.","created_at":"2018-03-12T01:40:18Z","updated_at":"2018-03-12T01:40:18Z","html_url":"https://github.com/ansible/ansible/pull/24808#discussion_r173680319","pull_request_url":"https://api.github.com/repos/ansible/ansible/pulls/24808","author_association":"NONE","_links":{"self":{"href":"https://api.github.com/repos/ansible/ansible/pulls/comments/173680319"},"html":{"href":"https://github.com/ansible/ansible/pull/24808#discussion_r173680319"},"pull_request":{"href":"https://api.github.com/repos/ansible/ansible/pulls/24808"}},"in_reply_to_id":172293049},"pull_request":{"url":"https://api.github.com/repos/ansible/ansible/pulls/24808","id":121420145,"html_url":"https://github.com/ansible/ansible/pull/24808","diff_url":"https://github.com/ansible/ansible/pull/24808.diff","patch_url":"https://github.com/ansible/ansible/pull/24808.patch","issue_url":"https://api.github.com/repos/ansible/ansible/issues/24808","number":24808,"state":"open","locked":false,"title":"osx_defaults: refactor to use Foundation/CoreFoudation python modules instead of `defaults` command, adding a lot of new features at the same time (nested keys and values, support for all types, etc.)","user":{"login":"etienned","id":251190,"avatar_url":"https://avatars3.githubusercontent.com/u/251190?v=4","gravatar_id":"","url":"https://api.github.com/users/etienned","html_url":"https://github.com/etienned","followers_url":"https://api.github.com/users/etienned/followers","following_url":"https://api.github.com/users/etienned/following{/other_user}","gists_url":"https://api.github.com/users/etienned/gists{/gist_id}","starred_url":"https://api.github.com/users/etienned/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/etienned/subscriptions","organizations_url":"https://api.github.com/users/etienned/orgs","repos_url":"https://api.github.com/users/etienned/repos","events_url":"https://api.github.com/users/etienned/events{/privacy}","received_events_url":"https://api.github.com/users/etienned/received_events","type":"User","site_admin":false},"body":"##### SUMMARY\r\nUpdate of the osx_defaults module to use Foundation / CoreFoudation python modules instead of the `defaults` command. The goal is to add new features and those features were not possible using the `defaults` command. New features are:\r\n- Access to nested keys and indexes (ex.:  DesktopViewSettings -> IconViewSettings -> arrangeBy).\r\n- Writing nested and complex values (ex.: [1, {key1: 'text', key2: true}, 'other stuff']).\r\n- Support for all data types: array, boolean, data, date, dict, float, integer, string.\r\n- Support to access preferences for any users (like network settings, etc.).\r\n- Specifying type is now optional. Types are mostly deduce as YAML/Ansible do.\r\n\r\nThose changes should be backwards compatible.\r\n\r\nAlso this update fixes some issues that are present in the current implementation (`array_add` not keeping items order, etc.).\r\n\r\nCode cleanup to adhere to Ansible's guidelines.\r\n\r\nNot python 3 compatible for the moment. But in most case Foundation / CoreFoundation modules runs under the stock macOS' python and it's currently version 2.7. It should not be too hard to add support for python 3 after the module will be tested for some time in the wild.\r\n\r\nThe new version of the module is built in way that it could be relatively easy to add other backends like `defaults` and `PlistBuddy` commands. It could be possible to automatically fallback to another backend (ex. using `defaults`, so module will have with limited functionalities) if Foundation can't be imported. I don't know if it's a good or a bad idea.\r\n\r\n##### ISSUE TYPE\r\n - Feature Pull Request\r\n\r\n##### COMPONENT NAME\r\nosx_defaults\r\n\r\n##### ANSIBLE VERSION\r\n```\r\nansible 2.4.0 (devel da99deeddc) last updated 2017/05/18 22:45:57 (GMT -400)\r\n```\r\n\r\n\r\n##### ADDITIONAL INFORMATION\r\nI know there's already at least one other pull request (like [this one](https://github.com/ansible/ansible/pull/24032)) for an updated version of `osx_defaults` but I think this version is more complete. I started to work on this a long time ago after [I submit](https://github.com/ansible/ansible-modules-extras/pull/1444) a fix for a bug. Unfortunately I didn't have a lot of time to work on it. I also did many iterations, first one was using `PlistBuddy`.\r\n\r\nI did a mostly complete test suite for the main class (`CFPreferences`) but unfortunately at the time I start it there was no official way to test modules. Also it was not possible to import something, like a class, from an Ansible module. So my test suite is built on `pytest` and the class is simply copied in the test file. That's not really pretty. You can find it [here](https://gist.github.com/etienned/34d8247756a1a49110c08385b85d634b). If there's a relatively simple way to integrate this test suite to Ansible, tell me. I also have a test playbook that cover most combinations [here](https://gist.github.com/etienned/ced131cbf8c34ddf3e1a09d6a945812f#file-test_osx_defaults-yml).\r\n\r\nI would like also to mention that I had to circumvent some inconsistent Ansible behaviors, particularly on how Ansible cast types in YAML, as this module use YAML structure and types intensively. I know there's some work that is done on that side currently and it's a good thing. I hit some limitations of JSON: no support for datetime and for data. That's one of the reason that datetime should always be quoted for this module. Maybe a kind of support for those types could be added directly to Ansible in the future.\r\n\r\nI tried to document the code correctly but if you have any interrogations just ask.","created_at":"2017-05-19T03:44:57Z","updated_at":"2018-03-12T01:40:18Z","closed_at":null,"merged_at":null,"merge_commit_sha":"e04134584cd69b9b5c191e578d12e768e7349f4b","assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[{"id":562292166,"url":"https://api.github.com/repos/ansible/ansible/labels/affects_2.4","name":"affects_2.4","color":"ededed","default":false},{"id":852590921,"url":"https://api.github.com/repos/ansible/ansible/labels/feature","name":"feature","color":"006b75","default":false},{"id":496359346,"url":"https://api.github.com/repos/ansible/ansible/labels/module","name":"module","color":"1d76db","default":false},{"id":632871029,"url":"https://api.github.com/repos/ansible/ansible/labels/needs_maintainer","name":"needs_maintainer","color":"fef2c0","default":false},{"id":61436281,"url":"https://api.github.com/repos/ansible/ansible/labels/needs_revision","name":"needs_revision","color":"eb6450","default":false},{"id":723150340,"url":"https://api.github.com/repos/ansible/ansible/labels/new_contributor","name":"new_contributor","color":"ededed","default":false},{"id":769569252,"url":"https://api.github.com/repos/ansible/ansible/labels/pep8","name":"pep8","color":"f66265","default":false},{"id":581543918,"url":"https://api.github.com/repos/ansible/ansible/labels/stale_ci","name":"stale_ci","color":"fef2c0","default":false},{"id":636327968,"url":"https://api.github.com/repos/ansible/ansible/labels/support:community","name":"support:community","color":"ededed","default":false},{"id":468273802,"url":"https://api.github.com/repos/ansible/ansible/labels/test","name":"test","color":"0e8a16","default":false},{"id":461464968,"url":"https://api.github.com/repos/ansible/ansible/labels/test_pull_requests","name":"test_pull_requests","color":"0e8a16","default":false}],"milestone":null,"commits_url":"https://api.github.com/repos/ansible/ansible/pulls/24808/commits","review_comments_url":"https://api.github.com/repos/ansible/ansible/pulls/24808/comments","review_comment_url":"https://api.github.com/repos/ansible/ansible/pulls/comments{/number}","comments_url":"https://api.github.com/repos/ansible/ansible/issues/24808/comments","statuses_url":"https://api.github.com/repos/ansible/ansible/statuses/10877132d81e1a3a5d8a0f882510367b246b56f3","head":{"label":"etienned:devel","ref":"devel","sha":"10877132d81e1a3a5d8a0f882510367b246b56f3","user":{"login":"etienned","id":251190,"avatar_url":"https://avatars3.githubusercontent.com/u/251190?v=4","gravatar_id":"","url":"https://api.github.com/users/etienned","html_url":"https://github.com/etienned","followers_url":"https://api.github.com/users/etienned/followers","following_url":"https://api.github.com/users/etienned/following{/other_user}","gists_url":"https://api.github.com/users/etienned/gists{/gist_id}","starred_url":"https://api.github.com/users/etienned/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/etienned/subscriptions","organizations_url":"https://api.github.com/users/etienned/orgs","repos_url":"https://api.github.com/users/etienned/repos","events_url":"https://api.github.com/users/etienned/events{/privacy}","received_events_url":"https://api.github.com/users/etienned/received_events","type":"User","site_admin":false},"repo":{"id":91757189,"name":"ansible","full_name":"etienned/ansible","owner":{"login":"etienned","id":251190,"avatar_url":"https://avatars3.githubusercontent.com/u/251190?v=4","gravatar_id":"","url":"https://api.github.com/users/etienned","html_url":"https://github.com/etienned","followers_url":"https://api.github.com/users/etienned/followers","following_url":"https://api.github.com/users/etienned/following{/other_user}","gists_url":"https://api.github.com/users/etienned/gists{/gist_id}","starred_url":"https://api.github.com/users/etienned/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/etienned/subscriptions","organizations_url":"https://api.github.com/users/etienned/orgs","repos_url":"https://api.github.com/users/etienned/repos","events_url":"https://api.github.com/users/etienned/events{/privacy}","received_events_url":"https://api.github.com/users/etienned/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/etienned/ansible","description":"Ansible is a radically simple IT automation platform that makes your applications and systems easier to deploy. Avoid writing scripts or custom code to deploy and update your applications— automate in a language that approaches plain English, using SSH, with no agents to install on remote systems.","fork":true,"url":"https://api.github.com/repos/etienned/ansible","forks_url":"https://api.github.com/repos/etienned/ansible/forks","keys_url":"https://api.github.com/repos/etienned/ansible/keys{/key_id}","collaborators_url":"https://api.github.com/repos/etienned/ansible/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/etienned/ansible/teams","hooks_url":"https://api.github.com/repos/etienned/ansible/hooks","issue_events_url":"https://api.github.com/repos/etienned/ansible/issues/events{/number}","events_url":"https://api.github.com/repos/etienned/ansible/events","assignees_url":"https://api.github.com/repos/etienned/ansible/assignees{/user}","branches_url":"https://api.github.com/repos/etienned/ansible/branches{/branch}","tags_url":"https://api.github.com/repos/etienned/ansible/tags","blobs_url":"https://api.github.com/repos/etienned/ansible/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/etienned/ansible/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/etienned/ansible/git/refs{/sha}","trees_url":"https://api.github.com/repos/etienned/ansible/git/trees{/sha}","statuses_url":"https://api.github.com/repos/etienned/ansible/statuses/{sha}","languages_url":"https://api.github.com/repos/etienned/ansible/languages","stargazers_url":"https://api.github.com/repos/etienned/ansible/stargazers","contributors_url":"https://api.github.com/repos/etienned/ansible/contributors","subscribers_url":"https://api.github.com/repos/etienned/ansible/subscribers","subscription_url":"https://api.github.com/repos/etienned/ansible/subscription","commits_url":"https://api.github.com/repos/etienned/ansible/commits{/sha}","git_commits_url":"https://api.github.com/repos/etienned/ansible/git/commits{/sha}","comments_url":"https://api.github.com/repos/etienned/ansible/comments{/number}","issue_comment_url":"https://api.github.com/repos/etienned/ansible/issues/comments{/number}","contents_url":"https://api.github.com/repos/etienned/ansible/contents/{+path}","compare_url":"https://api.github.com/repos/etienned/ansible/compare/{base}...{head}","merges_url":"https://api.github.com/repos/etienned/ansible/merges","archive_url":"https://api.github.com/repos/etienned/ansible/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/etienned/ansible/downloads","issues_url":"https://api.github.com/repos/etienned/ansible/issues{/number}","pulls_url":"https://api.github.com/repos/etienned/ansible/pulls{/number}","milestones_url":"https://api.github.com/repos/etienned/ansible/milestones{/number}","notifications_url":"https://api.github.com/repos/etienned/ansible/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/etienned/ansible/labels{/name}","releases_url":"https://api.github.com/repos/etienned/ansible/releases{/id}","deployments_url":"https://api.github.com/repos/etienned/ansible/deployments","created_at":"2017-05-19T02:30:29Z","updated_at":"2017-05-19T02:30:50Z","pushed_at":"2018-01-09T04:03:06Z","git_url":"git://github.com/etienned/ansible.git","ssh_url":"git@github.com:etienned/ansible.git","clone_url":"https://github.com/etienned/ansible.git","svn_url":"https://github.com/etienned/ansible","homepage":"http://ansible.com/","size":103613,"stargazers_count":0,"watchers_count":0,"language":"Python","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"open_issues_count":0,"license":{"key":"gpl-3.0","name":"GNU General Public License v3.0","spdx_id":"GPL-3.0","url":"https://api.github.com/licenses/gpl-3.0"},"forks":0,"open_issues":0,"watchers":0,"default_branch":"devel"}},"base":{"label":"ansible:devel","ref":"devel","sha":"b5245e581a5d669ce578d8f47738d29868d5622a","user":{"login":"ansible","id":1507452,"avatar_url":"https://avatars2.githubusercontent.com/u/1507452?v=4","gravatar_id":"","url":"https://api.github.com/users/ansible","html_url":"https://github.com/ansible","followers_url":"https://api.github.com/users/ansible/followers","following_url":"https://api.github.com/users/ansible/following{/other_user}","gists_url":"https://api.github.com/users/ansible/gists{/gist_id}","starred_url":"https://api.github.com/users/ansible/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ansible/subscriptions","organizations_url":"https://api.github.com/users/ansible/orgs","repos_url":"https://api.github.com/users/ansible/repos","events_url":"https://api.github.com/users/ansible/events{/privacy}","received_events_url":"https://api.github.com/users/ansible/received_events","type":"Organization","site_admin":false},"repo":{"id":3638964,"name":"ansible","full_name":"ansible/ansible","owner":{"login":"ansible","id":1507452,"avatar_url":"https://avatars2.githubusercontent.com/u/1507452?v=4","gravatar_id":"","url":"https://api.github.com/users/ansible","html_url":"https://github.com/ansible","followers_url":"https://api.github.com/users/ansible/followers","following_url":"https://api.github.com/users/ansible/following{/other_user}","gists_url":"https://api.github.com/users/ansible/gists{/gist_id}","starred_url":"https://api.github.com/users/ansible/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ansible/subscriptions","organizations_url":"https://api.github.com/users/ansible/orgs","repos_url":"https://api.github.com/users/ansible/repos","events_url":"https://api.github.com/users/ansible/events{/privacy}","received_events_url":"https://api.github.com/users/ansible/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/ansible/ansible","description":"Ansible is a radically simple IT automation platform that makes your applications and systems easier to deploy. Avoid writing scripts or custom code to deploy and update your applications— automate in a language that approaches plain English, using SSH, with no agents to install on remote systems.","fork":false,"url":"https://api.github.com/repos/ansible/ansible","forks_url":"https://api.github.com/repos/ansible/ansible/forks","keys_url":"https://api.github.com/repos/ansible/ansible/keys{/key_id}","collaborators_url":"https://api.github.com/repos/ansible/ansible/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/ansible/ansible/teams","hooks_url":"https://api.github.com/repos/ansible/ansible/hooks","issue_events_url":"https://api.github.com/repos/ansible/ansible/issues/events{/number}","events_url":"https://api.github.com/repos/ansible/ansible/events","assignees_url":"https://api.github.com/repos/ansible/ansible/assignees{/user}","branches_url":"https://api.github.com/repos/ansible/ansible/branches{/branch}","tags_url":"https://api.github.com/repos/ansible/ansible/tags","blobs_url":"https://api.github.com/repos/ansible/ansible/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/ansible/ansible/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/ansible/ansible/git/refs{/sha}","trees_url":"https://api.github.com/repos/ansible/ansible/git/trees{/sha}","statuses_url":"https://api.github.com/repos/ansible/ansible/statuses/{sha}","languages_url":"https://api.github.com/repos/ansible/ansible/languages","stargazers_url":"https://api.github.com/repos/ansible/ansible/stargazers","contributors_url":"https://api.github.com/repos/ansible/ansible/contributors","subscribers_url":"https://api.github.com/repos/ansible/ansible/subscribers","subscription_url":"https://api.github.com/repos/ansible/ansible/subscription","commits_url":"https://api.github.com/repos/ansible/ansible/commits{/sha}","git_commits_url":"https://api.github.com/repos/ansible/ansible/git/commits{/sha}","comments_url":"https://api.github.com/repos/ansible/ansible/comments{/number}","issue_comment_url":"https://api.github.com/repos/ansible/ansible/issues/comments{/number}","contents_url":"https://api.github.com/repos/ansible/ansible/contents/{+path}","compare_url":"https://api.github.com/repos/ansible/ansible/compare/{base}...{head}","merges_url":"https://api.github.com/repos/ansible/ansible/merges","archive_url":"https://api.github.com/repos/ansible/ansible/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/ansible/ansible/downloads","issues_url":"https://api.github.com/repos/ansible/ansible/issues{/number}","pulls_url":"https://api.github.com/repos/ansible/ansible/pulls{/number}","milestones_url":"https://api.github.com/repos/ansible/ansible/milestones{/number}","notifications_url":"https://api.github.com/repos/ansible/ansible/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/ansible/ansible/labels{/name}","releases_url":"https://api.github.com/repos/ansible/ansible/releases{/id}","deployments_url":"https://api.github.com/repos/ansible/ansible/deployments","created_at":"2012-03-06T14:58:02Z","updated_at":"2018-03-12T01:09:19Z","pushed_at":"2018-03-12T01:20:07Z","git_url":"git://github.com/ansible/ansible.git","ssh_url":"git@github.com:ansible/ansible.git","clone_url":"https://github.com/ansible/ansible.git","svn_url":"https://github.com/ansible/ansible","homepage":"https://www.ansible.com/","size":120835,"stargazers_count":28921,"watchers_count":28921,"language":"Python","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":10452,"mirror_url":null,"archived":false,"open_issues_count":4898,"license":{"key":"gpl-3.0","name":"GNU General Public License v3.0","spdx_id":"GPL-3.0","url":"https://api.github.com/licenses/gpl-3.0"},"forks":10452,"open_issues":4898,"watchers":28921,"default_branch":"devel"}},"_links":{"self":{"href":"https://api.github.com/repos/ansible/ansible/pulls/24808"},"html":{"href":"https://github.com/ansible/ansible/pull/24808"},"issue":{"href":"https://api.github.com/repos/ansible/ansible/issues/24808"},"comments":{"href":"https://api.github.com/repos/ansible/ansible/issues/24808/comments"},"review_comments":{"href":"https://api.github.com/repos/ansible/ansible/pulls/24808/comments"},"review_comment":{"href":"https://api.github.com/repos/ansible/ansible/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/ansible/ansible/pulls/24808/commits"},"statuses":{"href":"https://api.github.com/repos/ansible/ansible/statuses/10877132d81e1a3a5d8a0f882510367b246b56f3"}},"author_association":"NONE"}}
{"actor":{"display_login":"etienned"}}