bot/code_review_bot/report/phabricator.py (291 lines of code) (raw):

# 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 collections import defaultdict from typing import List from urllib.parse import urljoin import structlog from libmozdata.phabricator import BuildState, PhabricatorAPI from code_review_bot import Issue, Level, stats from code_review_bot.backend import BackendAPI from code_review_bot.report.base import Reporter from code_review_bot.tasks.clang_tidy_external import ExternalTidyIssue from code_review_bot.tasks.coverage import CoverageIssue from code_review_bot.tools import treeherder BUG_REPORT_URL = "https://bugzilla.mozilla.org/enter_bug.cgi?product=Developer+Infrastructure&component=Source+Code+Analysis&short_desc=[Automated+review]+THIS+IS+A+PLACEHOLDER&comment=**Phabricator+URL:**+https://phabricator.services.mozilla.com/...&format=__default__" COMMENT_FAILURE = """ Code analysis found {defects_total} in diff [{diff_id}]({phabricator_diff_url}): """ COMMENT_WARNINGS = """ WARNING: Found {nb_warnings} (warning level) that can be dismissed. """ COMMENT_ERRORS = """ IMPORTANT: Found {nb_errors} (error level) that must be fixed before landing. """ COMMENT_DIFF_FOLLOWUP = """compared to the previous diff [{diff_id}]({phabricator_diff_url}). """ COMMENT_RUN_ANALYZERS = """ You can run this analysis locally with: {analyzers} """ COMMENT_COVERAGE = """ In our previous code coverage analysis run, we found some files which had no coverage and are being modified in this patch: Should they have tests, or are they dead code? - You can file a bug blocking [Bug 1415824](https://bugzilla.mozilla.org/show_bug.cgi?id=1415824) for untested files that should be **tested**. - You can file a bug blocking [Bug 1415819](https://bugzilla.mozilla.org/show_bug.cgi?id=1415819) for untested files that should be **removed**. """ # Use two line breaks to force '---' to be rendered as a horizontal rule in Phabricator's markdown BUG_REPORT = """ If you see a problem in this automated review, [please report it here]({bug_report_url}). """ COMMENT_DIFF_DOWNLOAD = """ For your convenience, [here is a patch]({url}) that fixes all the {analyzer} defects (use it in your repository with `hg import` or `git apply -p0`). """ COMMENT_TASK_FAILURE = """ The analysis task [{name}]({url}) failed, but we could not detect any defect. Please check this task manually. """ FRONTEND_LINKS = """ You can view these defects in the Diff Detail section of [Phabricator diff {diff_id}]({phabricator_diff_url}). """ logger = structlog.get_logger(__name__) Issues = List[Issue] class PhabricatorReporter(Reporter): """ API connector to report on Phabricator """ def __init__(self, configuration={}, *args, **kwargs): if kwargs.get("api") is not None: self.setup_api(kwargs["api"]) self.analyzers_skipped = configuration.get("analyzers_skipped", []) assert isinstance( self.analyzers_skipped, list ), "analyzers_skipped must be a list" self.frontend_diff_url = configuration.get( "frontend_diff_url", "https://code-review.moz.tools/#/diff/{diff_id}" ) self.phabricator_diff_url = urljoin( configuration.get( "phabricator_base_url", "https://phabricator.services.mozilla.com" ), "differential/diff/{diff_id}/", ) # Setup Code Review backend API client self.backend_api = BackendAPI() def setup_api(self, api): assert isinstance(api, PhabricatorAPI) self.api = api logger.info("Phabricator reporter enabled") @staticmethod def pluralize(word, nb): """ Simple helper to pluralize a noun depending of the number of elements """ assert isinstance(word, str) assert isinstance(nb, int) return "{} {}".format(nb, nb == 1 and word or word + "s") def compare_issues(self, former_diff_id, issues): """ Compare new issues depending on their evolution from the previous diff on the same revision. Returns a tuple containing lists of: * Unresolved issues, that are present on both diffs * Closed issues, that were present in the previous diff and are now gone """ if not self.backend_api.enabled: logger.warning( "Backend API must be enabled to compare issues with previous diff {former_diff_id}." ) return [], [] # If this is the first diff, there's no need to compare issues with a # previous diff since there is no previous diff. if former_diff_id is None: return [], [] # Retrieve issues related to the previous diff try: previous_issues = self.backend_api.list_diff_issues(former_diff_id) except Exception as e: logger.warning( f"An error occurred listing issues on previous diff {former_diff_id}: {e}. " "Each issue will be considered as a new case." ) previous_issues = [] # Multiple issues may share a similar hash in case they were # produced by the same linter on the same lines indexed_issues = defaultdict(list) for issue in previous_issues: indexed_issues[issue["hash"]].append(issue) # Compare current issues with the previous ones based on the hash unresolved = [ issue.on_backend["hash"] for issue in issues if issue.on_backend and issue.on_backend["hash"] in indexed_issues ] # All previous issues that are not unresolved are closed closed = [issue for issue in previous_issues if issue["hash"] not in unresolved] return unresolved, closed def publish(self, issues, revision, task_failures, notices, reviewers): """ Publish issues on Phabricator: * publishable issues use lint results * build errors are displayed as unit test results """ # Add extra reviewers groups to the revision if reviewers: phids = [] for reviewers_group in reviewers: data = self.api.search_projects(slugs=[reviewers_group]) if not data or "phid" not in data[0]: logger.warning( f'Unable to find the PHID of the reviewers group identified by the slug "{reviewers_group}"' ) continue phids.append(data[0]["phid"]) if phids: self.api.edit_revision( revision.phabricator_id, [ { "type": "reviewers.add", "value": phids, } ], ) # Use only new and publishable issues and patches # Avoid publishing a patch from a de-activated analyzer publishable_issues = [ issue for issue in issues if issue.is_publishable() and issue.analyzer.name not in self.analyzers_skipped ] patches = [ patch for patch in revision.improvement_patches if patch.analyzer.name not in self.analyzers_skipped ] if publishable_issues: # Publish detected patch's issues on Harbormaster, all at once, as lint issues self.publish_harbormaster(revision, publishable_issues) # Retrieve all diffs for the current revision rev_diffs = self.api.search_diffs(revision_phid=revision.phabricator_phid) if any(diff["id"] > revision.diff_id for diff in rev_diffs): logger.warning( "A newer diff exists on this patch, skipping the comment publication" ) return publishable_issues, patches # Compare issues that are not known on the repository to a previous diff older_diff_ids = [ diff["id"] for diff in rev_diffs if diff["id"] < revision.diff_id ] former_diff_id = sorted(older_diff_ids)[-1] if older_diff_ids else None unresolved_issues, closed_issues = self.compare_issues( former_diff_id, publishable_issues ) if ( len(unresolved_issues) == len(publishable_issues) and not closed_issues and not task_failures and not notices ): # Nothing changed, no issue have been opened or closed logger.info( "No new issues nor failures/notices were detected. " "Skipping comment publication (some issues are unresolved)", unresolved_count=len(unresolved_issues), ) return publishable_issues, patches # Publish comment summarizing detected, unresolved and closed issues self.publish_summary( revision, publishable_issues, patches, task_failures, notices, former_diff_id=former_diff_id, unresolved_count=len(unresolved_issues), closed_count=len(closed_issues), ) # Publish statistics stats.add_metric("report.phabricator.issues", len(issues)) stats.add_metric("report.phabricator") return publishable_issues, patches def publish_harbormaster( self, revision, lint_issues: Issues = [], unit_issues: Issues = [] ): """ Publish issues through HarborMaster either as lint results or unit tests results """ assert lint_issues or unit_issues, "No issues to publish" self.api.update_build_target( revision.build_target_phid, state=BuildState.Work, lint=[issue.as_phabricator_lint() for issue in lint_issues], unit=[issue.as_phabricator_unitresult() for issue in unit_issues], ) logger.info( "Updated Harbormaster build state with issues", nb_lint=len(lint_issues), nb_unit=len(unit_issues), ) def publish_summary( self, revision, issues, patches, task_failures, notices, former_diff_id, unresolved_count, closed_count, ): """ Summarize publishable issues through Phabricator comment """ self.api.comment( revision.phabricator_id, self.build_comment( revision=revision, issues=issues, patches=patches, bug_report_url=BUG_REPORT_URL, task_failures=task_failures, notices=notices, former_diff_id=former_diff_id, unresolved=unresolved_count, closed=closed_count, ), ) logger.info("Published phabricator summary") def build_comment( self, revision, issues, bug_report_url, notices, patches=[], task_failures=[], former_diff_id=None, unresolved=0, closed=0, ): """ Build a Markdown comment about published issues """ comment = "" # Comment with an absolute link to the revision diff in Phabricator # Relative links are not supported in comment and non readable in related emails phabricator_diff_url = self.phabricator_diff_url.format( diff_id=revision.diff_id ) # Build main comment for issues inside the patch if (nb := len(issues)) > 0: comment = COMMENT_FAILURE.format( defects_total=self.pluralize("defect", nb), diff_id=revision.diff_id, phabricator_diff_url=phabricator_diff_url, ) # Calc stats for issues inside the patch, grouped by class and sorted by number of issues defects = [] stats = self.calc_stats(issues) for stat in sorted(stats, key=lambda x: (x["total"], x["analyzer"])): defect_nb = [] if stat["nb_build_errors"] > 0: defect_nb.append(self.pluralize("build error", stat["nb_build_errors"])) if stat["nb_defects"] > 0: defect_nb.append(self.pluralize("defect", stat["nb_defects"])) defects.append( " - {nb} found by {analyzer}".format( analyzer=stat["analyzer"], nb=" and ".join(defect_nb) ) ) if defects: comment += "\n".join(defects) + "\n" # In case of a new diff, display the number of resolved or closed issues if unresolved or closed: followup_comment = "" if unresolved: followup_comment += ( self.pluralize("defect", unresolved) + " unresolved " ) if closed: followup_comment += "and " if closed: followup_comment += self.pluralize("defect", closed) + " closed " followup_comment += COMMENT_DIFF_FOLLOWUP.format( phabricator_diff_url=self.phabricator_diff_url.format( diff_id=former_diff_id ), diff_id=former_diff_id, ) comment += followup_comment # Add colored warning section total_warnings = sum(1 for i in issues if i.level == Level.Warning) if total_warnings: comment += COMMENT_WARNINGS.format( nb_warnings=self.pluralize("defect", total_warnings) ) # Add colored error section total_errors = sum(1 for i in issues if i.level == Level.Error) if total_errors: comment += COMMENT_ERRORS.format( nb_errors=self.pluralize("defect", total_errors) ) # Build analyzers help comment for all issues analyzers = set() for stat in stats: _help = stat.get("help") if _help is not None: analyzers.add(f" - {_help}") if analyzers: comment += COMMENT_RUN_ANALYZERS.format(analyzers="\n".join(analyzers)) for tidy_external_issue in filter( lambda i: isinstance(i, ExternalTidyIssue), issues ): comment += tidy_external_issue.as_markdown_for_phab() for patch in patches: comment += COMMENT_DIFF_DOWNLOAD.format( analyzer=patch.analyzer.display_name, url=patch.url or patch.path ) for task in task_failures: treeherder_url = treeherder.get_job_url( revision.repository_try_name, revision.head_changeset, task.id, task.run_id, ) comment += COMMENT_TASK_FAILURE.format(name=task.name, url=treeherder_url) # Add coverage reporting details when a coverage issue is published issue_classes = {issue.__class__ for issue in issues} if CoverageIssue in issue_classes: comment += COMMENT_COVERAGE if notices: # Use two line breaks to force '---' to be rendered as a horizontal rule in Phabricator's markdown comment += "\n\n---\n".join(notices) assert comment != "", "Empty comment" # Display more information in the footer section comment += "\n\n---\n" comment += BUG_REPORT.format(bug_report_url=bug_report_url) if defects: comment += FRONTEND_LINKS.format( diff_id=revision.diff_id, phabricator_diff_url=phabricator_diff_url, ) return comment