{"action":"created","comment":{"url":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/comments/36531741","id":36531741,"diff_hunk":"@@ -2829,188 +2894,302 @@ def to_json(self):\n             \"anonymous\": self.anonymous\n         }\n \n+class Sanction(StoredObject):\n+    \"\"\"Sanction object is a generic way to track approval states\"\"\"\n \n-def validate_retraction_state(value):\n-    acceptable_states = [Retraction.PENDING, Retraction.RETRACTED, Retraction.CANCELLED]\n-    if value not in acceptable_states:\n-        raise ValidationValueError('Invalid retraction state assignment.')\n+    abstract = True\n \n-    return True\n+    UNAPPROVED = 'unapproved'\n+    APPROVED = 'approved'\n+    REJECTED = 'rejected'\n \n+    DISPLAY_NAME = 'sanction'\n \n-class Retraction(StoredObject):\n-    \"\"\"Retraction object for public registrations.\"\"\"\n-\n-    PENDING = 'pending'\n-    RETRACTED = 'retracted'\n-    CANCELLED = 'cancelled'\n+    APPROVAL_NOT_AUTHORIZED_MESSAGE = 'This user is not authorized to approve this {0}'\n+    APPROVAL_INVALID_TOKEN_MESSAGE = 'Invalid approval token provided for this {0}.'\n+    REJECTION_NOT_AUTHORIZED_MESSAEGE = 'This user is not authorized to reject this {0}'\n+    REJECTION_INVALID_TOKEN_MESSAGE = 'Invalid rejection token provided for this {0}.'\n \n     _id = fields.StringField(primary=True, default=lambda: str(ObjectId()))\n-    justification = fields.StringField(default=None, validate=MaxLengthValidator(2048))\n     initiation_date = fields.DateTimeField(auto_now_add=datetime.datetime.utcnow)\n-    initiated_by = fields.ForeignField('user', backref='retracted')\n+    end_date = fields.DateTimeField(default=None)\n+\n+    # Sanction subclasses must have an initiated_by field\n+    # initiated_by = fields.ForeignField('user', backref='initiated')\n+\n     # Expanded: Dictionary field mapping admin IDs their approval status and relevant tokens:\n     # {\n     #   'b3k97': {\n     #     'has_approved': False,\n-    #     'approval_token': 'Cru7wj1Puf7DENUPFPnXSwa1rf3xPN',\n-    #     'disapproval_token': 'UotzClTFOic2PYxHDStby94bCQMwJy'}\n+    #     'approval_token': 'Pew7wj1Puf7DENUPFPnXSwa1rf3xPN',\n+    #     'rejection_token': 'TwozClTFOic2PYxHDStby94bCQMwJy'}\n     # }\n     approval_state = fields.DictionaryField()\n-    # One of 'pending', 'retracted', or 'cancelled'\n-    state = fields.StringField(default='pending', validate=validate_retraction_state)\n+    # One of 'unapproved', 'approved', or 'rejected'\n+    state = fields.StringField(default='unapproved')\n \n     def __repr__(self):\n-        parent_registration = Node.find_one(Q('retraction', 'eq', self))\n-        return ('<Retraction(parent_registration={0}, initiated_by={1}) '\n-                'with _id {2}>').format(\n-            parent_registration,\n-            self.initiated_by,\n+        return '<Sanction(end_date={1}) with _id {2}>'.format(\n+            self.end_date,\n             self._id\n         )\n \n     @property\n-    def is_retracted(self):\n-        return self.state == self.RETRACTED\n+    def pending_approval(self):\n+        return self.state == Sanction.UNAPPROVED\n \n     @property\n-    def pending_retraction(self):\n-        return self.state == self.PENDING\n+    def is_approved(self):\n+        return self.state == Sanction.APPROVED\n \n-    def disapprove_retraction(self, user, token):\n-        \"\"\"Cancels retraction if user is admin and token verifies.\"\"\"\n-        try:\n-            if self.approval_state[user._id]['disapproval_token'] != token:\n-                raise InvalidRetractionDisapprovalToken('Invalid retraction disapproval token provided.')\n-        except KeyError:\n-            raise PermissionsError('User must be an admin to disapprove retraction of a registration.')\n+    @property\n+    def is_rejected(self):\n+        return self.state == Sanction.REJECTED\n \n-        self.state = self.CANCELLED\n-        parent_registration = Node.find_one(Q('retraction', 'eq', self))\n-        parent_registration.registered_from.add_log(\n-            action=NodeLog.RETRACTION_CANCELLED,\n-            params={\n-                'node': parent_registration._id,\n-                'retraction_id': self._id,\n-            },\n-            auth=Auth(user),\n-            save=True,\n-        )\n+    def _validate_authorizer(self, user):\n+        return True\n \n-    def approve_retraction(self, user, token):\n+    def add_authorizer(self, user, approved=False, save=False):\n+        valid = self._validate_authorizer(user)\n+        if valid and user._id not in self.approval_state:\n+            self.approval_state[user._id] = {\n+                'has_approved': approved,\n+                'approval_token': security.random_string(30),\n+                'rejection_token': security.random_string(30),\n+            }\n+            if save:\n+                self.save()\n+            return True\n+        return False\n+\n+    def remove_authorizer(self, user):\n+        if user._id not in self.approval_state:\n+            return False\n+\n+        del self.approval_state[user._id]\n+        self.save()\n+        return True\n+\n+    def _on_approve(self, user, token):\n+        if all(authorizer['has_approved'] for authorizer in self.approval_state.values()):\n+            self.state = Sanction.APPROVED\n+            self._on_complete(user)\n+\n+    def _on_reject(self, user, token):\n+        \"\"\"Early termination of a Sanction\"\"\"\n+        raise NotImplementedError('Sanction subclasses must implement an #_on_reject method')\n+\n+    def _on_complete(self, user):\n+        \"\"\"When a Sanction has unanimous approval\"\"\"\n+        raise NotImplementedError('Sanction subclasses must implement an #_on_complete method')\n+\n+    def approve(self, user, token):\n         \"\"\"Add user to approval list if user is admin and token verifies.\"\"\"\n         try:\n             if self.approval_state[user._id]['approval_token'] != token:\n-                raise InvalidRetractionApprovalToken('Invalid retraction approval token provided.')\n+                raise InvalidSanctionApprovalToken(self.APPROVAL_INVALID_TOKEN_MESSAGE.format(self.DISPLAY_NAME))\n         except KeyError:\n-            raise PermissionsError('User must be an admin to disapprove retraction of a registration.')\n-\n+            raise PermissionsError(self.APPROVAL_NOT_AUTHORIZED_MESSAGE.format(self.DISPLAY_NAME))\n         self.approval_state[user._id]['has_approved'] = True\n-        if all(val['has_approved'] for val in self.approval_state.values()):\n-            self.state = self.RETRACTED\n+        self._on_approve(user, token)\n \n-            parent_registration = Node.find_one(Q('retraction', 'eq', self))\n-            parent_registration.registered_from.add_log(\n-                action=NodeLog.RETRACTION_APPROVED,\n-                params={\n-                    'node': parent_registration._id,\n-                    'retraction_id': self._id,\n-                },\n-                auth=Auth(user),\n-            )\n-            # Remove any embargoes associated with the registration\n-            if parent_registration.embargo_end_date or parent_registration.pending_embargo:\n-                parent_registration.embargo.state = self.CANCELLED\n-                parent_registration.registered_from.add_log(\n-                    action=NodeLog.EMBARGO_CANCELLED,\n-                    params={\n-                        'node': parent_registration._id,\n-                        'embargo_id': parent_registration.embargo._id,\n-                    },\n-                    auth=Auth(user),\n-                )\n-                parent_registration.embargo.save()\n-            # Ensure retracted registration is public\n-            if not parent_registration.is_public:\n-                parent_registration.set_privacy('public')\n-            parent_registration.update_search()\n-            # Retraction status is inherited from the root project, so we\n-            # need to recursively update search for every descendant node\n-            # so that retracted subrojects/components don't appear in search\n-            for node in parent_registration.get_descendants_recursive():\n-                node.update_search()\n-\n-\n-def validate_embargo_state(value):\n-    acceptable_states = [\n-        Embargo.UNAPPROVED, Embargo.ACTIVE, Embargo.CANCELLED, Embargo.COMPLETED\n-    ]\n-    if value not in acceptable_states:\n-        raise ValidationValueError('Invalid embargo state assignment.')\n-    return True\n+    def reject(self, user, token):\n+        \"\"\"Cancels sanction if user is admin and token verifies.\"\"\"\n+        try:\n+            if self.approval_state[user._id]['rejection_token'] != token:\n+                raise InvalidSanctionRejectionToken(self.REJECTION_INVALID_TOKEN_MESSAGE.format(self.DISPLAY_NAME))\n+        except KeyError:\n+            raise PermissionsError(self.REJECTION_NOT_AUTHORIZED_MESSAEGE.format(self.DISPLAY_NAME))\n+        self.state = Sanction.REJECTED\n+        self._on_reject(user, token)\n \n+    def _notify_authorizer(self, user):\n+        pass\n \n-class Embargo(StoredObject):\n-    \"\"\"Embargo object for registrations waiting to go public.\"\"\"\n+    def _notify_non_authorizer(self, user):\n+        pass\n \n-    UNAPPROVED = 'unapproved'\n-    ACTIVE = 'active'\n-    CANCELLED = 'cancelled'\n-    COMPLETED = 'completed'\n+    def ask(self, group):\n+        for contrib in group:\n+            if contrib._id in self.approval_state:\n+                self._notify_authorizer(contrib)\n+            else:\n+                self._notify_non_authorizer(contrib)\n \n-    _id = fields.StringField(primary=True, default=lambda: str(ObjectId()))\n-    initiation_date = fields.DateTimeField(auto_now_add=datetime.datetime.utcnow)\n-    initiated_by = fields.ForeignField('user', backref='embargoed')\n-    end_date = fields.DateTimeField()\n-    # Expanded: Dictionary field mapping admin IDs their approval status and relevant tokens:\n+class EmailApprovableSanction(Sanction):\n+\n+    AUTHORIZER_NOTIFY_TEMPLATE = None\n+    NON_AUTHORIZER_NOTIFY_TEMPLATE = None\n+\n+    VIEW_URL_TEMPLATE = ''\n+    APPROVE_URL_TEMPLATE = ''\n+    REJECT_URL_TEMPLATE = ''\n+\n+    # Store a persistant copy of urls for use when needed outside of a request context.\n+    # This field gets automagically updated whenever models approval_state is modified\n+    # and the model is saved\n     # {\n-    #   'b3k97': {\n-    #     'has_approved': False,\n-    #     'approval_token': 'Pew7wj1Puf7DENUPFPnXSwa1rf3xPN',\n-    #     'disapproval_token': 'TwozClTFOic2PYxHDStby94bCQMwJy'}\n+    #   'abcde': {\n+    #     'approve': [APPROVAL_URL],\n+    #     'reject': [REJECT_URL],\n+    #   }\n     # }\n-    approval_state = fields.DictionaryField()\n-    # One of 'unapproved', 'active', 'cancelled', or 'completed\n-    state = fields.StringField(default='unapproved', validate=validate_embargo_state)\n-    for_existing_registration = fields.BooleanField(default=False)\n+    stashed_urls = fields.DictionaryField(default={})","path":"website/project/model.py","position":569,"original_position":569,"commit_id":"bc413520a38282abf049825660e531e0bd54f0e6","original_commit_id":"abfc1b5af2db754d79d9b4b4dd6d3472bda63185","user":{"login":"sloria","id":2379650,"avatar_url":"https://avatars.githubusercontent.com/u/2379650?v=3","gravatar_id":"","url":"https://api.github.com/users/sloria","html_url":"https://github.com/sloria","followers_url":"https://api.github.com/users/sloria/followers","following_url":"https://api.github.com/users/sloria/following{/other_user}","gists_url":"https://api.github.com/users/sloria/gists{/gist_id}","starred_url":"https://api.github.com/users/sloria/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/sloria/subscriptions","organizations_url":"https://api.github.com/users/sloria/orgs","repos_url":"https://api.github.com/users/sloria/repos","events_url":"https://api.github.com/users/sloria/events{/privacy}","received_events_url":"https://api.github.com/users/sloria/received_events","type":"User","site_admin":false},"body":"Don't pass mutable objects as the `default` argument; use a callable, e.g. `dict`.","created_at":"2015-08-07T15:45:35Z","updated_at":"2015-08-07T15:45:35Z","html_url":"https://github.com/CenterForOpenScience/osf.io/pull/3919#discussion_r36531741","pull_request_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/3919","_links":{"self":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/comments/36531741"},"html":{"href":"https://github.com/CenterForOpenScience/osf.io/pull/3919#discussion_r36531741"},"pull_request":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/3919"}}},"pull_request":{"url":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/3919","id":41503843,"html_url":"https://github.com/CenterForOpenScience/osf.io/pull/3919","diff_url":"https://github.com/CenterForOpenScience/osf.io/pull/3919.diff","patch_url":"https://github.com/CenterForOpenScience/osf.io/pull/3919.patch","issue_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/issues/3919","number":3919,"state":"open","locked":false,"title":"[Ready for Review] Registration Approvals","user":{"login":"samchrisinger","id":2207561,"avatar_url":"https://avatars.githubusercontent.com/u/2207561?v=3","gravatar_id":"","url":"https://api.github.com/users/samchrisinger","html_url":"https://github.com/samchrisinger","followers_url":"https://api.github.com/users/samchrisinger/followers","following_url":"https://api.github.com/users/samchrisinger/following{/other_user}","gists_url":"https://api.github.com/users/samchrisinger/gists{/gist_id}","starred_url":"https://api.github.com/users/samchrisinger/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/samchrisinger/subscriptions","organizations_url":"https://api.github.com/users/samchrisinger/orgs","repos_url":"https://api.github.com/users/samchrisinger/repos","events_url":"https://api.github.com/users/samchrisinger/events{/privacy}","received_events_url":"https://api.github.com/users/samchrisinger/received_events","type":"User","site_admin":false},"body":"_[Associated Proposal](https://docs.google.com/document/d/1_zOgOvbAphIuj2A3L2Hg0rXP83xxAzCc0uAKRIF0bIs/edit#heading=h.4mazeeocviis)_\r\n\r\n# Purpose\r\n\r\nCommunity has requested that (to match the embargo behavior) all registrations require approval by administrators.  \r\n\r\nThere was already some (backend) redundancy with how embargoes and retractions were handled and a future need for generalized approval behavior (Prereg), so this seemed like an opportune time to generalize some of our infrastructure while simultaneously adding the requested registration approval behavior.\r\n\r\n# Changes\r\n\r\n- Add Sanction class as a generic representation of a stateful approval of some action\r\n- Add EmailApprovableSanction to generalize email-specific behavior shared by Embargo, Retraction\r\n- Add RegistrationApproval as a subclass of EmailApprovableSanction \r\n- Update embargo/retraction/registration views to use this new abstraction\r\n- Update Archiver to use new abstraction\r\n- Fix old tests\r\n- Add new tests for Sanction and EmailApprovableSanction\r\n\r\n# Side Effects\r\n\r\nThis touches Embargoes and Retractions. I'm confident in the quality of the unit tests in place as well as the newly added tests, but we should confirm that this functionally still works as expected. \r\n\r\nAdditionally now any time a project is registered and the associated archival task completes that registration will remain private until approved, or after they remain unapproved for 48 hours. An approval email is sent to every project admin, and a notification email is sent to every non-admin collaborator. This functionality mirrors Embargoes/Retractions as closely as possible. \r\n\r\nWe have also added logs for:\r\n- registration initiated\r\n- registration approved\r\n- registration rejected\r\n\r\nTODOs\r\n\r\n- [x] Implement generic approval class\r\n- [x] Make Embargo and Retraction subclasses of generic approval class\r\n- [x] Implement RegistrationApproval class\r\n- [x] Implement views to support registration approvals\r\n- [x] Add registration approval logs and log templates\r\n  - [x] add tests for new logs and log templates\r\n- [x] Add registration approval email templates\r\n- [x] Fix the tests\r\n- [x] Add tests for new code\r\n- [x] Add cron scripts for auto-approval of registrations\r\n- [x] @HarryRybacki add pending_registration_approval to Node serialization and to mako templates","created_at":"2015-08-03T20:02:16Z","updated_at":"2015-08-07T15:45:35Z","closed_at":null,"merged_at":null,"merge_commit_sha":"b7bf4543e9d20dd6faedaef120434e05f0cd2470","assignee":null,"milestone":null,"commits_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/3919/commits","review_comments_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/3919/comments","review_comment_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/comments{/number}","comments_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/issues/3919/comments","statuses_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/statuses/bc413520a38282abf049825660e531e0bd54f0e6","head":{"label":"samchrisinger:feature/approvals","ref":"feature/approvals","sha":"bc413520a38282abf049825660e531e0bd54f0e6","user":{"login":"samchrisinger","id":2207561,"avatar_url":"https://avatars.githubusercontent.com/u/2207561?v=3","gravatar_id":"","url":"https://api.github.com/users/samchrisinger","html_url":"https://github.com/samchrisinger","followers_url":"https://api.github.com/users/samchrisinger/followers","following_url":"https://api.github.com/users/samchrisinger/following{/other_user}","gists_url":"https://api.github.com/users/samchrisinger/gists{/gist_id}","starred_url":"https://api.github.com/users/samchrisinger/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/samchrisinger/subscriptions","organizations_url":"https://api.github.com/users/samchrisinger/orgs","repos_url":"https://api.github.com/users/samchrisinger/repos","events_url":"https://api.github.com/users/samchrisinger/events{/privacy}","received_events_url":"https://api.github.com/users/samchrisinger/received_events","type":"User","site_admin":false},"repo":{"id":30772672,"name":"osf.io","full_name":"samchrisinger/osf.io","owner":{"login":"samchrisinger","id":2207561,"avatar_url":"https://avatars.githubusercontent.com/u/2207561?v=3","gravatar_id":"","url":"https://api.github.com/users/samchrisinger","html_url":"https://github.com/samchrisinger","followers_url":"https://api.github.com/users/samchrisinger/followers","following_url":"https://api.github.com/users/samchrisinger/following{/other_user}","gists_url":"https://api.github.com/users/samchrisinger/gists{/gist_id}","starred_url":"https://api.github.com/users/samchrisinger/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/samchrisinger/subscriptions","organizations_url":"https://api.github.com/users/samchrisinger/orgs","repos_url":"https://api.github.com/users/samchrisinger/repos","events_url":"https://api.github.com/users/samchrisinger/events{/privacy}","received_events_url":"https://api.github.com/users/samchrisinger/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/samchrisinger/osf.io","description":"Facilitating Open Science","fork":true,"url":"https://api.github.com/repos/samchrisinger/osf.io","forks_url":"https://api.github.com/repos/samchrisinger/osf.io/forks","keys_url":"https://api.github.com/repos/samchrisinger/osf.io/keys{/key_id}","collaborators_url":"https://api.github.com/repos/samchrisinger/osf.io/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/samchrisinger/osf.io/teams","hooks_url":"https://api.github.com/repos/samchrisinger/osf.io/hooks","issue_events_url":"https://api.github.com/repos/samchrisinger/osf.io/issues/events{/number}","events_url":"https://api.github.com/repos/samchrisinger/osf.io/events","assignees_url":"https://api.github.com/repos/samchrisinger/osf.io/assignees{/user}","branches_url":"https://api.github.com/repos/samchrisinger/osf.io/branches{/branch}","tags_url":"https://api.github.com/repos/samchrisinger/osf.io/tags","blobs_url":"https://api.github.com/repos/samchrisinger/osf.io/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/samchrisinger/osf.io/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/samchrisinger/osf.io/git/refs{/sha}","trees_url":"https://api.github.com/repos/samchrisinger/osf.io/git/trees{/sha}","statuses_url":"https://api.github.com/repos/samchrisinger/osf.io/statuses/{sha}","languages_url":"https://api.github.com/repos/samchrisinger/osf.io/languages","stargazers_url":"https://api.github.com/repos/samchrisinger/osf.io/stargazers","contributors_url":"https://api.github.com/repos/samchrisinger/osf.io/contributors","subscribers_url":"https://api.github.com/repos/samchrisinger/osf.io/subscribers","subscription_url":"https://api.github.com/repos/samchrisinger/osf.io/subscription","commits_url":"https://api.github.com/repos/samchrisinger/osf.io/commits{/sha}","git_commits_url":"https://api.github.com/repos/samchrisinger/osf.io/git/commits{/sha}","comments_url":"https://api.github.com/repos/samchrisinger/osf.io/comments{/number}","issue_comment_url":"https://api.github.com/repos/samchrisinger/osf.io/issues/comments{/number}","contents_url":"https://api.github.com/repos/samchrisinger/osf.io/contents/{+path}","compare_url":"https://api.github.com/repos/samchrisinger/osf.io/compare/{base}...{head}","merges_url":"https://api.github.com/repos/samchrisinger/osf.io/merges","archive_url":"https://api.github.com/repos/samchrisinger/osf.io/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/samchrisinger/osf.io/downloads","issues_url":"https://api.github.com/repos/samchrisinger/osf.io/issues{/number}","pulls_url":"https://api.github.com/repos/samchrisinger/osf.io/pulls{/number}","milestones_url":"https://api.github.com/repos/samchrisinger/osf.io/milestones{/number}","notifications_url":"https://api.github.com/repos/samchrisinger/osf.io/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/samchrisinger/osf.io/labels{/name}","releases_url":"https://api.github.com/repos/samchrisinger/osf.io/releases{/id}","created_at":"2015-02-13T19:40:14Z","updated_at":"2015-02-27T18:03:37Z","pushed_at":"2015-08-07T15:19:00Z","git_url":"git://github.com/samchrisinger/osf.io.git","ssh_url":"git@github.com:samchrisinger/osf.io.git","clone_url":"https://github.com/samchrisinger/osf.io.git","svn_url":"https://github.com/samchrisinger/osf.io","homepage":"http://osf.io","size":100481,"stargazers_count":0,"watchers_count":0,"language":"Python","has_issues":false,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":0,"mirror_url":null,"open_issues_count":1,"forks":0,"open_issues":1,"watchers":0,"default_branch":"develop"}},"base":{"label":"CenterForOpenScience:develop","ref":"develop","sha":"96062b92c34cb14779e25fb86747f7e112604d6d","user":{"login":"CenterForOpenScience","id":3344584,"avatar_url":"https://avatars.githubusercontent.com/u/3344584?v=3","gravatar_id":"","url":"https://api.github.com/users/CenterForOpenScience","html_url":"https://github.com/CenterForOpenScience","followers_url":"https://api.github.com/users/CenterForOpenScience/followers","following_url":"https://api.github.com/users/CenterForOpenScience/following{/other_user}","gists_url":"https://api.github.com/users/CenterForOpenScience/gists{/gist_id}","starred_url":"https://api.github.com/users/CenterForOpenScience/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/CenterForOpenScience/subscriptions","organizations_url":"https://api.github.com/users/CenterForOpenScience/orgs","repos_url":"https://api.github.com/users/CenterForOpenScience/repos","events_url":"https://api.github.com/users/CenterForOpenScience/events{/privacy}","received_events_url":"https://api.github.com/users/CenterForOpenScience/received_events","type":"Organization","site_admin":false},"repo":{"id":10199599,"name":"osf.io","full_name":"CenterForOpenScience/osf.io","owner":{"login":"CenterForOpenScience","id":3344584,"avatar_url":"https://avatars.githubusercontent.com/u/3344584?v=3","gravatar_id":"","url":"https://api.github.com/users/CenterForOpenScience","html_url":"https://github.com/CenterForOpenScience","followers_url":"https://api.github.com/users/CenterForOpenScience/followers","following_url":"https://api.github.com/users/CenterForOpenScience/following{/other_user}","gists_url":"https://api.github.com/users/CenterForOpenScience/gists{/gist_id}","starred_url":"https://api.github.com/users/CenterForOpenScience/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/CenterForOpenScience/subscriptions","organizations_url":"https://api.github.com/users/CenterForOpenScience/orgs","repos_url":"https://api.github.com/users/CenterForOpenScience/repos","events_url":"https://api.github.com/users/CenterForOpenScience/events{/privacy}","received_events_url":"https://api.github.com/users/CenterForOpenScience/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/CenterForOpenScience/osf.io","description":"Facilitating Open Science","fork":false,"url":"https://api.github.com/repos/CenterForOpenScience/osf.io","forks_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/forks","keys_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/keys{/key_id}","collaborators_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/teams","hooks_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/hooks","issue_events_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/issues/events{/number}","events_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/events","assignees_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/assignees{/user}","branches_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/branches{/branch}","tags_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/tags","blobs_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/git/refs{/sha}","trees_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/git/trees{/sha}","statuses_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/statuses/{sha}","languages_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/languages","stargazers_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/stargazers","contributors_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/contributors","subscribers_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/subscribers","subscription_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/subscription","commits_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/commits{/sha}","git_commits_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/git/commits{/sha}","comments_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/comments{/number}","issue_comment_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/issues/comments{/number}","contents_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/contents/{+path}","compare_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/compare/{base}...{head}","merges_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/merges","archive_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/downloads","issues_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/issues{/number}","pulls_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls{/number}","milestones_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/milestones{/number}","notifications_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/labels{/name}","releases_url":"https://api.github.com/repos/CenterForOpenScience/osf.io/releases{/id}","created_at":"2013-05-21T15:53:37Z","updated_at":"2015-08-05T16:17:51Z","pushed_at":"2015-08-07T15:25:56Z","git_url":"git://github.com/CenterForOpenScience/osf.io.git","ssh_url":"git@github.com:CenterForOpenScience/osf.io.git","clone_url":"https://github.com/CenterForOpenScience/osf.io.git","svn_url":"https://github.com/CenterForOpenScience/osf.io","homepage":"http://osf.io","size":221476,"stargazers_count":125,"watchers_count":125,"language":"Python","has_issues":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":107,"mirror_url":null,"open_issues_count":514,"forks":107,"open_issues":514,"watchers":125,"default_branch":"develop"}},"_links":{"self":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/3919"},"html":{"href":"https://github.com/CenterForOpenScience/osf.io/pull/3919"},"issue":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/issues/3919"},"comments":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/issues/3919/comments"},"review_comments":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/3919/comments"},"review_comment":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/pulls/3919/commits"},"statuses":{"href":"https://api.github.com/repos/CenterForOpenScience/osf.io/statuses/bc413520a38282abf049825660e531e0bd54f0e6"}}}}