def _render_author_response(phid, user_search_data): author = user_search_data.get(phid, {}) return { 'phid': PhabricatorClient.expect(author, 'phid'), 'username': PhabricatorClient.expect(author, 'fields', 'username'), 'real_name': PhabricatorClient.expect(author, 'fields', 'realName'), }
def collate_reviewer_attachments(reviewers, reviewers_extra): """Return collated reviewer data. Args: reviewers: Data from the 'reviewers' attachment of differential.revision.search. reviewers_extra: Data from the 'reviewers-extra' attachment of differential.revision.search. """ phids = {} for reviewer in reviewers: data = {} for k in ('reviewerPHID', 'isBlocking', 'actorPHID'): data[k] = PhabricatorClient.expect(reviewer, k) data['status'] = ReviewerStatus.from_status( PhabricatorClient.expect(reviewer, 'status') ) phids[data['reviewerPHID']] = data for reviewer in reviewers_extra: data = {} for k in ('reviewerPHID', 'diffPHID', 'voidedPHID'): data[k] = PhabricatorClient.expect(reviewer, k) data.update(phids.get(data['reviewerPHID'], {})) phids[data['reviewerPHID']] = data if len(phids) > min(len(reviewers), len(reviewers_extra)): raise PhabricatorCommunicationException( 'Phabricator responded with unexpected data' ) return phids
def warning_previously_landed(*, revision, diff, **kwargs): revision_id = PhabricatorClient.expect(revision, "id") diff_id = PhabricatorClient.expect(diff, "id") landed_transplant = ( Transplant.revisions_query([revision_id]) .filter_by(status=TransplantStatus.landed) .order_by(Transplant.updated_at.desc()) .first() ) if landed_transplant is None: return None landed_diff_id = landed_transplant.revision_to_diff_id[str(revision_id)] same = diff_id == landed_diff_id only_revision = len(landed_transplant.revision_order) == 1 return ( "Already landed with {is_same_string} diff ({landed_diff_id}), " "pushed {push_string} {commit_sha}.".format( is_same_string=("the same" if same else "an older"), landed_diff_id=landed_diff_id, push_string=("as" if only_revision else "with new tip"), commit_sha=landed_transplant.result, ) )
def get_landable_repos_for_revision_data(revision_data, supported_repos): """Return a dictionary mapping string PHID to landable Repo Args: revision_data: A RevisionData. supported_repos: A dictionary mapping repository shortname to a Repo for repositories lando supports. Returns: A dictionary where each key is a string PHID for a repository from revision_data and the value is a Repo taken from supported_repos. Repositories in revision_data which are unsupported will not be present in the dictionary. """ repo_phids = { PhabricatorClient.expect(revision, "fields", "repositoryPHID") for revision in revision_data.revisions.values() if PhabricatorClient.expect(revision, "fields", "repositoryPHID") } repos = { phid: supported_repos.get( PhabricatorClient.expect( revision_data.repositories[phid], "fields", "shortName" ) ) for phid in repo_phids if phid in revision_data.repositories } return {phid: repo for phid, repo in repos.items() if repo}
def create_approval_request(phab: PhabricatorClient, revision: dict, form_content: str): """Update an existing revision with reviewers & form comment""" release_managers = get_release_managers(phab) rev = phab.call_conduit( "differential.revision.edit", objectIdentifier=revision["phid"], transactions=[ # Set release managers as reviewers {"type": "reviewers.add", "value": [release_managers["phid"]]}, # Post the form as a comment on the revision {"type": "comment", "value": form_content}, ], ) rev_id = phab.expect(rev, "object", "id") rev_phid = phab.expect(rev, "object", "phid") assert rev_id == revision["id"], "Revision id mismatch" logger.info("Updated Phabricator revision", extra={"id": rev_id, "phid": rev_phid}) return { "mode": "approval", "url": f"{phab.url_base}/D{rev_id}", "revision_id": rev_id, "revision_phid": rev_phid, }
def check_approval_state( phab: PhabricatorClient, revision_id: int, target_repository_name: str ) -> dict: """Helper to load the Phabricator revision and check its approval requirement state * if the revision's target repository is the same as its current repository, it's an approval * otherwise it's an uplift request """ # Load target repo from Phabricator target_repo = phab.call_conduit( "diffusion.repository.search", constraints={"shortNames": [target_repository_name]}, ) target_repo = phab.single(target_repo, "data") target_repo_phid = phab.expect(target_repo, "phid") # Load base revision details from Phabricator revision = phab.call_conduit( "differential.revision.search", constraints={"ids": [revision_id]} ) revision = phab.single(revision, "data") revision_repo_phid = phab.expect(revision, "fields", "repositoryPHID") # Lookup if this is an uplift or an approval request is_approval = target_repo_phid == revision_repo_phid return (is_approval, revision, target_repo)
def serialize_author(phid, user_search_data): out = {"phid": phid, "username": None, "real_name": None} author = user_search_data.get(phid) if author is not None: out["username"] = PhabricatorClient.expect(author, "fields", "username") out["real_name"] = PhabricatorClient.expect(author, "fields", "realName") return out
def check_landing_blockers( auth0_user, requested_path, stack_data, landable_paths, landable_repos, *, user_blocks=[user_block_no_auth0_email, user_block_scm_level]): revision_path = [] revision_to_diff_id = {} for revision_phid, diff_id in requested_path: revision_path.append(revision_phid) revision_to_diff_id[revision_phid] = diff_id # Check that the provided path is a prefix to, or equal to, # a landable path. for path in landable_paths: if revision_path == path[:len(revision_path)]: break else: return TransplantAssessment( blocker="The requested set of revisions are not landable.") # Check the requested diffs are the latest. for revision_phid in revision_path: latest_diff_phid = PhabricatorClient.expect( stack_data.revisions[revision_phid], "fields", "diffPHID") latest_diff_id = PhabricatorClient.expect( stack_data.diffs[latest_diff_phid], "id") if latest_diff_id != revision_to_diff_id[revision_phid]: return TransplantAssessment( blocker="A requested diff is not the latest.") # Check if there is already a landing for something in the stack. if (Transplant.revisions_query([ PhabricatorClient.expect(r, "id") for r in stack_data.revisions.values() ]).filter_by(status=TransplantStatus.submitted).first() is not None): return TransplantAssessment(blocker=( "A landing for revisions in this stack is already in progress.")) # To be a landable path the entire path must have the same # repository, so we can get away with checking only one. repo = landable_repos[stack_data.revisions[revision_path[0]]["fields"] ["repositoryPHID"]] # Check anything that would block the current user from # landing this. for block in user_blocks: result = block(auth0_user=auth0_user, landing_repo=repo) if result is not None: return TransplantAssessment(blocker=result) return TransplantAssessment()
def check(cls, *, get_revision, get_diff, **kwargs): diff, _ = get_diff() revision = get_revision() if (PhabricatorClient.expect(revision, 'phid') != PhabricatorClient.expect(diff, 'fields', 'revisionPHID')): raise ProblemException( 400, 'Diff not related to the revision', 'The requested diff is not related to the requested revision.', type='https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400' # noqa ) # yapf: disable
def get_raw_comments(transaction): """Return a list of 'raw' comment bodies in a Phabricator transaction. A single transaction can have multiple comment bodies: e.g. a top-level comment and a couple of inline comments along with it. See https://phabricator.services.mozilla.com/conduit/method/transaction.search/. """ return [ PhabricatorClient.expect(comment, "content", "raw") for comment in PhabricatorClient.expect(transaction, "comments") ]
def lazy_get_reviewers(revision): """Return a dictionary mapping phid to collated reviewer attachment data. Args: revision: A dict of the revision data from differential.revision.search with the 'reviewers' and 'reviewers-extra' attachments. """ attachments = PhabricatorClient.expect(revision, 'attachments') return collate_reviewer_attachments( PhabricatorClient.expect(attachments, 'reviewers', 'reviewers'), PhabricatorClient.expect(attachments, 'reviewers-extra', 'reviewers-extra'))
def serialize_author(phid, user_search_data): out = { 'phid': phid, 'username': None, 'real_name': None, } author = user_search_data.get(phid) if author is not None: out['username'] = PhabricatorClient.expect(author, 'fields', 'username') out['real_name'] = PhabricatorClient.expect(author, 'fields', 'realName') return out
def _convert_path_id_to_phid(path, stack_data): mapping = { PhabricatorClient.expect(r, "id"): PhabricatorClient.expect(r, "phid") for r in stack_data.revisions.values() } try: return [(mapping[r], d) for r, d in path] except IndexError: ProblemException( 400, "Landing Path Invalid", "The provided landing_path is not valid.", type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400", )
def _render_diff_response(querydiffs_data): return { 'id': int(PhabricatorClient.expect(querydiffs_data, 'id')), 'date_created': _epoch_to_isoformat_time( PhabricatorClient.expect(querydiffs_data, 'dateCreated') ), 'date_modified': _epoch_to_isoformat_time( PhabricatorClient.expect(querydiffs_data, 'dateModified') ), 'author': { 'name': querydiffs_data.get('authorName', ''), 'email': querydiffs_data.get('authorEmail', ''), }, } # yapf: disable
def serialize_diff(diff): author_name, author_email = select_diff_author(diff) fields = PhabricatorClient.expect(diff, "fields") return { "id": PhabricatorClient.expect(diff, "id"), "phid": PhabricatorClient.expect(diff, "phid"), "date_created": PhabricatorClient.to_datetime( PhabricatorClient.expect(fields, "dateCreated") ).isoformat(), "date_modified": PhabricatorClient.to_datetime( PhabricatorClient.expect(fields, "dateModified") ).isoformat(), "author": {"name": author_name or "", "email": author_email or ""}, }
def warning_not_accepted(*, revision, **kwargs): status = RevisionStatus.from_status( PhabricatorClient.expect(revision, "fields", "status", "value")) if status is RevisionStatus.ACCEPTED: return None return status.output_name
def _assess_transplant_request(phab, landing_path): nodes, edges = _find_stack_from_landing_path(phab, landing_path) stack_data = request_extended_revision_data(phab, [phid for phid in nodes]) landing_path = _convert_path_id_to_phid(landing_path, stack_data) supported_repos = get_repos_for_env(current_app.config.get('ENVIRONMENT')) landable_repos = get_landable_repos_for_revision_data( stack_data, supported_repos) landable, blocked = calculate_landable_subgraphs( stack_data, edges, landable_repos, other_checks=DEFAULT_OTHER_BLOCKER_CHECKS) assessment = check_landing_blockers( g.auth0_user, landing_path, stack_data, landable, landable_repos, ) if assessment.blocker is not None: return (assessment, None, None, None) # We have now verified that landable_path is valid and is indeed # landable (in the sense that it is a landable_subgraph, with no # revisions being blocked). Make this clear by using a different # value, and assume it going forward. valid_path = landing_path # Now that we know this is a valid path we can convert it into a list # of (revision, diff) tuples. to_land = [stack_data.revisions[r_phid] for r_phid, _ in valid_path] to_land = [ (r, stack_data.diffs[PhabricatorClient.expect(r, 'fields', 'diffPHID')]) for r in to_land ] # To be a landable path the entire path must have the same # repository, so we can get away with checking only one. repo = stack_data.repositories[to_land[0][0]['fields']['repositoryPHID']] landing_repo = landable_repos[repo['phid']] involved_phids = set() for revision, _ in to_land: involved_phids.update(gather_involved_phids(revision)) involved_phids = list(involved_phids) users = lazy_user_search(phab, involved_phids)() projects = lazy_project_search(phab, involved_phids)() reviewers = { revision['phid']: get_collated_reviewers(revision) for revision, _ in to_land } assessment = check_landing_warnings(g.auth0_user, to_land, repo, landing_repo, reviewers, users, projects) return (assessment, to_land, landing_repo, stack_data)
def check_author_planned_changes(*, revision, **kwargs): status = RevisionStatus.from_status( PhabricatorClient.expect(revision, "fields", "status", "value")) if status is not RevisionStatus.CHANGES_PLANNED: return None return "The author has indicated they are planning changes to this revision."
def transaction_search(phabricator, object_identifier, transaction_phids=None, limit=100): """Yield the Phabricator transactions related to an object. See https://phabricator.services.mozilla.com/conduit/method/transaction.search/. If the transaction list is larger that one page of API results then the generator will call the Phabricator API successive times to fetch the full transaction list. Args: phabricator: A PhabricatorClient instance. object_identifier: An object identifier (PHID or monogram) whose transactions we want to fetch. transaction_phids: An optional list of specific transactions PHIDs we want to find for the given object_identifier. limit: Integer keyword, limit the number of records retrieved per API call. Default is 100 records. Returns: Yields individual transactions. """ next_page_start = None if transaction_phids: constraints = {"phids": transaction_phids} else: constraints = {} while True: transactions = phabricator.call_conduit( "transaction.search", objectIdentifier=object_identifier, constraints=constraints, limit=limit, after=next_page_start, ) yield from PhabricatorClient.expect(transactions, "data") next_page_start = PhabricatorClient.expect(transactions, "cursor", "after") if next_page_start is None: # This was the last page of results. return
def reviewer_identity(phid, user_search_data, project_search_data): if phid in user_search_data: return ReviewerIdentity( PhabricatorClient.expect(user_search_data, phid, "fields", "username"), PhabricatorClient.expect(user_search_data, phid, "fields", "realName"), ) if phid in project_search_data: name = PhabricatorClient.expect(project_search_data, phid, "fields", "name") return ReviewerIdentity(name, name) logger.info("reviewer was missing from user / project search data", extra={"phid": phid}) return ReviewerIdentity("<unknown>", "Unknown User/Project")
def lazy_get_revision_status(revision): """Return a landoapi.phabricator.RevisionStatus. Args: revision: A dict of the revision data just as it is returned by Phabricator. """ return RevisionStatus.from_status( PhabricatorClient.expect(revision, 'fields', 'status', 'value'))
def serialize_diff(diff): author_name, author_email = select_diff_author(diff) fields = PhabricatorClient.expect(diff, 'fields') return { 'id': PhabricatorClient.expect(diff, 'id'), 'phid': PhabricatorClient.expect(diff, 'phid'), 'date_created': PhabricatorClient.to_datetime( PhabricatorClient.expect(fields, 'dateCreated') ).isoformat(), 'date_modified': PhabricatorClient.to_datetime( PhabricatorClient.expect(fields, 'dateModified') ).isoformat(), 'author': { 'name': author_name or '', 'email': author_email or '', }, } # yapf: disable
def select_diff_author(diff): commits = PhabricatorClient.expect(diff, "attachments", "commits", "commits") if not commits: return None, None authors = [c.get("author", {}) for c in commits] authors = Counter((a.get("name"), a.get("email")) for a in authors) authors = authors.most_common(1) return authors[0][0] if authors else (None, None)
def select_diff_author(diff): commits = PhabricatorClient.expect(diff, 'attachments', 'commits', 'commits') if not commits: return None, None authors = [c.get('author', {}) for c in commits] authors = Counter((a.get('name'), a.get('email')) for a in authors) authors = authors.most_common(1) return authors[0][0] if authors else (None, None)
def reviewer_identity(phid, user_search_data, project_search_data): if phid in user_search_data: return ReviewerIdentity( PhabricatorClient.expect(user_search_data, phid, 'fields', 'username'), PhabricatorClient.expect(user_search_data, phid, 'fields', 'realName')) if phid in project_search_data: name = PhabricatorClient.expect(project_search_data, phid, 'fields', 'name') return ReviewerIdentity(name, name) logger.info( { 'msg': 'A reviewer was missing from user / project search data', 'phid': phid, }, 'reviewer.unknown') return ReviewerIdentity('<unknown>', 'Unknown User/Project')
def check(cls, *, get_revision, get_landing_repo, **kwargs): if not PhabricatorClient.expect(get_revision(), 'fields', 'repositoryPHID'): return cls( 'This revision is not associated with a repository. ' 'In order to land, a revision must be associated with a ' 'repository on Phabricator.') return None if get_landing_repo() else cls( 'The repository this revision is associated with is not ' 'supported by Lando at this time.')
def check(cls, *, get_open_parents, **kwargs): open_parents = get_open_parents() if open_parents: open_text = ', '.join( 'D{}'.format(PhabricatorClient.expect(r, 'id')) for r in open_parents) if len(open_parents) > 1: return cls('This revision depends on the following revisions ' 'which are still open: {}'.format(open_text)) else: return cls('This revision depends on the following revision ' 'which is still open: {}'.format(open_text))
def build(cls, revision, transactions): """Build a `SecApprovalRequest` object for a transaction list. Args: revision: The Phabricator API revision object that we requested sec-approval for. transactions: A list Phabricator transaction data results related to the sec-approval event that we want to save. Returns: A `SecApprovalRequest` that is ready to be added to the session. """ possible_comment_phids = [] for transaction in transactions: phid = PhabricatorClient.expect(transaction, "phid") possible_comment_phids.append(phid) return cls( revision_id=revision["id"], diff_phid=PhabricatorClient.expect(revision, "fields", "diffPHID"), comment_candidates=possible_comment_phids, )
def warning_revision_secure(*, revision, secure_project_phid, **kwargs): if secure_project_phid is None: return None revision_project_tags = PhabricatorClient.expect(revision, "attachments", "projects", "projectPHIDs") if secure_project_phid not in revision_project_tags: return None return ( "This revision is tied to a secure bug. Ensure that you are following the " "Security Bug Approval Process guidelines before landing this changeset." )
def lazy_get_reviewers_extra_state(reviewers, diff): """Return a dictionary mapping phid to extra reviewer state. Args: reviewers: a dictionary mapping phid to collated reviewer attachment data diff: diff data from the Phabricator API """ diff_phid = PhabricatorClient.expect(diff[0], 'phid') return { phid: calculate_review_extra_state(diff_phid, r['status'], r['diffPHID'], r['voidedPHID']) for phid, r in reviewers.items() }