From 8a00ac0f09db59846e3c01b43a53b598b8b5dcee Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 06:15:30 +0900 Subject: [PATCH 01/10] Include only the pull requests marked as "rollup" when creating a rollup --- bors.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/bors.py b/bors.py index 1b11c86..1bcdb84 100755 --- a/bors.py +++ b/bors.py @@ -486,8 +486,10 @@ def merge_pull_head_to_test_ref(self): self.set_error(s) def merge_batched_pull_reqs_to_test_ref(self, pulls): + batched_pulls = [x for x in pulls if x.batched() and x.current_state() == STATE_APPROVED] + batch_msg = 'merging {} batched pull requests into {}'.format( - len([x for x in pulls if x.current_state() == STATE_APPROVED]), + len(batched_pulls), self.batch_ref, ) self.log.info(batch_msg) @@ -506,25 +508,24 @@ def merge_batched_pull_reqs_to_test_ref(self, pulls): batch_sha = '' - for pull in pulls: - if pull.current_state() == STATE_APPROVED: - self.log.info('merging {} into {}'.format(pull.short(), self.batch_ref)) - - msg = 'Merge pull request #{} from {}/{}\n\n{}\n\nReviewed-by: {}'.format( - pull.num, - pull.src_owner, pull.ref, - pull.title, - ', '.join(pull.approval_list()) - ) - pull_repr = '- {}/{} = {}: {}'.format(pull.src_owner, pull.ref, pull.sha, pull.title) - - try: - info = self.dst().merges().post(base=self.batch_ref, head=pull.sha, commit_message=msg) - batch_sha = info['sha'].encode('utf-8') - except github.ApiError: - failures.append(pull_repr) - else: - successes.append(pull_repr) + for pull in batched_pulls: + self.log.info('merging {} into {}'.format(pull.short(), self.batch_ref)) + + msg = 'Merge pull request #{} from {}/{}\n\n{}\n\nReviewed-by: {}'.format( + pull.num, + pull.src_owner, pull.ref, + pull.title, + ', '.join(pull.approval_list()) + ) + pull_repr = '- {}/{} = {}: {}'.format(pull.src_owner, pull.ref, pull.sha, pull.title) + + try: + info = self.dst().merges().post(base=self.batch_ref, head=pull.sha, commit_message=msg) + batch_sha = info['sha'].encode('utf-8') + except github.ApiError: + failures.append(pull_repr) + else: + successes.append(pull_repr) if batch_sha: try: From 8149061faa6b4669eb837e054622d8710f39e88f Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 06:49:50 +0900 Subject: [PATCH 02/10] Save additional information in the commit status --- bors.py | 121 +++++++++++++------------------------------------------- 1 file changed, 27 insertions(+), 94 deletions(-) diff --git a/bors.py b/bors.py index 1bcdb84..3177cfe 100755 --- a/bors.py +++ b/bors.py @@ -238,7 +238,6 @@ def __init__(self, cfg, gh, j): self.sha=j["head"]["sha"].encode("utf8") self.title=ustr(j["title"]) self.body=ustr(j["body"]) - self.merge_sha = None self.closed=j["state"].encode("utf8") == "closed" self.approved = False self.testpass = False @@ -256,6 +255,7 @@ def __init__(self, cfg, gh, j): self.get_head_statuses() self.get_mergeable() self.loaded_ok = True + self.metadata = self.parse_metadata() def short(self): @@ -445,19 +445,14 @@ def reset_test_ref_to_master(self): self.dst().git().refs().heads(self.test_ref).patch(sha=master_sha, force=True) - def parse_merge_sha(self): - parsed_merge_sha = None - + def parse_metadata(self): for s in self.dst().statuses(self.sha).get(): if s['creator']['login'].encode('utf-8') == self.user and s['state'].encode('utf-8') == 'pending': - mat = re.match(r'running tests for.*?candidate ([a-z0-9]+)', s['description'].encode('utf-8')) - if mat: parsed_merge_sha = mat.group(1) - break - - if self.merge_sha: - assert self.merge_sha == parsed_merge_sha - else: - self.merge_sha = parsed_merge_sha + lines = s['description'].encode('utf-8').split('\n', 1) + if len(lines) > 1: + return json.loads(lines[1]) + else: + return {} def merge_pull_head_to_test_ref(self): s = "merging %s into %s" % (self.short(), self.test_ref) @@ -470,14 +465,17 @@ def merge_pull_head_to_test_ref(self): j = self.dst().merges().post(base=self.test_ref, head=self.sha, commit_message=m) - self.merge_sha = j["sha"].encode("utf8") + merge_sha = j["sha"].encode("utf8") u = ("https://github.com/%s/%s/commit/%s" % - (self.dst_owner, self.dst_repo, self.merge_sha)) + (self.dst_owner, self.dst_repo, merge_sha)) s = "%s merged ok, testing candidate = %.8s" % (self.short(), - self.merge_sha) + merge_sha) self.log.info(s) self.add_comment(self.sha, s) - self.set_pending("running tests for candidate {}".format(self.merge_sha), u) + self.set_pending("running tests for candidate {:.7}\n{}".format( + merge_sha, + json.dumps({'merge_sha': merge_sha}), + ), u) except github.ApiError: s = s + " failed" @@ -535,14 +533,21 @@ def merge_batched_pull_reqs_to_test_ref(self, pulls): self.dst().git().refs().post(sha=batch_sha, ref='refs/heads/' + self.test_ref) url = 'https://github.com/{}/{}/commit/{}'.format(self.dst_owner, self.dst_repo, batch_sha) - short_msg = 'running tests for rollup candidate {} ({} successes, {} failures)'.format(batch_sha, len(successes), len(failures)) - msg = 'Testing rollup candidate = {:.8}'.format(batch_sha) + short_msg = 'running tests for rollup candidate {:.7} (successful merges: {} out of {})'.format( + batch_sha, + len(successes), + len(successes) + len(failures), + ) + msg = 'Testing rollup candidate = {:.7}'.format(batch_sha) if successes: msg += '\n\n**Successful merges:**\n\n{}'.format('\n'.join(successes)) if failures: msg += '\n\n**Failed merges:**\n\n{}'.format('\n'.join(failures)) self.log.info(short_msg) self.add_comment(self.sha, msg) - self.set_pending(short_msg, url) + self.set_pending('{}\n{}'.format( + short_msg, + json.dumps({'merge_sha': batch_sha}), + ), url) else: batch_msg += ' failed' @@ -558,12 +563,11 @@ def merge_or_batch(self, pulls): self.merge_pull_head_to_test_ref() def advance_master_ref_to_test(self): - assert self.merge_sha != None s = ("fast-forwarding %s to %s = %.8s" % - (self.master_ref, self.test_ref, self.merge_sha)) + (self.master_ref, self.test_ref, self.metadata['merge_sha'])) self.log.info(s) try: - self.dst().git().refs().heads(self.master_ref).patch(sha=self.merge_sha, + self.dst().git().refs().heads(self.master_ref).patch(sha=self.metadata['merge_sha'], force=False) self.add_comment(self.sha, s) except github.ApiError: @@ -601,18 +605,9 @@ def try_advance(self, pulls): self.merge_or_batch(pulls) elif s == STATE_PENDING: - self.parse_merge_sha() - if self.merge_sha == None: - c = ("No active merge of candidate %.8s found, likely manual push to %s" - % (self.sha, self.master_ref)) - self.log.info(c) - self.add_comment(self.sha, c) - self.merge_or_batch(pulls) - return self.log.info("%s - found pending state, checking tests", self.short()) - assert self.merge_sha != None bb = BuildBot(self.cfg) - (t, main_urls, extra_urls) = bb.test_status(self.merge_sha) + (t, main_urls, extra_urls) = bb.test_status(self.metadata['merge_sha']) if t == True: self.log.info("%s - tests passed, marking success", self.short()) @@ -641,7 +636,6 @@ def try_advance(self, pulls): elif s == STATE_TESTED: self.log.info("%s - tests successful, attempting landing", self.short()) - self.parse_merge_sha() self.advance_master_ref_to_test() @@ -696,67 +690,6 @@ def main(): pulls = [ PullReq(cfg, gh, pull) for pull in all_pulls ] - # - # We are reconstructing the relationship between three tree-states on the - # fly here. We're doing to because there was nowhere useful to leave it - # written between runs, and it can be discovered by inspection. - # - # The situation is this: - # - # - # test_ref ==> - # - # / \ - # / \ - # / \ - # master_ref ==> == p.sha - # | | - # | | - # ... ... - # - # - # When this is true, it means we're currently testing candidate_sha - # which (should) be the sha of a single pull req's head. - # - # We discover this situation by working backwards: - # - # - We get the test_ref's sha, test_sha - # - We get the master_ref's sha, master_sha - # - We get the 2 parent links of test_sha - # - We exclude the master_sha from that parent list - # - Whatever the _other_ parent is, we consider the candidate - # - # If we fail to find any steps along the way, bors will either ignore - # the current state of affairs (i.e. assume it's _not_ presently testing - # any pull req) or else crash due to inability to load something. - # So it's non-fatal if we get it wrong; we'll only advance (make changes) - # if we get it right. - # - - test_ref = cfg["test_ref"].encode("utf8") - master_ref = cfg["master_ref"].encode("utf8") - test_head = gh.repos(owner)(repo).git().refs().heads(test_ref).get() - master_head = gh.repos(owner)(repo).git().refs().heads(master_ref).get() - test_sha = test_head["object"]["sha"].encode("utf8") - master_sha = master_head["object"]["sha"].encode("utf8") - test_commit = gh.repos(owner)(repo).git().commits(test_sha).get() - test_parents = [ x["sha"].encode("utf8") for x in test_commit["parents"] ] - candidate_sha = None - if len(test_parents) == 2 and master_sha in test_parents: - test_parents.remove(master_sha) - candidate_sha = test_parents[0] - logging.info("test ref '%s' = %.8s, parents: '%s' = %.8s and candidate = %.8s", - test_ref, test_sha, - master_ref, master_sha, - candidate_sha) - for p in pulls: - if p.sha == candidate_sha: - logging.info("candidate = %.8s found in pull req %s", - candidate_sha, p.short()) - p.merge_sha = test_sha - - - # By now we have found all pull reqs and marked the one that's the # currently-building candidate (if it exists). We then sort them # by ripeness and pick the one closest to landing, try to push it From 086105cedf735b7c50095f77397ab6a8406f69dd Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 07:10:03 +0900 Subject: [PATCH 03/10] Check the freshness of each PR before fast-forwarding a rollup --- bors.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/bors.py b/bors.py index 3177cfe..2b747df 100755 --- a/bors.py +++ b/bors.py @@ -503,6 +503,7 @@ def merge_batched_pull_reqs_to_test_ref(self, pulls): successes = [] failures = [] + rollup_pulls = [] batch_sha = '' @@ -524,6 +525,7 @@ def merge_batched_pull_reqs_to_test_ref(self, pulls): failures.append(pull_repr) else: successes.append(pull_repr) + rollup_pulls.append([pull.num, pull.sha]) if batch_sha: try: @@ -546,7 +548,7 @@ def merge_batched_pull_reqs_to_test_ref(self, pulls): self.add_comment(self.sha, msg) self.set_pending('{}\n{}'.format( short_msg, - json.dumps({'merge_sha': batch_sha}), + json.dumps({'merge_sha': batch_sha, 'rollup_pulls': rollup_pulls}), ), url) else: batch_msg += ' failed' @@ -562,7 +564,22 @@ def merge_or_batch(self, pulls): else: self.merge_pull_head_to_test_ref() - def advance_master_ref_to_test(self): + def advance_master_ref_to_test(self, pulls): + if self.batched(): + num2sha = {x.num: x.sha for x in pulls} + + error_occurred = False + + for num, sha in self.metadata['rollup_pulls']: + if num2sha[num] != sha: + error_occurred = True + + msg = '#{} advanced, cannot continue'.format(num) + self.add_comment(self.sha, msg) + self.set_error(msg) + + if error_occurred: return + s = ("fast-forwarding %s to %s = %.8s" % (self.master_ref, self.test_ref, self.metadata['merge_sha'])) self.log.info(s) @@ -636,7 +653,7 @@ def try_advance(self, pulls): elif s == STATE_TESTED: self.log.info("%s - tests successful, attempting landing", self.short()) - self.advance_master_ref_to_test() + self.advance_master_ref_to_test(pulls) From 84f4aa3a6c873edeaeb2bf1af2b3adc1353d6805 Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 07:29:09 +0900 Subject: [PATCH 04/10] Use comments rather than statuses to save metadata --- bors.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/bors.py b/bors.py index 2b747df..b9a468e 100755 --- a/bors.py +++ b/bors.py @@ -255,7 +255,6 @@ def __init__(self, cfg, gh, j): self.get_head_statuses() self.get_mergeable() self.loaded_ok = True - self.metadata = self.parse_metadata() def short(self): @@ -446,13 +445,16 @@ def reset_test_ref_to_master(self): force=True) def parse_metadata(self): - for s in self.dst().statuses(self.sha).get(): - if s['creator']['login'].encode('utf-8') == self.user and s['state'].encode('utf-8') == 'pending': - lines = s['description'].encode('utf-8').split('\n', 1) - if len(lines) > 1: - return json.loads(lines[1]) - else: - return {} + cs = self.dst().commits(self.sha).comments().get() + status_comments = [ + c['body'][len(u'status: '):].encode('utf-8') + for c in cs + if c['user']['login'].encode('utf-8') == self.user and c['body'] and c['body'].startswith(u'status: ') + ] + self.metadata = json.loads(status_comments[-1]) if status_comments else {} + + def set_metadata(self, **kwargs): + self.add_comment(self.sha, 'status: {}'.format(json.dumps(kwargs))) def merge_pull_head_to_test_ref(self): s = "merging %s into %s" % (self.short(), self.test_ref) @@ -471,11 +473,9 @@ def merge_pull_head_to_test_ref(self): s = "%s merged ok, testing candidate = %.8s" % (self.short(), merge_sha) self.log.info(s) + self.set_metadata(merge_sha=merge_sha) + self.set_pending("running tests for candidate {:.7}".format(merge_sha), u) self.add_comment(self.sha, s) - self.set_pending("running tests for candidate {:.7}\n{}".format( - merge_sha, - json.dumps({'merge_sha': merge_sha}), - ), u) except github.ApiError: s = s + " failed" @@ -545,11 +545,9 @@ def merge_batched_pull_reqs_to_test_ref(self, pulls): if failures: msg += '\n\n**Failed merges:**\n\n{}'.format('\n'.join(failures)) self.log.info(short_msg) + self.set_metadata(merge_sha=batch_sha, rollup_pulls=rollup_pulls) + self.set_pending(short_msg, url) self.add_comment(self.sha, msg) - self.set_pending('{}\n{}'.format( - short_msg, - json.dumps({'merge_sha': batch_sha, 'rollup_pulls': rollup_pulls}), - ), url) else: batch_msg += ' failed' @@ -622,6 +620,7 @@ def try_advance(self, pulls): self.merge_or_batch(pulls) elif s == STATE_PENDING: + self.parse_metadata() self.log.info("%s - found pending state, checking tests", self.short()) bb = BuildBot(self.cfg) (t, main_urls, extra_urls) = bb.test_status(self.metadata['merge_sha']) @@ -652,6 +651,7 @@ def try_advance(self, pulls): self.log.info("%s - no info yet, waiting on tests", self.short()) elif s == STATE_TESTED: + self.parse_metadata() self.log.info("%s - tests successful, attempting landing", self.short()) self.advance_master_ref_to_test(pulls) From 04fb772773ca936c7a00763295107ed76cb95bd7 Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 07:48:30 +0900 Subject: [PATCH 05/10] Show pull request numbers in the merge status --- bors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bors.py b/bors.py index b9a468e..41d49d8 100755 --- a/bors.py +++ b/bors.py @@ -516,7 +516,7 @@ def merge_batched_pull_reqs_to_test_ref(self, pulls): pull.title, ', '.join(pull.approval_list()) ) - pull_repr = '- {}/{} = {}: {}'.format(pull.src_owner, pull.ref, pull.sha, pull.title) + pull_repr = '- #{} {} ({}/{} = {})'.format(pull.num, pull.title, pull.src_owner, pull.ref, pull.sha) try: info = self.dst().merges().post(base=self.batch_ref, head=pull.sha, commit_message=msg) From 7d95f54dc967a7df3020df5f8dd7c872e000fd87 Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 08:05:21 +0900 Subject: [PATCH 06/10] Consider only the latest status when checking if a commit is successful --- bors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bors.py b/bors.py index 41d49d8..60df739 100755 --- a/bors.py +++ b/bors.py @@ -387,7 +387,7 @@ def count_failures(self): return len([c for c in self.statuses if c == "failure"]) def count_successes(self): - return len([c for c in self.statuses if c == "success"]) + return 1 if self.statuses and self.statuses[0] == 'success' else 0 def count_pendings(self): return len([c for c in self.statuses if c == "pending"]) From 416f6cc09755151c3d906a6111ae22ffc16ee94f Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 08:21:22 +0900 Subject: [PATCH 07/10] Test a rollup again if one of the PRs advances, without the advanced PR --- bors.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bors.py b/bors.py index 60df739..9412da3 100755 --- a/bors.py +++ b/bors.py @@ -566,17 +566,19 @@ def advance_master_ref_to_test(self, pulls): if self.batched(): num2sha = {x.num: x.sha for x in pulls} - error_occurred = False - + advanced = False for num, sha in self.metadata['rollup_pulls']: if num2sha[num] != sha: - error_occurred = True + advanced = True - msg = '#{} advanced, cannot continue'.format(num) + msg = '#{} advanced, testing again without the PR'.format(num) + self.log.info(msg) self.add_comment(self.sha, msg) - self.set_error(msg) - if error_occurred: return + if advanced: + self.statuses = [x for x in self.statuses if x not in ['success', 'pending']] # Mark this PR as unsuccessful + self.merge_or_batch(pulls) + return s = ("fast-forwarding %s to %s = %.8s" % (self.master_ref, self.test_ref, self.metadata['merge_sha'])) From 8a40722793e13a2238a92b8750ab413fc7edb282 Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 09:52:36 +0900 Subject: [PATCH 08/10] Reduce the number of runs needed to merge a successful pull request Basically, we can skip the TESTED state entirely. We can also check the next pull request at the same time. Fixes #35. --- bors.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bors.py b/bors.py index 9412da3..b7caaf0 100755 --- a/bors.py +++ b/bors.py @@ -563,6 +563,8 @@ def merge_or_batch(self, pulls): self.merge_pull_head_to_test_ref() def advance_master_ref_to_test(self, pulls): + ret = False + if self.batched(): num2sha = {x.num: x.sha for x in pulls} @@ -578,7 +580,7 @@ def advance_master_ref_to_test(self, pulls): if advanced: self.statuses = [x for x in self.statuses if x not in ['success', 'pending']] # Mark this PR as unsuccessful self.merge_or_batch(pulls) - return + return ret s = ("fast-forwarding %s to %s = %.8s" % (self.master_ref, self.test_ref, self.metadata['merge_sha'])) @@ -587,6 +589,8 @@ def advance_master_ref_to_test(self, pulls): self.dst().git().refs().heads(self.master_ref).patch(sha=self.metadata['merge_sha'], force=False) self.add_comment(self.sha, s) + + ret = True except github.ApiError: s = s + " failed" self.log.info(s) @@ -600,9 +604,9 @@ def advance_master_ref_to_test(self, pulls): self.log.info("closing failed; auto-closed after merge?") pass + return ret - - def try_advance(self, pulls): + def try_advance(self, pulls, cfg): s = self.current_state() self.log.info("considering %s", self.desc()) @@ -638,6 +642,8 @@ def try_advance(self, pulls): self.add_comment(self.sha, c) self.set_success("all tests passed") + s = STATE_TESTED + elif t == False: self.log.info("%s - tests failed, marking failure", self.short()) c = "some tests failed:" @@ -652,11 +658,11 @@ def try_advance(self, pulls): else: self.log.info("%s - no info yet, waiting on tests", self.short()) - elif s == STATE_TESTED: + if s == STATE_TESTED: self.parse_metadata() self.log.info("%s - tests successful, attempting landing", self.short()) - self.advance_master_ref_to_test(pulls) - + if self.advance_master_ref_to_test(pulls): + run(cfg) def main(): @@ -682,6 +688,9 @@ def main(): logging.info("loading bors.cfg") cfg = json.load(open("bors.cfg")) + run(cfg) + +def run(cfg): gh = None if "gh_pass" in cfg: gh = github.GitHub(username=cfg["gh_user"].encode("utf8"), @@ -763,7 +772,7 @@ def main(): else: p = pulls[-1] logging.info("working with most-ripe pull %s", p.short()) - p.try_advance(list(reversed(pulls))) + p.try_advance(list(reversed(pulls)), cfg) From 5a1a6b6cf163ae4726b64d156291c98e4bd8c556 Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 09:59:46 +0900 Subject: [PATCH 09/10] Do not manually close a pull request Because of the manual closing, the pull request is occasionally set to "closed" rather than "merged" even though it was successful. This is because there is a little latency between the fast-forwarding and the automatic transition to "merged". If a bad thing happens, it would be better to leave it "open". --- bors.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/bors.py b/bors.py index b7caaf0..b6ac4bd 100755 --- a/bors.py +++ b/bors.py @@ -597,13 +597,6 @@ def advance_master_ref_to_test(self, pulls): self.add_comment(self.sha, s) self.set_error(s) - try: - self.dst().pulls(self.num).patch(state="closed") - self.closed = True - except github.ApiError: - self.log.info("closing failed; auto-closed after merge?") - pass - return ret def try_advance(self, pulls, cfg): From 93e89f1727b87d071fc87da663161855a2b462b3 Mon Sep 17 00:00:00 2001 From: Barosl Lee Date: Mon, 15 Dec 2014 10:29:00 +0900 Subject: [PATCH 10/10] Parse the commands more generously r+, r=me, r=[name], r-, and retry are checked more generously. --- bors.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bors.py b/bors.py index b6ac4bd..38f52ff 100755 --- a/bors.py +++ b/bors.py @@ -308,11 +308,11 @@ def last_comment(self): def approval_list(self): return ([u for (d,u,c) in self.head_comments - if (c.startswith("r+") or - c.startswith("r=me"))] + + # Check if "r+" or "r=me" + for m in [re.search(r'\b(?:r\+|r=me\b)', c)] if m] + [ m.group(1) for (_,_,c) in self.head_comments - for m in [re.match(r"^r=(\w+)", c)] if m ]) + for m in [re.search(r'\b(?:r=(\w+))\b', c)] if m ]) def batched(self): for date, user, comment in self.head_comments: @@ -336,11 +336,11 @@ def prioritized_state(self): def disapproval_list(self): return [u for (d,u,c) in self.head_comments - if c.startswith("r-")] + for m in [re.search(r'\b(?:r-)', c)] if m] def count_retries(self): - return len([c for (d,u,c) in self.head_comments if ( - c.startswith("@bors: retry"))]) + return len([c for (d,u,c) in self.head_comments + for m in [re.search(r'\b(?:retry)\b', c)] if m]) # annoyingly, even though we're starting from a "pull" json # blob, this blob does not have the "mergeable" flag; only