vcssync: use betamax for testing GitHub API interactions (bug 1358312); r?glob draft
authorGregory Szorc <gps@mozilla.com>
Thu, 20 Apr 2017 15:20:53 -0700
changeset 10838 7329f79c67b36e28f17cee1bdcd88b44d8b9c8ad
parent 10837 8d63b66f93ce1825776535dc09b628c787eac3bb
child 10839 cf7f78cd0ad028ded390e211215b212064291802
push id1636
push userbmo:gps@mozilla.com
push dateFri, 21 Apr 2017 00:31:50 +0000
reviewersglob
bugs1358312
vcssync: use betamax for testing GitHub API interactions (bug 1358312); r?glob The betamax Python package collaborates with the requests Python package to record HTTP interactions so they can later be reused. Essentially, you define a directory containing "cassettes," which are JSON files holding recorded HTTP requests and responses. Using Betamax, you can attach a cassette to a requests.Session. Depending on how Betamax is configured, HTTP requests will actually be made and saved to the cassette, will be immediately "served" from that cassette, or will be rejected outright. I see a number of compelling reasons to use Betamax over other approaches. This is especially true when you can't host the service you are talking to (such as the case with the GitHub API). This commit establishes a framework for using Betamax to test GitHub API interactions in the vcssync tests. The functionality is explained more in the added documentation. MozReview-Commit-ID: 1wYcKpSo1Eq
docs/vcssync/development.rst
vcssync/mozvcssync/util.py
vcssync/test-requirements.txt
vcssync/tests/cassettes/linearize-github-pull-request-messages.json
vcssync/tests/helpers.sh
vcssync/tests/record_cassettes.py
vcssync/tests/test-linearize-github-pull-request-messages.t
--- a/docs/vcssync/development.rst
+++ b/docs/vcssync/development.rst
@@ -17,8 +17,34 @@ Then activate the environment in your sh
    $ source activate venv/vcssync/bin/activate
 
 Running Tests
 =============
 
 To run the vcssync tests, run::
 
    $ ./run-tests -j4
+
+Using Betamax for HTTP Request Replaying
+========================================
+
+We use the `Betamax <http://betamax.readthedocs.io/>`_ Python package
+to facilitate testing HTTP requests against various services, such as
+the GitHub API.
+
+When the ``BETAMAX_LIBRARY_DIR`` and ``BETAMAX_CASSETTE`` environment
+variables are defined, Betamax is configured to use the cassette
+(recording of HTTP interactions) specified. Betamax's record mode is
+set to ``none``, which means that only HTTP interactions saved in the
+cassette are allowed.
+
+The ``vcssync/tests/record_cassettes.py`` script is used to record
+cassettes (read: perform actual interactions with real servers and
+save the results). Run this script from an activated virtualenv to
+create/update/re-record cassettes. Minor changes in the cassettes
+(such as dates and request IDs) are expected to change. Other things
+may change over time and changes should be scrutinized during review.
+
+``record_cassettes.py`` requires a GitHub API token. Go to
+https://github.com/settings/tokens/new to generate one. It should only
+need minimal privileges. While the cassettes shouldn't save your token,
+it is a good practice to delete the token once you're done recording
+cassettes.
--- a/vcssync/mozvcssync/util.py
+++ b/vcssync/mozvcssync/util.py
@@ -1,14 +1,15 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 from __future__ import absolute_import, unicode_literals
 
+import os
 import pipes
 
 import github3
 import hglib
 
 
 def run_hg(logger, client, args):
     """Run a Mercurial command through hgclient and log output."""
@@ -24,14 +25,39 @@ def run_hg(logger, client, args):
 
     if ret:
         raise hglib.error.CommandError(args, ret, out.getvalue(), b'')
 
     return out.getvalue()
 
 
 def get_github_client(token):
-    """Obtain a github3 client using an API token for authentication."""
+    """Obtain a github3 client using an API token for authentication.
+
+    If the ``BETAMAX_LIBRARY_DIR`` and ``BETAMAX_CASSETTE`` environment
+    variables are defined, the ``requests.Session`` used by the client
+    will be hooked up to betamax and pre-recorded HTTP requests will be used
+    instead of incurring actual requests. When betamax is active, the auth
+    token is not relevant.
+    """
 
     gh = github3.GitHub()
+
+    betamax_library_dir = os.environ.get('BETAMAX_LIBRARY_DIR')
+    betamax_cassette = os.environ.get('BETAMAX_CASSETTE')
+
+    if betamax_library_dir and betamax_cassette:
+        # Delay import because only needed for testing.
+        import betamax
+
+        with betamax.Betamax.configure() as config:
+            config.cassette_library_dir = betamax_library_dir
+
+            # We don't want requests hitting the network at all.
+            config.default_cassette_options['record_mode'] = 'none'
+
+        recorder = betamax.Betamax(gh._session)
+        recorder.use_cassette(betamax_cassette)
+        recorder.start()
+
     gh.login(token=token)
 
     return gh
--- a/vcssync/test-requirements.txt
+++ b/vcssync/test-requirements.txt
@@ -1,4 +1,10 @@
 -r prod-requirements.txt
 
+betamax==0.8.0 \
+    --hash=sha256:91d5f6ca6b03901df93f9c0c2ac7fe1a0ff557bd6f30124ec73cefae00fc14a2
+
+betamax-serializers==0.2.0 \
+    --hash=sha256:c7c4bd96f71d763debbec21e09bea932613cf651c2055514e6fd889358a46e3e
+
 coverage==3.7.1 \
     --hash=sha256:d1aea1c4aa61b8366d6a42dd3650622fbf9c634ed24eaf7f379c8b970e5ed44e
new file mode 100644
--- /dev/null
+++ b/vcssync/tests/cassettes/linearize-github-pull-request-messages.json
@@ -0,0 +1,176 @@
+{
+  "http_interactions": [
+    {
+      "recorded_at": "2017-04-21T00:25:38",
+      "request": {
+        "body": {
+          "encoding": "utf-8",
+          "string": ""
+        },
+        "headers": {
+          "Accept": "application/vnd.github.v3.full+json",
+          "Accept-Charset": "utf-8",
+          "Accept-Encoding": "identity",
+          "Authorization": "token <AUTH_TOKEN>",
+          "Connection": "keep-alive",
+          "Content-Type": "application/json",
+          "User-Agent": "github3.py/0.9.6"
+        },
+        "method": "GET",
+        "uri": "https://api.github.com/repos/servo/servo"
+      },
+      "response": {
+        "body": {
+          "encoding": "utf-8",
+          "string": "{\"id\":3390243,\"name\":\"servo\",\"full_name\":\"servo/servo\",\"owner\":{\"login\":\"servo\",\"id\":2566135,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/2566135?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/servo\",\"html_url\":\"https://github.com/servo\",\"followers_url\":\"https://api.github.com/users/servo/followers\",\"following_url\":\"https://api.github.com/users/servo/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/servo/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/servo/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/servo/subscriptions\",\"organizations_url\":\"https://api.github.com/users/servo/orgs\",\"repos_url\":\"https://api.github.com/users/servo/repos\",\"events_url\":\"https://api.github.com/users/servo/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/servo/received_events\",\"type\":\"Organization\",\"site_admin\":false},\"private\":false,\"html_url\":\"https://github.com/servo/servo\",\"description\":\"The Servo Browser Engine\",\"fork\":false,\"url\":\"https://api.github.com/repos/servo/servo\",\"forks_url\":\"https://api.github.com/repos/servo/servo/forks\",\"keys_url\":\"https://api.github.com/repos/servo/servo/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/servo/servo/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/servo/servo/teams\",\"hooks_url\":\"https://api.github.com/repos/servo/servo/hooks\",\"issue_events_url\":\"https://api.github.com/repos/servo/servo/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/servo/servo/events\",\"assignees_url\":\"https://api.github.com/repos/servo/servo/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/servo/servo/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/servo/servo/tags\",\"blobs_url\":\"https://api.github.com/repos/servo/servo/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/servo/servo/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/servo/servo/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/servo/servo/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/servo/servo/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/servo/servo/languages\",\"stargazers_url\":\"https://api.github.com/repos/servo/servo/stargazers\",\"contributors_url\":\"https://api.github.com/repos/servo/servo/contributors\",\"subscribers_url\":\"https://api.github.com/repos/servo/servo/subscribers\",\"subscription_url\":\"https://api.github.com/repos/servo/servo/subscription\",\"commits_url\":\"https://api.github.com/repos/servo/servo/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/servo/servo/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/servo/servo/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/servo/servo/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/servo/servo/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/servo/servo/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/servo/servo/merges\",\"archive_url\":\"https://api.github.com/repos/servo/servo/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/servo/servo/downloads\",\"issues_url\":\"https://api.github.com/repos/servo/servo/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/servo/servo/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/servo/servo/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/servo/servo/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/servo/servo/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/servo/servo/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/servo/servo/deployments\",\"created_at\":\"2012-02-08T19:07:25Z\",\"updated_at\":\"2017-04-20T20:48:47Z\",\"pushed_at\":\"2017-04-21T00:20:30Z\",\"git_url\":\"git://github.com/servo/servo.git\",\"ssh_url\":\"git@github.com:servo/servo.git\",\"clone_url\":\"https://github.com/servo/servo.git\",\"svn_url\":\"https://github.com/servo/servo\",\"homepage\":\"https://servo.org/\",\"size\":326890,\"stargazers_count\":9259,\"watchers_count\":9259,\"language\":null,\"has_issues\":true,\"has_projects\":false,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"forks_count\":1586,\"mirror_url\":null,\"open_issues_count\":2162,\"forks\":1586,\"open_issues\":2162,\"watchers\":9259,\"default_branch\":\"master\",\"permissions\":{\"admin\":false,\"push\":false,\"pull\":true},\"organization\":{\"login\":\"servo\",\"id\":2566135,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/2566135?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/servo\",\"html_url\":\"https://github.com/servo\",\"followers_url\":\"https://api.github.com/users/servo/followers\",\"following_url\":\"https://api.github.com/users/servo/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/servo/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/servo/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/servo/subscriptions\",\"organizations_url\":\"https://api.github.com/users/servo/orgs\",\"repos_url\":\"https://api.github.com/users/servo/repos\",\"events_url\":\"https://api.github.com/users/servo/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/servo/received_events\",\"type\":\"Organization\",\"site_admin\":false},\"network_count\":1586,\"subscribers_count\":433}"
+        },
+        "headers": {
+          "Access-Control-Allow-Origin": "*",
+          "Access-Control-Expose-Headers": "ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval",
+          "Cache-Control": "private, max-age=60, s-maxage=60",
+          "Content-Length": "5306",
+          "Content-Security-Policy": "default-src 'none'",
+          "Content-Type": "application/json; charset=utf-8",
+          "Date": "Fri, 21 Apr 2017 00:25:38 GMT",
+          "ETag": "\"c3927170223bdefbeec7f41d82fe694c\"",
+          "Last-Modified": "Thu, 20 Apr 2017 20:48:47 GMT",
+          "Server": "GitHub.com",
+          "Status": "200 OK",
+          "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload",
+          "Vary": "Accept, Authorization, Cookie, X-GitHub-OTP",
+          "X-Accepted-OAuth-Scopes": "repo",
+          "X-Content-Type-Options": "nosniff",
+          "X-Frame-Options": "deny",
+          "X-GitHub-Media-Type": "github.v3; param=full; format=json",
+          "X-GitHub-Request-Id": "9B1D:1A40:978937:BD7830:58F95182",
+          "X-OAuth-Scopes": "",
+          "X-RateLimit-Limit": "5000",
+          "X-RateLimit-Remaining": "4999",
+          "X-RateLimit-Reset": "1492737938",
+          "X-Served-By": "49aa99f015c25437a7443c4d3a58cd17",
+          "X-XSS-Protection": "1; mode=block"
+        },
+        "status": {
+          "code": 200,
+          "message": "OK"
+        },
+        "url": "https://api.github.com/repos/servo/servo"
+      }
+    },
+    {
+      "recorded_at": "2017-04-21T00:25:38",
+      "request": {
+        "body": {
+          "encoding": "utf-8",
+          "string": ""
+        },
+        "headers": {
+          "Accept": "application/vnd.github.v3.full+json",
+          "Accept-Charset": "utf-8",
+          "Accept-Encoding": "identity",
+          "Authorization": "token <AUTH_TOKEN>",
+          "Connection": "keep-alive",
+          "Content-Type": "application/json",
+          "User-Agent": "github3.py/0.9.6"
+        },
+        "method": "GET",
+        "uri": "https://api.github.com/repos/servo/servo/pulls/16549"
+      },
+      "response": {
+        "body": {
+          "encoding": "utf-8",
+          "string": "{\"url\":\"https://api.github.com/repos/servo/servo/pulls/16549\",\"id\":116863705,\"html_url\":\"https://github.com/servo/servo/pull/16549\",\"diff_url\":\"https://github.com/servo/servo/pull/16549.diff\",\"patch_url\":\"https://github.com/servo/servo/pull/16549.patch\",\"issue_url\":\"https://api.github.com/repos/servo/servo/issues/16549\",\"number\":16549,\"state\":\"closed\",\"locked\":false,\"title\":\"store simple selectors and combinators inline\",\"user\":{\"login\":\"bholley\",\"id\":377435,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/377435?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/bholley\",\"html_url\":\"https://github.com/bholley\",\"followers_url\":\"https://api.github.com/users/bholley/followers\",\"following_url\":\"https://api.github.com/users/bholley/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/bholley/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/bholley/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/bholley/subscriptions\",\"organizations_url\":\"https://api.github.com/users/bholley/orgs\",\"repos_url\":\"https://api.github.com/users/bholley/repos\",\"events_url\":\"https://api.github.com/users/bholley/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/bholley/received_events\",\"type\":\"User\",\"site_admin\":false},\"body\":\"https://bugzilla.mozilla.org/show_bug.cgi?id=1357973\\n\\n<!-- Reviewable:start -->\\n---\\nThis change is\u2002[<img src=\\\"https://reviewable.io/review_button.svg\\\" height=\\\"34\\\" align=\\\"absmiddle\\\" alt=\\\"Reviewable\\\"/>](https://reviewable.io/reviews/servo/servo/16549)\\n<!-- Reviewable:end -->\\n\",\"created_at\":\"2017-04-20T21:01:53Z\",\"updated_at\":\"2017-04-20T22:42:24Z\",\"closed_at\":\"2017-04-20T22:42:23Z\",\"merged_at\":\"2017-04-20T22:42:23Z\",\"merge_commit_sha\":\"fe97033aa877f85e1ad2438245861a4425977e9b\",\"assignee\":{\"login\":\"emilio\",\"id\":1323194,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/1323194?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/emilio\",\"html_url\":\"https://github.com/emilio\",\"followers_url\":\"https://api.github.com/users/emilio/followers\",\"following_url\":\"https://api.github.com/users/emilio/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/emilio/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/emilio/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/emilio/subscriptions\",\"organizations_url\":\"https://api.github.com/users/emilio/orgs\",\"repos_url\":\"https://api.github.com/users/emilio/repos\",\"events_url\":\"https://api.github.com/users/emilio/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/emilio/received_events\",\"type\":\"User\",\"site_admin\":false},\"assignees\":[{\"login\":\"emilio\",\"id\":1323194,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/1323194?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/emilio\",\"html_url\":\"https://github.com/emilio\",\"followers_url\":\"https://api.github.com/users/emilio/followers\",\"following_url\":\"https://api.github.com/users/emilio/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/emilio/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/emilio/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/emilio/subscriptions\",\"organizations_url\":\"https://api.github.com/users/emilio/orgs\",\"repos_url\":\"https://api.github.com/users/emilio/repos\",\"events_url\":\"https://api.github.com/users/emilio/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/emilio/received_events\",\"type\":\"User\",\"site_admin\":false}],\"milestone\":null,\"commits_url\":\"https://api.github.com/repos/servo/servo/pulls/16549/commits\",\"review_comments_url\":\"https://api.github.com/repos/servo/servo/pulls/16549/comments\",\"review_comment_url\":\"https://api.github.com/repos/servo/servo/pulls/comments{/number}\",\"comments_url\":\"https://api.github.com/repos/servo/servo/issues/16549/comments\",\"statuses_url\":\"https://api.github.com/repos/servo/servo/statuses/fe97033aa877f85e1ad2438245861a4425977e9b\",\"head\":{\"label\":\"bholley:inline_selectors\",\"ref\":\"inline_selectors\",\"sha\":\"fe97033aa877f85e1ad2438245861a4425977e9b\",\"user\":{\"login\":\"bholley\",\"id\":377435,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/377435?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/bholley\",\"html_url\":\"https://github.com/bholley\",\"followers_url\":\"https://api.github.com/users/bholley/followers\",\"following_url\":\"https://api.github.com/users/bholley/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/bholley/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/bholley/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/bholley/subscriptions\",\"organizations_url\":\"https://api.github.com/users/bholley/orgs\",\"repos_url\":\"https://api.github.com/users/bholley/repos\",\"events_url\":\"https://api.github.com/users/bholley/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/bholley/received_events\",\"type\":\"User\",\"site_admin\":false},\"repo\":{\"id\":13421111,\"name\":\"servo\",\"full_name\":\"bholley/servo\",\"owner\":{\"login\":\"bholley\",\"id\":377435,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/377435?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/bholley\",\"html_url\":\"https://github.com/bholley\",\"followers_url\":\"https://api.github.com/users/bholley/followers\",\"following_url\":\"https://api.github.com/users/bholley/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/bholley/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/bholley/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/bholley/subscriptions\",\"organizations_url\":\"https://api.github.com/users/bholley/orgs\",\"repos_url\":\"https://api.github.com/users/bholley/repos\",\"events_url\":\"https://api.github.com/users/bholley/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/bholley/received_events\",\"type\":\"User\",\"site_admin\":false},\"private\":false,\"html_url\":\"https://github.com/bholley/servo\",\"description\":\"The Servo Browser Engine\",\"fork\":true,\"url\":\"https://api.github.com/repos/bholley/servo\",\"forks_url\":\"https://api.github.com/repos/bholley/servo/forks\",\"keys_url\":\"https://api.github.com/repos/bholley/servo/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/bholley/servo/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/bholley/servo/teams\",\"hooks_url\":\"https://api.github.com/repos/bholley/servo/hooks\",\"issue_events_url\":\"https://api.github.com/repos/bholley/servo/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/bholley/servo/events\",\"assignees_url\":\"https://api.github.com/repos/bholley/servo/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/bholley/servo/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/bholley/servo/tags\",\"blobs_url\":\"https://api.github.com/repos/bholley/servo/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/bholley/servo/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/bholley/servo/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/bholley/servo/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/bholley/servo/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/bholley/servo/languages\",\"stargazers_url\":\"https://api.github.com/repos/bholley/servo/stargazers\",\"contributors_url\":\"https://api.github.com/repos/bholley/servo/contributors\",\"subscribers_url\":\"https://api.github.com/repos/bholley/servo/subscribers\",\"subscription_url\":\"https://api.github.com/repos/bholley/servo/subscription\",\"commits_url\":\"https://api.github.com/repos/bholley/servo/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/bholley/servo/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/bholley/servo/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/bholley/servo/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/bholley/servo/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/bholley/servo/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/bholley/servo/merges\",\"archive_url\":\"https://api.github.com/repos/bholley/servo/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/bholley/servo/downloads\",\"issues_url\":\"https://api.github.com/repos/bholley/servo/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/bholley/servo/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/bholley/servo/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/bholley/servo/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/bholley/servo/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/bholley/servo/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/bholley/servo/deployments\",\"created_at\":\"2013-10-08T17:55:47Z\",\"updated_at\":\"2015-10-09T02:34:11Z\",\"pushed_at\":\"2017-04-20T22:05:06Z\",\"git_url\":\"git://github.com/bholley/servo.git\",\"ssh_url\":\"git@github.com:bholley/servo.git\",\"clone_url\":\"https://github.com/bholley/servo.git\",\"svn_url\":\"https://github.com/bholley/servo\",\"homepage\":\"\",\"size\":249850,\"stargazers_count\":0,\"watchers_count\":0,\"language\":null,\"has_issues\":false,\"has_projects\":true,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"forks_count\":0,\"mirror_url\":null,\"open_issues_count\":0,\"forks\":0,\"open_issues\":0,\"watchers\":0,\"default_branch\":\"master\"}},\"base\":{\"label\":\"servo:master\",\"ref\":\"master\",\"sha\":\"93fa0ae1e3bcfe9e70a6fea91d137f20d8b5f790\",\"user\":{\"login\":\"servo\",\"id\":2566135,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/2566135?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/servo\",\"html_url\":\"https://github.com/servo\",\"followers_url\":\"https://api.github.com/users/servo/followers\",\"following_url\":\"https://api.github.com/users/servo/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/servo/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/servo/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/servo/subscriptions\",\"organizations_url\":\"https://api.github.com/users/servo/orgs\",\"repos_url\":\"https://api.github.com/users/servo/repos\",\"events_url\":\"https://api.github.com/users/servo/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/servo/received_events\",\"type\":\"Organization\",\"site_admin\":false},\"repo\":{\"id\":3390243,\"name\":\"servo\",\"full_name\":\"servo/servo\",\"owner\":{\"login\":\"servo\",\"id\":2566135,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/2566135?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/servo\",\"html_url\":\"https://github.com/servo\",\"followers_url\":\"https://api.github.com/users/servo/followers\",\"following_url\":\"https://api.github.com/users/servo/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/servo/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/servo/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/servo/subscriptions\",\"organizations_url\":\"https://api.github.com/users/servo/orgs\",\"repos_url\":\"https://api.github.com/users/servo/repos\",\"events_url\":\"https://api.github.com/users/servo/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/servo/received_events\",\"type\":\"Organization\",\"site_admin\":false},\"private\":false,\"html_url\":\"https://github.com/servo/servo\",\"description\":\"The Servo Browser Engine\",\"fork\":false,\"url\":\"https://api.github.com/repos/servo/servo\",\"forks_url\":\"https://api.github.com/repos/servo/servo/forks\",\"keys_url\":\"https://api.github.com/repos/servo/servo/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/servo/servo/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/servo/servo/teams\",\"hooks_url\":\"https://api.github.com/repos/servo/servo/hooks\",\"issue_events_url\":\"https://api.github.com/repos/servo/servo/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/servo/servo/events\",\"assignees_url\":\"https://api.github.com/repos/servo/servo/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/servo/servo/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/servo/servo/tags\",\"blobs_url\":\"https://api.github.com/repos/servo/servo/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/servo/servo/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/servo/servo/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/servo/servo/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/servo/servo/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/servo/servo/languages\",\"stargazers_url\":\"https://api.github.com/repos/servo/servo/stargazers\",\"contributors_url\":\"https://api.github.com/repos/servo/servo/contributors\",\"subscribers_url\":\"https://api.github.com/repos/servo/servo/subscribers\",\"subscription_url\":\"https://api.github.com/repos/servo/servo/subscription\",\"commits_url\":\"https://api.github.com/repos/servo/servo/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/servo/servo/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/servo/servo/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/servo/servo/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/servo/servo/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/servo/servo/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/servo/servo/merges\",\"archive_url\":\"https://api.github.com/repos/servo/servo/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/servo/servo/downloads\",\"issues_url\":\"https://api.github.com/repos/servo/servo/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/servo/servo/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/servo/servo/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/servo/servo/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/servo/servo/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/servo/servo/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/servo/servo/deployments\",\"created_at\":\"2012-02-08T19:07:25Z\",\"updated_at\":\"2017-04-20T20:48:47Z\",\"pushed_at\":\"2017-04-21T00:20:30Z\",\"git_url\":\"git://github.com/servo/servo.git\",\"ssh_url\":\"git@github.com:servo/servo.git\",\"clone_url\":\"https://github.com/servo/servo.git\",\"svn_url\":\"https://github.com/servo/servo\",\"homepage\":\"https://servo.org/\",\"size\":326890,\"stargazers_count\":9259,\"watchers_count\":9259,\"language\":null,\"has_issues\":true,\"has_projects\":false,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"forks_count\":1586,\"mirror_url\":null,\"open_issues_count\":2162,\"forks\":1586,\"open_issues\":2162,\"watchers\":9259,\"default_branch\":\"master\"}},\"_links\":{\"self\":{\"href\":\"https://api.github.com/repos/servo/servo/pulls/16549\"},\"html\":{\"href\":\"https://github.com/servo/servo/pull/16549\"},\"issue\":{\"href\":\"https://api.github.com/repos/servo/servo/issues/16549\"},\"comments\":{\"href\":\"https://api.github.com/repos/servo/servo/issues/16549/comments\"},\"review_comments\":{\"href\":\"https://api.github.com/repos/servo/servo/pulls/16549/comments\"},\"review_comment\":{\"href\":\"https://api.github.com/repos/servo/servo/pulls/comments{/number}\"},\"commits\":{\"href\":\"https://api.github.com/repos/servo/servo/pulls/16549/commits\"},\"statuses\":{\"href\":\"https://api.github.com/repos/servo/servo/statuses/fe97033aa877f85e1ad2438245861a4425977e9b\"}},\"body_html\":\"<p><a href=\\\"https://bugzilla.mozilla.org/show_bug.cgi?id=1357973\\\">https://bugzilla.mozilla.org/show_bug.cgi?id=1357973</a></p>\\n\\n<hr>\\n<p>This change is\u2002<a href=\\\"https://reviewable.io/reviews/servo/servo/16549\\\"><img src=\\\"https://camo.githubusercontent.com/0210135a140627a19496177795afadacb8502c1f/68747470733a2f2f72657669657761626c652e696f2f7265766965775f627574746f6e2e737667\\\" height=\\\"34\\\" align=\\\"absmiddle\\\" alt=\\\"Reviewable\\\" data-canonical-src=\\\"https://reviewable.io/review_button.svg\\\" style=\\\"max-width:100%;\\\"></a></p>\\n\",\"body_text\":\"https://bugzilla.mozilla.org/show_bug.cgi?id=1357973\\n\\n\\nThis change is\u2002\",\"merged\":true,\"mergeable\":null,\"rebaseable\":null,\"mergeable_state\":\"unknown\",\"merged_by\":{\"login\":\"bors-servo\",\"id\":4368172,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/4368172?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/bors-servo\",\"html_url\":\"https://github.com/bors-servo\",\"followers_url\":\"https://api.github.com/users/bors-servo/followers\",\"following_url\":\"https://api.github.com/users/bors-servo/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/bors-servo/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/bors-servo/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/bors-servo/subscriptions\",\"organizations_url\":\"https://api.github.com/users/bors-servo/orgs\",\"repos_url\":\"https://api.github.com/users/bors-servo/repos\",\"events_url\":\"https://api.github.com/users/bors-servo/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/bors-servo/received_events\",\"type\":\"User\",\"site_admin\":false},\"comments\":10,\"review_comments\":0,\"maintainer_can_modify\":false,\"commits\":4,\"additions\":863,\"deletions\":483,\"changed_files\":11}"
+        },
+        "headers": {
+          "Access-Control-Allow-Origin": "*",
+          "Access-Control-Expose-Headers": "ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval",
+          "Cache-Control": "private, max-age=60, s-maxage=60",
+          "Content-Length": "17114",
+          "Content-Security-Policy": "default-src 'none'",
+          "Content-Type": "application/json; charset=utf-8",
+          "Date": "Fri, 21 Apr 2017 00:25:38 GMT",
+          "ETag": "\"f3a2f5d574b2b99a2037c6266772d116\"",
+          "Last-Modified": "Thu, 20 Apr 2017 22:42:23 GMT",
+          "Server": "GitHub.com",
+          "Status": "200 OK",
+          "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload",
+          "Vary": "Accept, Authorization, Cookie, X-GitHub-OTP",
+          "X-Accepted-OAuth-Scopes": "",
+          "X-Content-Type-Options": "nosniff",
+          "X-Frame-Options": "deny",
+          "X-GitHub-Media-Type": "github.v3; param=full; format=json",
+          "X-GitHub-Request-Id": "9B1D:1A40:978942:BD7845:58F95182",
+          "X-OAuth-Scopes": "",
+          "X-RateLimit-Limit": "5000",
+          "X-RateLimit-Remaining": "4998",
+          "X-RateLimit-Reset": "1492737938",
+          "X-Served-By": "769054b6618fd34f3638f01469b229db",
+          "X-XSS-Protection": "1; mode=block"
+        },
+        "status": {
+          "code": 200,
+          "message": "OK"
+        },
+        "url": "https://api.github.com/repos/servo/servo/pulls/16549"
+      }
+    },
+    {
+      "recorded_at": "2017-04-21T00:25:39",
+      "request": {
+        "body": {
+          "encoding": "utf-8",
+          "string": ""
+        },
+        "headers": {
+          "Accept": "application/vnd.github.v3.full+json",
+          "Accept-Charset": "utf-8",
+          "Accept-Encoding": "identity",
+          "Authorization": "token <AUTH_TOKEN>",
+          "Connection": "keep-alive",
+          "Content-Type": "application/json",
+          "User-Agent": "github3.py/0.9.6"
+        },
+        "method": "GET",
+        "uri": "https://api.github.com/users/bholley"
+      },
+      "response": {
+        "body": {
+          "encoding": "utf-8",
+          "string": "{\"login\":\"bholley\",\"id\":377435,\"avatar_url\":\"https://avatars2.githubusercontent.com/u/377435?v=3\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/bholley\",\"html_url\":\"https://github.com/bholley\",\"followers_url\":\"https://api.github.com/users/bholley/followers\",\"following_url\":\"https://api.github.com/users/bholley/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/bholley/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/bholley/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/bholley/subscriptions\",\"organizations_url\":\"https://api.github.com/users/bholley/orgs\",\"repos_url\":\"https://api.github.com/users/bholley/repos\",\"events_url\":\"https://api.github.com/users/bholley/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/bholley/received_events\",\"type\":\"User\",\"site_admin\":false,\"name\":\"Bobby Holley\",\"company\":\"Mozilla\",\"blog\":\"http://bholley.net\",\"location\":null,\"email\":\"bobbyholley@gmail.com\",\"hireable\":null,\"bio\":null,\"public_repos\":22,\"public_gists\":1,\"followers\":40,\"following\":0,\"created_at\":\"2010-08-27T00:17:16Z\",\"updated_at\":\"2017-04-18T17:26:07Z\"}"
+        },
+        "headers": {
+          "Access-Control-Allow-Origin": "*",
+          "Access-Control-Expose-Headers": "ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval",
+          "Cache-Control": "private, max-age=60, s-maxage=60",
+          "Content-Length": "1140",
+          "Content-Security-Policy": "default-src 'none'",
+          "Content-Type": "application/json; charset=utf-8",
+          "Date": "Fri, 21 Apr 2017 00:25:39 GMT",
+          "ETag": "\"b52c199467ec7fa9b4f66e837e1d2ead\"",
+          "Last-Modified": "Tue, 18 Apr 2017 17:26:07 GMT",
+          "Server": "GitHub.com",
+          "Status": "200 OK",
+          "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload",
+          "Vary": "Accept, Authorization, Cookie, X-GitHub-OTP",
+          "X-Accepted-OAuth-Scopes": "",
+          "X-Content-Type-Options": "nosniff",
+          "X-Frame-Options": "deny",
+          "X-GitHub-Media-Type": "github.v3; param=full; format=json",
+          "X-GitHub-Request-Id": "9B1D:1A40:97895C:BD7857:58F95182",
+          "X-OAuth-Scopes": "",
+          "X-RateLimit-Limit": "5000",
+          "X-RateLimit-Remaining": "4997",
+          "X-RateLimit-Reset": "1492737938",
+          "X-Served-By": "5aeb3f30c9e3ef6ef7bcbcddfd9a68f7",
+          "X-XSS-Protection": "1; mode=block"
+        },
+        "status": {
+          "code": 200,
+          "message": "OK"
+        },
+        "url": "https://api.github.com/users/bholley"
+      }
+    }
+  ],
+  "recorded_with": "betamax/0.8.0"
+}
\ No newline at end of file
--- a/vcssync/tests/helpers.sh
+++ b/vcssync/tests/helpers.sh
@@ -7,16 +7,18 @@
 # make git commits deterministic and environment agnostic
 export GIT_AUTHOR_NAME=test
 export GIT_AUTHOR_EMAIL=test@example.com
 export GIT_AUTHOR_DATE='Thu Jan 1 00:00:00 1970 +0000'
 export GIT_COMMITTER_NAME=test
 export GIT_COMMITTER_EMAIL=test@example.com
 export GIT_COMMITTER_DATE='Thu Jan 1 00:00:00 1970 +0000'
 
+export BETAMAX_LIBRARY_DIR=$TESTDIR/vcssync/tests/cassettes
+
 standardgitrepo() {
     here=`pwd`
     git init $1
     cd $1
     echo 0 > foo
     git add foo
     git commit -m initial
     cat > file0 << EOF
new file mode 100755
--- /dev/null
+++ b/vcssync/tests/record_cassettes.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python2.7
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This script is used to record betamax cassettes for use in tests.
+
+from __future__ import absolute_import, unicode_literals
+
+import argparse
+import os
+
+import betamax
+import betamax_serializers.pretty_json as pretty_json
+import github3
+
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument('token', help='GitHub token')
+
+    args = parser.parse_args()
+
+    betamax.Betamax.register_serializer(pretty_json.PrettyJSONSerializer)
+
+    with betamax.Betamax.configure() as config:
+        config.cassette_library_dir = os.path.join(HERE, 'cassettes')
+
+        # Replace real API token with a placeholder so it isn't leaked.
+        config.define_cassette_placeholder(b'<AUTH_TOKEN>', args.token)
+
+        # Force recording of all requests all the time.
+        config.default_cassette_options['record_mode'] = 'all'
+
+        # Make saved cassette files readable.
+        config.default_cassette_options['serialize_with'] = 'prettyjson'
+
+    client = github3.GitHub()
+
+    # requests advertises gzip support by default (as it should!). However,
+    # This means that HTTP response bodies are base64 encoded in the cassette
+    # JSON, making them difficult to diff and audit for sensitive data. Since
+    # the encoding of the HTTP response body isn't important for our testing,
+    # disable it.
+    client._session.headers.update({'Accept-Encoding': 'identity'})
+
+    recorder = betamax.Betamax(client._session)
+
+    with recorder.use_cassette('linearize-github-pull-request-messages'):
+        # This test simply looks up a pull request and resolves more info about
+        # the user who created it.
+        client.login(token=args.token)
+        client.pull_request('servo', 'servo', 16549)
+        client.user('bholley')
+
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/vcssync/tests/test-linearize-github-pull-request-messages.t
@@ -0,0 +1,452 @@
+  $ . $TESTDIR/vcssync/tests/helpers.sh
+
+  $ export BETAMAX_CASSETTE=linearize-github-pull-request-messages
+
+  $ git init -q grepo
+  $ cd grepo
+  $ echo 0 > foo
+  $ git add foo
+  $ git commit -q -m initial
+
+Reference a pull request to trigger GitHub API fetching.
+The origin account is purposefully incorrect to prove that API data is used.
+
+  $ echo 1 > foo
+  $ cat > message << EOF
+  > Auto merge of #16549 - wrong_account:wrong_repo, r=emilio
+  > 
+  > First line after summary line.
+  > 
+  > https://bugzilla.mozilla.org/show_bug.cgi?id=1357973
+  > EOF
+
+  $ git commit -q --all -F message
+
+  $ linearize-git --normalize-github-merge-message --source-repo https://github.com/servo/servo --github-token dummy . heads/master
+  linearizing 2 commits from heads/master (dbd62b82aaf0a7a05665d9455a9b4d490d52ddaf to d477d7c32c063427971dff0fc479b44482c6988d)
+  1/2 dbd62b82aaf0a7a05665d9455a9b4d490d52ddaf initial
+  2/2 d477d7c32c063427971dff0fc479b44482c6988d Auto merge of #16549 - wrong_account:wrong_repo, r=emilio
+  2 commits from heads/master converted; original: d477d7c32c063427971dff0fc479b44482c6988d; rewritten: 64c20d6f09fb0c86c8472f6982616937166920e7
+
+  $ git log refs/convert/dest/heads/master
+  commit 64c20d6f09fb0c86c8472f6982616937166920e7
+  Author: test <test@example.com>
+  Date:   Thu Jan 1 00:00:00 1970 +0000
+  
+      Merge #16549 - store simple selectors and combinators inline (from bholley:inline_selectors); r=emilio
+      
+      First line after summary line.
+      
+      https://bugzilla.mozilla.org/show_bug.cgi?id=1357973
+  
+  commit dbd62b82aaf0a7a05665d9455a9b4d490d52ddaf
+  Author: test <test@example.com>
+  Date:   Thu Jan 1 00:00:00 1970 +0000
+  
+      initial
+
+GitHub API responses should be cached
+
+  $ cat github-cache/user-bholley.json
+  {
+    "avatar_url": "https://avatars2.githubusercontent.com/u/377435?v=3",
+    "bio": null,
+    "blog": "http://bholley.net",
+    "company": "Mozilla",
+    "created_at": "2010-08-27T00:17:16Z",
+    "email": "bobbyholley@gmail.com",
+    "events_url": "https://api.github.com/users/bholley/events{/privacy}",
+    "followers": 40,
+    "followers_url": "https://api.github.com/users/bholley/followers",
+    "following": 0,
+    "following_url": "https://api.github.com/users/bholley/following{/other_user}",
+    "gists_url": "https://api.github.com/users/bholley/gists{/gist_id}",
+    "gravatar_id": "",
+    "hireable": null,
+    "html_url": "https://github.com/bholley",
+    "id": 377435,
+    "location": null,
+    "login": "bholley",
+    "name": "Bobby Holley",
+    "organizations_url": "https://api.github.com/users/bholley/orgs",
+    "public_gists": 1,
+    "public_repos": 22,
+    "received_events_url": "https://api.github.com/users/bholley/received_events",
+    "repos_url": "https://api.github.com/users/bholley/repos",
+    "site_admin": false,
+    "starred_url": "https://api.github.com/users/bholley/starred{/owner}{/repo}",
+    "subscriptions_url": "https://api.github.com/users/bholley/subscriptions",
+    "type": "User",
+    "updated_at": "2017-04-18T17:26:07Z",
+    "url": "https://api.github.com/users/bholley"
+  } (no-eol)
+
+  $ cat github-cache/pr-16549.json
+  {
+    "_links": {
+      "comments": {
+        "href": "https://api.github.com/repos/servo/servo/issues/16549/comments"
+      },
+      "commits": {
+        "href": "https://api.github.com/repos/servo/servo/pulls/16549/commits"
+      },
+      "html": {
+        "href": "https://github.com/servo/servo/pull/16549"
+      },
+      "issue": {
+        "href": "https://api.github.com/repos/servo/servo/issues/16549"
+      },
+      "review_comment": {
+        "href": "https://api.github.com/repos/servo/servo/pulls/comments{/number}"
+      },
+      "review_comments": {
+        "href": "https://api.github.com/repos/servo/servo/pulls/16549/comments"
+      },
+      "self": {
+        "href": "https://api.github.com/repos/servo/servo/pulls/16549"
+      },
+      "statuses": {
+        "href": "https://api.github.com/repos/servo/servo/statuses/fe97033aa877f85e1ad2438245861a4425977e9b"
+      }
+    },
+    "additions": 863,
+    "assignee": {
+      "avatar_url": "https://avatars2.githubusercontent.com/u/1323194?v=3",
+      "events_url": "https://api.github.com/users/emilio/events{/privacy}",
+      "followers_url": "https://api.github.com/users/emilio/followers",
+      "following_url": "https://api.github.com/users/emilio/following{/other_user}",
+      "gists_url": "https://api.github.com/users/emilio/gists{/gist_id}",
+      "gravatar_id": "",
+      "html_url": "https://github.com/emilio",
+      "id": 1323194,
+      "login": "emilio",
+      "organizations_url": "https://api.github.com/users/emilio/orgs",
+      "received_events_url": "https://api.github.com/users/emilio/received_events",
+      "repos_url": "https://api.github.com/users/emilio/repos",
+      "site_admin": false,
+      "starred_url": "https://api.github.com/users/emilio/starred{/owner}{/repo}",
+      "subscriptions_url": "https://api.github.com/users/emilio/subscriptions",
+      "type": "User",
+      "url": "https://api.github.com/users/emilio"
+    },
+    "assignees": [
+      {
+        "avatar_url": "https://avatars2.githubusercontent.com/u/1323194?v=3",
+        "events_url": "https://api.github.com/users/emilio/events{/privacy}",
+        "followers_url": "https://api.github.com/users/emilio/followers",
+        "following_url": "https://api.github.com/users/emilio/following{/other_user}",
+        "gists_url": "https://api.github.com/users/emilio/gists{/gist_id}",
+        "gravatar_id": "",
+        "html_url": "https://github.com/emilio",
+        "id": 1323194,
+        "login": "emilio",
+        "organizations_url": "https://api.github.com/users/emilio/orgs",
+        "received_events_url": "https://api.github.com/users/emilio/received_events",
+        "repos_url": "https://api.github.com/users/emilio/repos",
+        "site_admin": false,
+        "starred_url": "https://api.github.com/users/emilio/starred{/owner}{/repo}",
+        "subscriptions_url": "https://api.github.com/users/emilio/subscriptions",
+        "type": "User",
+        "url": "https://api.github.com/users/emilio"
+      }
+    ],
+    "base": {
+      "label": "servo:master",
+      "ref": "master",
+      "repo": {
+        "archive_url": "https://api.github.com/repos/servo/servo/{archive_format}{/ref}",
+        "assignees_url": "https://api.github.com/repos/servo/servo/assignees{/user}",
+        "blobs_url": "https://api.github.com/repos/servo/servo/git/blobs{/sha}",
+        "branches_url": "https://api.github.com/repos/servo/servo/branches{/branch}",
+        "clone_url": "https://github.com/servo/servo.git",
+        "collaborators_url": "https://api.github.com/repos/servo/servo/collaborators{/collaborator}",
+        "comments_url": "https://api.github.com/repos/servo/servo/comments{/number}",
+        "commits_url": "https://api.github.com/repos/servo/servo/commits{/sha}",
+        "compare_url": "https://api.github.com/repos/servo/servo/compare/{base}...{head}",
+        "contents_url": "https://api.github.com/repos/servo/servo/contents/{+path}",
+        "contributors_url": "https://api.github.com/repos/servo/servo/contributors",
+        "created_at": "2012-02-08T19:07:25Z",
+        "default_branch": "master",
+        "deployments_url": "https://api.github.com/repos/servo/servo/deployments",
+        "description": "The Servo Browser Engine",
+        "downloads_url": "https://api.github.com/repos/servo/servo/downloads",
+        "events_url": "https://api.github.com/repos/servo/servo/events",
+        "fork": false,
+        "forks": 1586,
+        "forks_count": 1586,
+        "forks_url": "https://api.github.com/repos/servo/servo/forks",
+        "full_name": "servo/servo",
+        "git_commits_url": "https://api.github.com/repos/servo/servo/git/commits{/sha}",
+        "git_refs_url": "https://api.github.com/repos/servo/servo/git/refs{/sha}",
+        "git_tags_url": "https://api.github.com/repos/servo/servo/git/tags{/sha}",
+        "git_url": "git://github.com/servo/servo.git",
+        "has_downloads": true,
+        "has_issues": true,
+        "has_pages": false,
+        "has_projects": false,
+        "has_wiki": true,
+        "homepage": "https://servo.org/",
+        "hooks_url": "https://api.github.com/repos/servo/servo/hooks",
+        "html_url": "https://github.com/servo/servo",
+        "id": 3390243,
+        "issue_comment_url": "https://api.github.com/repos/servo/servo/issues/comments{/number}",
+        "issue_events_url": "https://api.github.com/repos/servo/servo/issues/events{/number}",
+        "issues_url": "https://api.github.com/repos/servo/servo/issues{/number}",
+        "keys_url": "https://api.github.com/repos/servo/servo/keys{/key_id}",
+        "labels_url": "https://api.github.com/repos/servo/servo/labels{/name}",
+        "language": null,
+        "languages_url": "https://api.github.com/repos/servo/servo/languages",
+        "merges_url": "https://api.github.com/repos/servo/servo/merges",
+        "milestones_url": "https://api.github.com/repos/servo/servo/milestones{/number}",
+        "mirror_url": null,
+        "name": "servo",
+        "notifications_url": "https://api.github.com/repos/servo/servo/notifications{?since,all,participating}",
+        "open_issues": 2162,
+        "open_issues_count": 2162,
+        "owner": {
+          "avatar_url": "https://avatars2.githubusercontent.com/u/2566135?v=3",
+          "events_url": "https://api.github.com/users/servo/events{/privacy}",
+          "followers_url": "https://api.github.com/users/servo/followers",
+          "following_url": "https://api.github.com/users/servo/following{/other_user}",
+          "gists_url": "https://api.github.com/users/servo/gists{/gist_id}",
+          "gravatar_id": "",
+          "html_url": "https://github.com/servo",
+          "id": 2566135,
+          "login": "servo",
+          "organizations_url": "https://api.github.com/users/servo/orgs",
+          "received_events_url": "https://api.github.com/users/servo/received_events",
+          "repos_url": "https://api.github.com/users/servo/repos",
+          "site_admin": false,
+          "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}",
+          "subscriptions_url": "https://api.github.com/users/servo/subscriptions",
+          "type": "Organization",
+          "url": "https://api.github.com/users/servo"
+        },
+        "private": false,
+        "pulls_url": "https://api.github.com/repos/servo/servo/pulls{/number}",
+        "pushed_at": "2017-04-21T00:20:30Z",
+        "releases_url": "https://api.github.com/repos/servo/servo/releases{/id}",
+        "size": 326890,
+        "ssh_url": "git@github.com:servo/servo.git",
+        "stargazers_count": 9259,
+        "stargazers_url": "https://api.github.com/repos/servo/servo/stargazers",
+        "statuses_url": "https://api.github.com/repos/servo/servo/statuses/{sha}",
+        "subscribers_url": "https://api.github.com/repos/servo/servo/subscribers",
+        "subscription_url": "https://api.github.com/repos/servo/servo/subscription",
+        "svn_url": "https://github.com/servo/servo",
+        "tags_url": "https://api.github.com/repos/servo/servo/tags",
+        "teams_url": "https://api.github.com/repos/servo/servo/teams",
+        "trees_url": "https://api.github.com/repos/servo/servo/git/trees{/sha}",
+        "updated_at": "2017-04-20T20:48:47Z",
+        "url": "https://api.github.com/repos/servo/servo",
+        "watchers": 9259,
+        "watchers_count": 9259
+      },
+      "sha": "93fa0ae1e3bcfe9e70a6fea91d137f20d8b5f790",
+      "user": {
+        "avatar_url": "https://avatars2.githubusercontent.com/u/2566135?v=3",
+        "events_url": "https://api.github.com/users/servo/events{/privacy}",
+        "followers_url": "https://api.github.com/users/servo/followers",
+        "following_url": "https://api.github.com/users/servo/following{/other_user}",
+        "gists_url": "https://api.github.com/users/servo/gists{/gist_id}",
+        "gravatar_id": "",
+        "html_url": "https://github.com/servo",
+        "id": 2566135,
+        "login": "servo",
+        "organizations_url": "https://api.github.com/users/servo/orgs",
+        "received_events_url": "https://api.github.com/users/servo/received_events",
+        "repos_url": "https://api.github.com/users/servo/repos",
+        "site_admin": false,
+        "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}",
+        "subscriptions_url": "https://api.github.com/users/servo/subscriptions",
+        "type": "Organization",
+        "url": "https://api.github.com/users/servo"
+      }
+    },
+    "body": "https://bugzilla.mozilla.org/show_bug.cgi?id=1357973\n\n<!-- Reviewable:start -->\n---\nThis change is\u2002[<img src=\"https://reviewable.io/review_button.svg\" height=\"34\" align=\"absmiddle\" alt=\"Reviewable\"/>](https://reviewable.io/reviews/servo/servo/16549)\n<!-- Reviewable:end -->\n",
+    "body_html": "<p><a href=\"https://bugzilla.mozilla.org/show_bug.cgi?id=1357973\">https://bugzilla.mozilla.org/show_bug.cgi?id=1357973</a></p>\n\n<hr>\n<p>This change is\u2002<a href=\"https://reviewable.io/reviews/servo/servo/16549\"><img src=\"https://camo.githubusercontent.com/0210135a140627a19496177795afadacb8502c1f/68747470733a2f2f72657669657761626c652e696f2f7265766965775f627574746f6e2e737667\" height=\"34\" align=\"absmiddle\" alt=\"Reviewable\" data-canonical-src=\"https://reviewable.io/review_button.svg\" style=\"max-width:100%;\"></a></p>\n",
+    "body_text": "https://bugzilla.mozilla.org/show_bug.cgi?id=1357973\n\n\nThis change is\u2002",
+    "changed_files": 11,
+    "closed_at": "2017-04-20T22:42:23Z",
+    "comments": 10,
+    "comments_url": "https://api.github.com/repos/servo/servo/issues/16549/comments",
+    "commits": 4,
+    "commits_url": "https://api.github.com/repos/servo/servo/pulls/16549/commits",
+    "created_at": "2017-04-20T21:01:53Z",
+    "deletions": 483,
+    "diff_url": "https://github.com/servo/servo/pull/16549.diff",
+    "head": {
+      "label": "bholley:inline_selectors",
+      "ref": "inline_selectors",
+      "repo": {
+        "archive_url": "https://api.github.com/repos/bholley/servo/{archive_format}{/ref}",
+        "assignees_url": "https://api.github.com/repos/bholley/servo/assignees{/user}",
+        "blobs_url": "https://api.github.com/repos/bholley/servo/git/blobs{/sha}",
+        "branches_url": "https://api.github.com/repos/bholley/servo/branches{/branch}",
+        "clone_url": "https://github.com/bholley/servo.git",
+        "collaborators_url": "https://api.github.com/repos/bholley/servo/collaborators{/collaborator}",
+        "comments_url": "https://api.github.com/repos/bholley/servo/comments{/number}",
+        "commits_url": "https://api.github.com/repos/bholley/servo/commits{/sha}",
+        "compare_url": "https://api.github.com/repos/bholley/servo/compare/{base}...{head}",
+        "contents_url": "https://api.github.com/repos/bholley/servo/contents/{+path}",
+        "contributors_url": "https://api.github.com/repos/bholley/servo/contributors",
+        "created_at": "2013-10-08T17:55:47Z",
+        "default_branch": "master",
+        "deployments_url": "https://api.github.com/repos/bholley/servo/deployments",
+        "description": "The Servo Browser Engine",
+        "downloads_url": "https://api.github.com/repos/bholley/servo/downloads",
+        "events_url": "https://api.github.com/repos/bholley/servo/events",
+        "fork": true,
+        "forks": 0,
+        "forks_count": 0,
+        "forks_url": "https://api.github.com/repos/bholley/servo/forks",
+        "full_name": "bholley/servo",
+        "git_commits_url": "https://api.github.com/repos/bholley/servo/git/commits{/sha}",
+        "git_refs_url": "https://api.github.com/repos/bholley/servo/git/refs{/sha}",
+        "git_tags_url": "https://api.github.com/repos/bholley/servo/git/tags{/sha}",
+        "git_url": "git://github.com/bholley/servo.git",
+        "has_downloads": true,
+        "has_issues": false,
+        "has_pages": false,
+        "has_projects": true,
+        "has_wiki": true,
+        "homepage": "",
+        "hooks_url": "https://api.github.com/repos/bholley/servo/hooks",
+        "html_url": "https://github.com/bholley/servo",
+        "id": 13421111,
+        "issue_comment_url": "https://api.github.com/repos/bholley/servo/issues/comments{/number}",
+        "issue_events_url": "https://api.github.com/repos/bholley/servo/issues/events{/number}",
+        "issues_url": "https://api.github.com/repos/bholley/servo/issues{/number}",
+        "keys_url": "https://api.github.com/repos/bholley/servo/keys{/key_id}",
+        "labels_url": "https://api.github.com/repos/bholley/servo/labels{/name}",
+        "language": null,
+        "languages_url": "https://api.github.com/repos/bholley/servo/languages",
+        "merges_url": "https://api.github.com/repos/bholley/servo/merges",
+        "milestones_url": "https://api.github.com/repos/bholley/servo/milestones{/number}",
+        "mirror_url": null,
+        "name": "servo",
+        "notifications_url": "https://api.github.com/repos/bholley/servo/notifications{?since,all,participating}",
+        "open_issues": 0,
+        "open_issues_count": 0,
+        "owner": {
+          "avatar_url": "https://avatars2.githubusercontent.com/u/377435?v=3",
+          "events_url": "https://api.github.com/users/bholley/events{/privacy}",
+          "followers_url": "https://api.github.com/users/bholley/followers",
+          "following_url": "https://api.github.com/users/bholley/following{/other_user}",
+          "gists_url": "https://api.github.com/users/bholley/gists{/gist_id}",
+          "gravatar_id": "",
+          "html_url": "https://github.com/bholley",
+          "id": 377435,
+          "login": "bholley",
+          "organizations_url": "https://api.github.com/users/bholley/orgs",
+          "received_events_url": "https://api.github.com/users/bholley/received_events",
+          "repos_url": "https://api.github.com/users/bholley/repos",
+          "site_admin": false,
+          "starred_url": "https://api.github.com/users/bholley/starred{/owner}{/repo}",
+          "subscriptions_url": "https://api.github.com/users/bholley/subscriptions",
+          "type": "User",
+          "url": "https://api.github.com/users/bholley"
+        },
+        "private": false,
+        "pulls_url": "https://api.github.com/repos/bholley/servo/pulls{/number}",
+        "pushed_at": "2017-04-20T22:05:06Z",
+        "releases_url": "https://api.github.com/repos/bholley/servo/releases{/id}",
+        "size": 249850,
+        "ssh_url": "git@github.com:bholley/servo.git",
+        "stargazers_count": 0,
+        "stargazers_url": "https://api.github.com/repos/bholley/servo/stargazers",
+        "statuses_url": "https://api.github.com/repos/bholley/servo/statuses/{sha}",
+        "subscribers_url": "https://api.github.com/repos/bholley/servo/subscribers",
+        "subscription_url": "https://api.github.com/repos/bholley/servo/subscription",
+        "svn_url": "https://github.com/bholley/servo",
+        "tags_url": "https://api.github.com/repos/bholley/servo/tags",
+        "teams_url": "https://api.github.com/repos/bholley/servo/teams",
+        "trees_url": "https://api.github.com/repos/bholley/servo/git/trees{/sha}",
+        "updated_at": "2015-10-09T02:34:11Z",
+        "url": "https://api.github.com/repos/bholley/servo",
+        "watchers": 0,
+        "watchers_count": 0
+      },
+      "sha": "fe97033aa877f85e1ad2438245861a4425977e9b",
+      "user": {
+        "avatar_url": "https://avatars2.githubusercontent.com/u/377435?v=3",
+        "events_url": "https://api.github.com/users/bholley/events{/privacy}",
+        "followers_url": "https://api.github.com/users/bholley/followers",
+        "following_url": "https://api.github.com/users/bholley/following{/other_user}",
+        "gists_url": "https://api.github.com/users/bholley/gists{/gist_id}",
+        "gravatar_id": "",
+        "html_url": "https://github.com/bholley",
+        "id": 377435,
+        "login": "bholley",
+        "organizations_url": "https://api.github.com/users/bholley/orgs",
+        "received_events_url": "https://api.github.com/users/bholley/received_events",
+        "repos_url": "https://api.github.com/users/bholley/repos",
+        "site_admin": false,
+        "starred_url": "https://api.github.com/users/bholley/starred{/owner}{/repo}",
+        "subscriptions_url": "https://api.github.com/users/bholley/subscriptions",
+        "type": "User",
+        "url": "https://api.github.com/users/bholley"
+      }
+    },
+    "html_url": "https://github.com/servo/servo/pull/16549",
+    "id": 116863705,
+    "issue_url": "https://api.github.com/repos/servo/servo/issues/16549",
+    "locked": false,
+    "maintainer_can_modify": false,
+    "merge_commit_sha": "fe97033aa877f85e1ad2438245861a4425977e9b",
+    "mergeable": null,
+    "mergeable_state": "unknown",
+    "merged": true,
+    "merged_at": "2017-04-20T22:42:23Z",
+    "merged_by": {
+      "avatar_url": "https://avatars2.githubusercontent.com/u/4368172?v=3",
+      "events_url": "https://api.github.com/users/bors-servo/events{/privacy}",
+      "followers_url": "https://api.github.com/users/bors-servo/followers",
+      "following_url": "https://api.github.com/users/bors-servo/following{/other_user}",
+      "gists_url": "https://api.github.com/users/bors-servo/gists{/gist_id}",
+      "gravatar_id": "",
+      "html_url": "https://github.com/bors-servo",
+      "id": 4368172,
+      "login": "bors-servo",
+      "organizations_url": "https://api.github.com/users/bors-servo/orgs",
+      "received_events_url": "https://api.github.com/users/bors-servo/received_events",
+      "repos_url": "https://api.github.com/users/bors-servo/repos",
+      "site_admin": false,
+      "starred_url": "https://api.github.com/users/bors-servo/starred{/owner}{/repo}",
+      "subscriptions_url": "https://api.github.com/users/bors-servo/subscriptions",
+      "type": "User",
+      "url": "https://api.github.com/users/bors-servo"
+    },
+    "milestone": null,
+    "number": 16549,
+    "patch_url": "https://github.com/servo/servo/pull/16549.patch",
+    "rebaseable": null,
+    "review_comment_url": "https://api.github.com/repos/servo/servo/pulls/comments{/number}",
+    "review_comments": 0,
+    "review_comments_url": "https://api.github.com/repos/servo/servo/pulls/16549/comments",
+    "state": "closed",
+    "statuses_url": "https://api.github.com/repos/servo/servo/statuses/fe97033aa877f85e1ad2438245861a4425977e9b",
+    "title": "store simple selectors and combinators inline",
+    "updated_at": "2017-04-20T22:42:24Z",
+    "url": "https://api.github.com/repos/servo/servo/pulls/16549",
+    "user": {
+      "avatar_url": "https://avatars2.githubusercontent.com/u/377435?v=3",
+      "events_url": "https://api.github.com/users/bholley/events{/privacy}",
+      "followers_url": "https://api.github.com/users/bholley/followers",
+      "following_url": "https://api.github.com/users/bholley/following{/other_user}",
+      "gists_url": "https://api.github.com/users/bholley/gists{/gist_id}",
+      "gravatar_id": "",
+      "html_url": "https://github.com/bholley",
+      "id": 377435,
+      "login": "bholley",
+      "organizations_url": "https://api.github.com/users/bholley/orgs",
+      "received_events_url": "https://api.github.com/users/bholley/received_events",
+      "repos_url": "https://api.github.com/users/bholley/repos",
+      "site_admin": false,
+      "starred_url": "https://api.github.com/users/bholley/starred{/owner}{/repo}",
+      "subscriptions_url": "https://api.github.com/users/bholley/subscriptions",
+      "type": "User",
+      "url": "https://api.github.com/users/bholley"
+    }
+  } (no-eol)
+