GitHub PullRequestReviewCommentEvent at 2014-08-20 18:45:38
Repository: Panoptes
Actor: camallen
Payload: {"action":"created","comment":{"url":"https://api.github.com/repos/zooniverse/Panoptes/pulls/comments/16496035","id":16496035,"diff_hunk":"@@ -0,0 +1,78 @@\n+module RoleControl\n+  class RoleQuery\n+    def initialize(roles, public, resource_class)\n+      @roles, @public, @klass = roles, public, resource_class\n+    end\n+\n+    def build(actor, target=nil, extra_tests=[])\n+      binding, join_query = join_clause(actor, target)\n+      extra_tests << public_test if @public\n+      \n+      query = @klass.where(where_clause(!!join_query, extra_tests))\n+      query = query.joins(join_query) if join_query\n+      \n+      rebind(query, binding)\n+    end\n+\n+    private\n+\n+    def rebind(query, bindings)\n+      return query unless bindings\n+      bindings.try(:reduce, query) { |q, b| q.bind(b) } \n+    end\n+\n+    def table\n+      @klass.arel_table\n+    end\n+    \n+    def roles_table\n+      @roles_table ||= Arel::Table.new(:roles_query)\n+    end\n+\n+    def role_query(actor, target)\n+      target = target.nil? ? @klass : target\n+      q = actor.roles_query(target)\n+      binding, arel = q.try(:bind_values), q.try(:arel).try(:as, 'roles_query')\n+      [binding, arel]\n+    end\n+\n+    def join_clause(actor, target)\n+      binding, query = role_query(actor, target)\n+      query = table.create_join(query, join_on, Arel::Nodes::OuterJoin) if query\n+      [binding, query]\n+    end\n+    \n+    def join_on\n+      table.create_on(roles_table[join_id].eq(table[:id]))\n+    end\n+\n+    def join_id\n+      \"#{ @klass.model_name.singular }_id\".to_sym\n+    end\n+\n+    def where_clause(include_roles, extra_tests)\n+      q = include_roles ? roles_test : extra_tests.pop \n+      extra_tests.reduce(q) { |query, test| query.or(test) }\n+    end\n+    \n+    def public_test\n+      table[@roles].eq('{}')\n+    end\n+    \n+    def roles_test","path":"lib/role_control/role_query.rb","position":62,"original_position":62,"commit_id":"ef39b11aa513c7a3d07d0c12d663d96cef57ed51","original_commit_id":"f8866e79f8d17a508b96690509f31d2cb92d9d5c","user":{"login":"camallen","id":295329,"avatar_url":"https://avatars.githubusercontent.com/u/295329?v=2","gravatar_id":"cb146d04eaf373d3946d75fd1c4ed17e","url":"https://api.github.com/users/camallen","html_url":"https://github.com/camallen","followers_url":"https://api.github.com/users/camallen/followers","following_url":"https://api.github.com/users/camallen/following{/other_user}","gists_url":"https://api.github.com/users/camallen/gists{/gist_id}","starred_url":"https://api.github.com/users/camallen/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/camallen/subscriptions","organizations_url":"https://api.github.com/users/camallen/orgs","repos_url":"https://api.github.com/users/camallen/repos","events_url":"https://api.github.com/users/camallen/events{/privacy}","received_events_url":"https://api.github.com/users/camallen/received_events","type":"User","site_admin":false},"body":"I am confused about the `\"roles_query\".\"roles\"` part. Where does the `roles_query` resolve to with SQL?","created_at":"2014-08-20T18:45:38Z","updated_at":"2014-08-20T18:45:38Z","html_url":"https://github.com/zooniverse/Panoptes/pull/132#discussion_r16496035","pull_request_url":"https://api.github.com/repos/zooniverse/Panoptes/pulls/132","_links":{"self":{"href":"https://api.github.com/repos/zooniverse/Panoptes/pulls/comments/16496035"},"html":{"href":"https://github.com/zooniverse/Panoptes/pull/132#discussion_r16496035"},"pull_request":{"href":"https://api.github.com/repos/zooniverse/Panoptes/pulls/132"}}},"pull_request":{"url":"https://api.github.com/repos/zooniverse/Panoptes/pulls/132","id":19218291,"html_url":"https://github.com/zooniverse/Panoptes/pull/132","diff_url":"https://github.com/zooniverse/Panoptes/pull/132.diff","patch_url":"https://github.com/zooniverse/Panoptes/pull/132.patch","issue_url":"https://api.github.com/repos/zooniverse/Panoptes/issues/132","number":132,"state":"open","locked":false,"title":"Control control - Better Permissions and Roles","user":{"login":"edpaget","id":108177,"avatar_url":"https://avatars.githubusercontent.com/u/108177?v=2","gravatar_id":"ba805c0cdc67dfa2f91fa9c4a987e42c","url":"https://api.github.com/users/edpaget","html_url":"https://github.com/edpaget","followers_url":"https://api.github.com/users/edpaget/followers","following_url":"https://api.github.com/users/edpaget/following{/other_user}","gists_url":"https://api.github.com/users/edpaget/gists{/gist_id}","starred_url":"https://api.github.com/users/edpaget/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/edpaget/subscriptions","organizations_url":"https://api.github.com/users/edpaget/orgs","repos_url":"https://api.github.com/users/edpaget/repos","events_url":"https://api.github.com/users/edpaget/events{/privacy}","received_events_url":"https://api.github.com/users/edpaget/received_events","type":"User","site_admin":false},"body":"**THIS IS DEFINITELY NOT READY YET** I've still got to a bunch of testing and need to revise the rest of the existing code to use this. This will close #69, when it's merged. \r\n\r\nI want to give a quick high-level overview about what I'm doing to get a bit of feedback, even if it's \"Ed this is crazy why would we ever do things that way\". I'll also add this to the wiki for documentation if we merge this. \r\n\r\nThis PR introduces two new namespaces `ControlControl` and `RoleControl` (they're separated so eventually I can turn them into gems). \r\n\r\n### `ControlControl`\r\nThis is a generic library for determining if one object is allowed to take actions on another object. It has two main modules `Resource` and `Actor`\r\n\r\nThe `Resource` Module defines the class method `can(action, filter=nil, &block)`, it defines an action type an actor object can take on a Resource (something abstract like `:edit` or `:read`), and then either takes a symbol name of an instance method, or a proc that is `instance_eval`ed. It also creates an instance method called `can_#{ action }?` that runs the supplied method or proc against an actor object to determine if it's allowed to take an action.\r\n\r\nYou can call `can` with the same action multiple times to build a chain of filters. An example resource class could look like. \r\n\r\n```ruby\r\nclass Thing\r\n  extends ControlControl::Resource\r\n\r\n  attr_accessible :favorite_thing\r\n\r\n  can :read, { |actor| actor.is_admin? }\r\n  can :read, :has_permissions\r\n  can :edit, :has_permissions\r\n\r\n  def has_permissions(actor)\r\n    actor.has_grail?\r\n  end\r\nend\r\n```\r\n\r\nThere is also a small extension of `Resource` called `ActAs` for objects that are both resources and actors that adds a `can_as` class method that acts similarly to `can`. It address #69. \r\n\r\nInstances of this class will have methods `can_read?` and `can_edit?`. `can_read?` will be true if the actor object `has_grail?` or `is_admin?`. \r\n\r\nThe `Actor` module is included adds a single method to a class it's included in. `do_to_resource(resource, action, as: nil, &block)`. This method simply tests the actor object against the `can_#{ action }?` method on the supplied resource object, and either raises a `ControlControl::AccessDenied` exception or executes the supplied proc with actor and resource as its arguments. \r\n\r\nAn example:\r\n\r\n```ruby\r\nClass ActingThing \r\n  include ControlControl::Actor\r\n\r\n  def has_grail?\r\n    true\r\n  end\r\nend\r\n\r\nthing = Thing.new\r\nat = ActingThing.new\r\n\r\nat.do_to_resource(thing, :edit) do |actor, resource|\r\n  resource.favorite_thing = actor\r\nend\r\n```\r\n\r\nIt also includes an optional keyword argument `as` that accepts another object that extends the `ActAs` method. When that argument is used the calling object is tested against the `can_#{ action }_as?` method of the `as` object. If that test sucess it, then does a `as_object.do_to_resource(resource, action, &block)` call. If that test fails, a `ControlControl::AccessDenied` exception is raise. \r\n\r\n### `RoleControl`\r\nRoleControl includes `Enrolled`, `Controlled`, and `VisibilityControlled` that sit on top of ControlControl and implement logic for accessing by user roles. \r\n\r\n`Enrolled` is implemented on top of Actor and adds a `roles_for(klass, association)` class method. It's used with the instance method `roles_query_for` that produces an ActiveRecord::Relation based on the supplied association for an instance of the given named class. \r\n\r\nI feel like that was a bad explanation so here's an example:\r\n\r\n```ruby\r\nclass User < ActiveRecord::Base\r\n  include ControlControl::Enrolled\r\n  has_many :user_project_preferences\r\n\r\n  roles_for Project, :user_project_preferences\r\nend\r\n\r\np = project.create!\r\np.save!\r\nu = User.create!\r\nu.save!\r\nUserProjectPreference.create(user: u, project: p, roles: ['collaborator']).save!\r\nu.roles_query_for(p) #=> ActiveRecord::Relation<[UserProjectPreference(project_id: 1, roles: ['collaborator'])]>\r\n```\r\n\r\n`Controlled` is implemented on top of `Resource` and adds a `can_by_role(action, *permitted_roles)` method. Basically you give a can action name as in `Resource` and then a list of permitted roles. When you call `can_#{ action }?` it compares the supplied roles against the result of `enrolled_actor.roles_query_for(controlled_resource)`. \r\n\r\nExample: (assuming the above User is still defined).\r\n\r\n```ruby\r\nclass Project < ActiveRecord::Base\r\n  extend RoleControl::Controlled\r\n\r\n  can_by_role :edit, :collaborator, :translator\r\nend\r\n\r\np = Project.new(name: \"blad!\")\r\np.save!\r\nu = User.new(name: \"grrr!\")\r\nu.save!\r\nupp = UserProjectPreferences.new(user: u, project: p, roles: ['collaborator'])\r\nupp.save!\r\n\r\nu.do_to_resource(p, :edit) do |user, project|\r\n  project.favorite_user = u\r\n  project.save!\r\nend\r\n```\r\n\r\n`VisibilityControlled` is a further extenion of `Controlled` that allows the permitted roles to be serialized to the Database instead of declared in code. Right now is only implemented as in a `can_read?` predicate, and a `visible_to` class_method that returns a scope of objects that are visible to an actor. \r\n\r\nThis does change how visibility is handled from how it was previously. Instead of having a visibility state, we're instead going to let users decide what roles a project will be visible to at a given time. (The state bit could still be faked in the front end if we want to keep similar functionality). \r\n\r\nLet me know if there any other questions about how stuff works in this. I know there are still a few more TODOs before this is ready to merge. Off the top of my head I can think of:\r\n\r\n- [x] Specs for `RoleControl` and `ControlControl`\r\n- [x] Conditional inclusion of ownership test in `VisibilityControl\r\n- [x] Conversion of Controllers to use this.\r\n- [x] Constrain and Validate the names of assignable roles. \r\n- [x] Override for Admin Users\r\n- [ ] RSpec Helpers for Testing Correct Scopes\r\n\r\nAnd I'm sure there will be more. Also apologies for the numerous spelling errors that are undoubtedly in here. ","created_at":"2014-08-01T15:59:21Z","updated_at":"2014-08-20T18:45:38Z","closed_at":null,"merged_at":null,"merge_commit_sha":"7b478527d1f3f499a990dfeb2b38713a5e903b3d","assignee":null,"milestone":null,"commits_url":"https://api.github.com/repos/zooniverse/Panoptes/pulls/132/commits","review_comments_url":"https://api.github.com/repos/zooniverse/Panoptes/pulls/132/comments","review_comment_url":"https://api.github.com/repos/zooniverse/Panoptes/pulls/comments/{number}","comments_url":"https://api.github.com/repos/zooniverse/Panoptes/issues/132/comments","statuses_url":"https://api.github.com/repos/zooniverse/Panoptes/statuses/ef39b11aa513c7a3d07d0c12d663d96cef57ed51","head":{"label":"edpaget:control-control","ref":"control-control","sha":"ef39b11aa513c7a3d07d0c12d663d96cef57ed51","user":{"login":"edpaget","id":108177,"avatar_url":"https://avatars.githubusercontent.com/u/108177?v=2","gravatar_id":"ba805c0cdc67dfa2f91fa9c4a987e42c","url":"https://api.github.com/users/edpaget","html_url":"https://github.com/edpaget","followers_url":"https://api.github.com/users/edpaget/followers","following_url":"https://api.github.com/users/edpaget/following{/other_user}","gists_url":"https://api.github.com/users/edpaget/gists{/gist_id}","starred_url":"https://api.github.com/users/edpaget/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/edpaget/subscriptions","organizations_url":"https://api.github.com/users/edpaget/orgs","repos_url":"https://api.github.com/users/edpaget/repos","events_url":"https://api.github.com/users/edpaget/events{/privacy}","received_events_url":"https://api.github.com/users/edpaget/received_events","type":"User","site_admin":false},"repo":{"id":20313194,"name":"Panoptes","full_name":"edpaget/Panoptes","owner":{"login":"edpaget","id":108177,"avatar_url":"https://avatars.githubusercontent.com/u/108177?v=2","gravatar_id":"ba805c0cdc67dfa2f91fa9c4a987e42c","url":"https://api.github.com/users/edpaget","html_url":"https://github.com/edpaget","followers_url":"https://api.github.com/users/edpaget/followers","following_url":"https://api.github.com/users/edpaget/following{/other_user}","gists_url":"https://api.github.com/users/edpaget/gists{/gist_id}","starred_url":"https://api.github.com/users/edpaget/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/edpaget/subscriptions","organizations_url":"https://api.github.com/users/edpaget/orgs","repos_url":"https://api.github.com/users/edpaget/repos","events_url":"https://api.github.com/users/edpaget/events{/privacy}","received_events_url":"https://api.github.com/users/edpaget/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/edpaget/Panoptes","description":"It sees everything","fork":true,"url":"https://api.github.com/repos/edpaget/Panoptes","forks_url":"https://api.github.com/repos/edpaget/Panoptes/forks","keys_url":"https://api.github.com/repos/edpaget/Panoptes/keys{/key_id}","collaborators_url":"https://api.github.com/repos/edpaget/Panoptes/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/edpaget/Panoptes/teams","hooks_url":"https://api.github.com/repos/edpaget/Panoptes/hooks","issue_events_url":"https://api.github.com/repos/edpaget/Panoptes/issues/events{/number}","events_url":"https://api.github.com/repos/edpaget/Panoptes/events","assignees_url":"https://api.github.com/repos/edpaget/Panoptes/assignees{/user}","branches_url":"https://api.github.com/repos/edpaget/Panoptes/branches{/branch}","tags_url":"https://api.github.com/repos/edpaget/Panoptes/tags","blobs_url":"https://api.github.com/repos/edpaget/Panoptes/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/edpaget/Panoptes/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/edpaget/Panoptes/git/refs{/sha}","trees_url":"https://api.github.com/repos/edpaget/Panoptes/git/trees{/sha}","statuses_url":"https://api.github.com/repos/edpaget/Panoptes/statuses/{sha}","languages_url":"https://api.github.com/repos/edpaget/Panoptes/languages","stargazers_url":"https://api.github.com/repos/edpaget/Panoptes/stargazers","contributors_url":"https://api.github.com/repos/edpaget/Panoptes/contributors","subscribers_url":"https://api.github.com/repos/edpaget/Panoptes/subscribers","subscription_url":"https://api.github.com/repos/edpaget/Panoptes/subscription","commits_url":"https://api.github.com/repos/edpaget/Panoptes/commits{/sha}","git_commits_url":"https://api.github.com/repos/edpaget/Panoptes/git/commits{/sha}","comments_url":"https://api.github.com/repos/edpaget/Panoptes/comments{/number}","issue_comment_url":"https://api.github.com/repos/edpaget/Panoptes/issues/comments/{number}","contents_url":"https://api.github.com/repos/edpaget/Panoptes/contents/{+path}","compare_url":"https://api.github.com/repos/edpaget/Panoptes/compare/{base}...{head}","merges_url":"https://api.github.com/repos/edpaget/Panoptes/merges","archive_url":"https://api.github.com/repos/edpaget/Panoptes/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/edpaget/Panoptes/downloads","issues_url":"https://api.github.com/repos/edpaget/Panoptes/issues{/number}","pulls_url":"https://api.github.com/repos/edpaget/Panoptes/pulls{/number}","milestones_url":"https://api.github.com/repos/edpaget/Panoptes/milestones{/number}","notifications_url":"https://api.github.com/repos/edpaget/Panoptes/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/edpaget/Panoptes/labels{/name}","releases_url":"https://api.github.com/repos/edpaget/Panoptes/releases{/id}","created_at":"2014-05-30T01:09:54Z","updated_at":"2014-06-25T16:11:06Z","pushed_at":"2014-08-20T15:57:59Z","git_url":"git://github.com/edpaget/Panoptes.git","ssh_url":"git@github.com:edpaget/Panoptes.git","clone_url":"https://github.com/edpaget/Panoptes.git","svn_url":"https://github.com/edpaget/Panoptes","homepage":"","size":3178,"stargazers_count":0,"watchers_count":0,"language":"Ruby","has_issues":false,"has_downloads":true,"has_wiki":true,"forks_count":0,"mirror_url":null,"open_issues_count":0,"forks":0,"open_issues":0,"watchers":0,"default_branch":"master"}},"base":{"label":"zooniverse:master","ref":"master","sha":"6cd7a12aae64fb9bb104b9a8630fd05f7e044293","user":{"login":"zooniverse","id":106856,"avatar_url":"https://avatars.githubusercontent.com/u/106856?v=2","gravatar_id":"b243d889b909865f13853bdcfd46f538","url":"https://api.github.com/users/zooniverse","html_url":"https://github.com/zooniverse","followers_url":"https://api.github.com/users/zooniverse/followers","following_url":"https://api.github.com/users/zooniverse/following{/other_user}","gists_url":"https://api.github.com/users/zooniverse/gists{/gist_id}","starred_url":"https://api.github.com/users/zooniverse/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/zooniverse/subscriptions","organizations_url":"https://api.github.com/users/zooniverse/orgs","repos_url":"https://api.github.com/users/zooniverse/repos","events_url":"https://api.github.com/users/zooniverse/events{/privacy}","received_events_url":"https://api.github.com/users/zooniverse/received_events","type":"Organization","site_admin":false},"repo":{"id":19036577,"name":"Panoptes","full_name":"zooniverse/Panoptes","owner":{"login":"zooniverse","id":106856,"avatar_url":"https://avatars.githubusercontent.com/u/106856?v=2","gravatar_id":"b243d889b909865f13853bdcfd46f538","url":"https://api.github.com/users/zooniverse","html_url":"https://github.com/zooniverse","followers_url":"https://api.github.com/users/zooniverse/followers","following_url":"https://api.github.com/users/zooniverse/following{/other_user}","gists_url":"https://api.github.com/users/zooniverse/gists{/gist_id}","starred_url":"https://api.github.com/users/zooniverse/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/zooniverse/subscriptions","organizations_url":"https://api.github.com/users/zooniverse/orgs","repos_url":"https://api.github.com/users/zooniverse/repos","events_url":"https://api.github.com/users/zooniverse/events{/privacy}","received_events_url":"https://api.github.com/users/zooniverse/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/zooniverse/Panoptes","description":"It sees everything","fork":false,"url":"https://api.github.com/repos/zooniverse/Panoptes","forks_url":"https://api.github.com/repos/zooniverse/Panoptes/forks","keys_url":"https://api.github.com/repos/zooniverse/Panoptes/keys{/key_id}","collaborators_url":"https://api.github.com/repos/zooniverse/Panoptes/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/zooniverse/Panoptes/teams","hooks_url":"https://api.github.com/repos/zooniverse/Panoptes/hooks","issue_events_url":"https://api.github.com/repos/zooniverse/Panoptes/issues/events{/number}","events_url":"https://api.github.com/repos/zooniverse/Panoptes/events","assignees_url":"https://api.github.com/repos/zooniverse/Panoptes/assignees{/user}","branches_url":"https://api.github.com/repos/zooniverse/Panoptes/branches{/branch}","tags_url":"https://api.github.com/repos/zooniverse/Panoptes/tags","blobs_url":"https://api.github.com/repos/zooniverse/Panoptes/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/zooniverse/Panoptes/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/zooniverse/Panoptes/git/refs{/sha}","trees_url":"https://api.github.com/repos/zooniverse/Panoptes/git/trees{/sha}","statuses_url":"https://api.github.com/repos/zooniverse/Panoptes/statuses/{sha}","languages_url":"https://api.github.com/repos/zooniverse/Panoptes/languages","stargazers_url":"https://api.github.com/repos/zooniverse/Panoptes/stargazers","contributors_url":"https://api.github.com/repos/zooniverse/Panoptes/contributors","subscribers_url":"https://api.github.com/repos/zooniverse/Panoptes/subscribers","subscription_url":"https://api.github.com/repos/zooniverse/Panoptes/subscription","commits_url":"https://api.github.com/repos/zooniverse/Panoptes/commits{/sha}","git_commits_url":"https://api.github.com/repos/zooniverse/Panoptes/git/commits{/sha}","comments_url":"https://api.github.com/repos/zooniverse/Panoptes/comments{/number}","issue_comment_url":"https://api.github.com/repos/zooniverse/Panoptes/issues/comments/{number}","contents_url":"https://api.github.com/repos/zooniverse/Panoptes/contents/{+path}","compare_url":"https://api.github.com/repos/zooniverse/Panoptes/compare/{base}...{head}","merges_url":"https://api.github.com/repos/zooniverse/Panoptes/merges","archive_url":"https://api.github.com/repos/zooniverse/Panoptes/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/zooniverse/Panoptes/downloads","issues_url":"https://api.github.com/repos/zooniverse/Panoptes/issues{/number}","pulls_url":"https://api.github.com/repos/zooniverse/Panoptes/pulls{/number}","milestones_url":"https://api.github.com/repos/zooniverse/Panoptes/milestones{/number}","notifications_url":"https://api.github.com/repos/zooniverse/Panoptes/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/zooniverse/Panoptes/labels{/name}","releases_url":"https://api.github.com/repos/zooniverse/Panoptes/releases{/id}","created_at":"2014-04-22T16:16:02Z","updated_at":"2014-07-25T13:59:18Z","pushed_at":"2014-08-06T13:51:40Z","git_url":"git://github.com/zooniverse/Panoptes.git","ssh_url":"git@github.com:zooniverse/Panoptes.git","clone_url":"https://github.com/zooniverse/Panoptes.git","svn_url":"https://github.com/zooniverse/Panoptes","homepage":"","size":4404,"stargazers_count":2,"watchers_count":2,"language":"Ruby","has_issues":true,"has_downloads":true,"has_wiki":true,"forks_count":5,"mirror_url":null,"open_issues_count":15,"forks":5,"open_issues":15,"watchers":2,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/zooniverse/Panoptes/pulls/132"},"html":{"href":"https://github.com/zooniverse/Panoptes/pull/132"},"issue":{"href":"https://api.github.com/repos/zooniverse/Panoptes/issues/132"},"comments":{"href":"https://api.github.com/repos/zooniverse/Panoptes/issues/132/comments"},"review_comments":{"href":"https://api.github.com/repos/zooniverse/Panoptes/pulls/132/comments"},"review_comment":{"href":"https://api.github.com/repos/zooniverse/Panoptes/pulls/comments/{number}"},"commits":{"href":"https://api.github.com/repos/zooniverse/Panoptes/pulls/132/commits"},"statuses":{"href":"https://api.github.com/repos/zooniverse/Panoptes/statuses/ef39b11aa513c7a3d07d0c12d663d96cef57ed51"}}}}