diff --git a/apis/bugzilla_api.py b/apis/bugzilla_api.py index 753e188..46790ad 100644 --- a/apis/bugzilla_api.py +++ b/apis/bugzilla_api.py @@ -23,6 +23,19 @@ def task_id_comment_tag(): return os.environ.get('TASK_ID', 'missing') +def task_log_url(): + if "TASK_ID" not in os.environ: + return None + root = os.environ.get('TASKCLUSTER_ROOT_URL', 'https://firefox-ci-tc.services.mozilla.com') + return root.rstrip('/') + '/tasks/' + os.environ['TASK_ID'] + + +class CommentRejectedError(Exception): + """Bugzilla's web filter (not Bugzilla itself) refused the comment, with an + empty 406 response. It has done this for comments quoting shell trace output.""" + pass + + def task_id_whiteboard(): if "TASK_ID" in os.environ: return sq('task_id: ' + os.environ['TASK_ID']) @@ -119,6 +132,9 @@ def commentOnBug(url, apikey, bugID, comment, needinfo=None, assignee=None): json=data ) + if r.status_code == 406: + raise CommentRejectedError("Bugzilla rejected the comment on Bug %s (status=406)" % bugID) + j = _load_json_or_raise(r, "commentOnBug") if 'bugs' in j: diff --git a/components/bugzilla.py b/components/bugzilla.py index 6067bfd..5e1a21e 100644 --- a/components/bugzilla.py +++ b/components/bugzilla.py @@ -3,7 +3,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/. -from apis.bugzilla_api import fileBug, commentOnBug, closeBug, openBugsMetadata, markFFVersionAffected, getBugComments +from apis.bugzilla_api import fileBug, commentOnBug, closeBug, openBugsMetadata, markFFVersionAffected, getBugComments, CommentRejectedError, task_log_url from components.providerbase import BaseProvider, INeedsLoggingProvider from components.logging import LogLevel, logEntryExit @@ -207,6 +207,16 @@ def MISSING_COMPONENT(library, filed_bug_id): filed_bug_id, FALLBACK_PRODUCT_COMPONENT[0], FALLBACK_PRODUCT_COMPONENT[1], library.yaml_path, filed_bug_id) + @staticmethod + def COMMENT_REJECTED(log_url): + s = "Updatebot tried to post a comment here, but Bugzilla rejected it, probably because " + s += "it quoted command output that Bugzilla's web filter considered suspicious. " + if log_url: + s += "The full message is in the Updatebot log: " + log_url + else: + s += "The full message is in the log of the Updatebot run that tried to post it." + return s + @staticmethod def TRY_RUN_SUBMITTED(revision, another=False): return "I've submitted a" + ("nother" if another else "") + " try run for this commit: https://treeherder.mozilla.org/jobs?repo=try&revision=" + revision @@ -305,7 +315,7 @@ def _file_missing_component_bug(self, library, filed_bug_id): self.logger.log("Could not file a bug about the missing component in %s: %s" % (library.yaml_path, e), level=LogLevel.Error) @logEntryExit - def comment_on_bug(self, bug_id, comment, needinfo=None, assignee=None): + def comment_on_bug(self, bug_id, comment, needinfo=None, assignee=None, _is_fallback=False): if len(comment) > 65535: self.logger.log("Comment on Bug %s is too long: %s characters; truncating." % (bug_id, len(comment)), level=LogLevel.Warning) suffix = "\n\nOops - the above comment was too long. I was unable to shrink it nicely, so I had to truncate it to fit Bugzilla's limits." @@ -314,10 +324,16 @@ def comment_on_bug(self, bug_id, comment, needinfo=None, assignee=None): try: commentOnBug( self.config['url'], self.config['apikey'], bug_id, comment, needinfo=needinfo, assignee=assignee) + except CommentRejectedError: + if _is_fallback: + raise + self.logger.log("Bugzilla rejected the comment on Bug %s; posting a short replacement instead. The rejected comment was:\n%s" % (bug_id, comment), level=LogLevel.Warning) + self.comment_on_bug(bug_id, CommentTemplates.COMMENT_REJECTED(task_log_url()), needinfo, assignee, _is_fallback=True) + return except Exception as e: if is_needinfo_exception(e): self.logger.log("Developer is not accepting needinfos, trying again without...", level=LogLevel.Info) - self.comment_on_bug(bug_id, comment, None, assignee) + self.comment_on_bug(bug_id, comment, None, assignee, _is_fallback=_is_fallback) else: raise e diff --git a/tests/bugzilla.py b/tests/bugzilla.py index 3cb60a1..5bc3e54 100755 --- a/tests/bugzilla.py +++ b/tests/bugzilla.py @@ -4,6 +4,7 @@ # 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/. +import os import re import sys import unittest @@ -18,7 +19,7 @@ from components.bugzilla import BugzillaProvider, CommentTemplates from components.logging import SimpleLoggerConfig -from apis.bugzilla_api import task_id_whiteboard +from apis.bugzilla_api import task_id_whiteboard, CommentRejectedError TRY_REVISION = "this-is-my-try-link" @@ -26,6 +27,7 @@ class MockBugzillaServer(server.BaseHTTPRequestHandler): closed_bug_ids = [] filed_bugs = [] + received_comments = {} def do_POST(self): expectedPath_file = "/bug?api_key=bob" @@ -105,6 +107,14 @@ def do_PUT(self): size = int(self.headers.get('content-length')) content = json.loads(self.rfile.read(size).decode("utf-8")) bug_id = re.match(r"/bug/([0-9]+)\?api_key=bob", self.path).groups(0)[0] + MockBugzillaServer.received_comments.setdefault(bug_id, []).append(content) + + # Like Bugzilla's web filter: an empty 406. Bug 1238 rejects everything + # but Updatebot's short replacement comment; bug 1239 rejects everything. + if bug_id == "1239" or (bug_id == "1238" and "Bugzilla rejected it" not in content['comment']['body']): + self.send_response(406) + self.end_headers() + return self.send_response(200) self.send_header("Content-type", "application/json") @@ -237,6 +247,27 @@ def testFileMissingComponentFollowUpFails(self): # Failing to file the follow-up must not lose the library's bug. self.assertEqual(self.bugzillaProvider.file_bug(library, "Update broken to new version V1", "Details", []), 457) + def testCommentRejected(self): + os.environ['TASK_ID'] = 'abc123' + try: + self.bugzillaProvider.comment_on_bug(1238, "> ++ export VIRTUAL_ENV\n> ++ '[' -n /bin/bash ']'", needinfo='Jon') + finally: + del os.environ['TASK_ID'] + + sent = MockBugzillaServer.received_comments["1238"] + self.assertEqual(len(sent), 2, "Expected the rejected comment, then a replacement") + self.assertIn("Bugzilla rejected it", sent[1]['comment']['body']) + self.assertIn("https://firefox-ci-tc.services.mozilla.com/tasks/abc123", sent[1]['comment']['body']) + self.assertEqual(sent[1]['flags'][0]['requestee'], 'Jon', "The replacement should keep the needinfo") + + def testCommentRejectedWithoutTaskId(self): + self.assertIn("log of the Updatebot run", CommentTemplates.COMMENT_REJECTED(None)) + + def testCommentAndReplacementRejected(self): + with self.assertRaises(CommentRejectedError): + self.bugzillaProvider.comment_on_bug(1239, "> ++ export VIRTUAL_ENV") + self.assertEqual(len(MockBugzillaServer.received_comments["1239"]), 2, "Should give up after the replacement is rejected too") + def testStatus(self): self.bugzillaProvider.mark_ff_version_affected(456, 76)