def get_review_for_database_id(pull_request_id: str, review_db_id: str) -> Optional[Review]: """Get the PullRequestReview given a pull request and the NUMERIC id id of the review. NOTE: `pull_request_id` and `review_db_id are DIFFERENT types of ids. The github API has two ids for each object: `id`: a base64-encoded string (also known as "node_id"). `databaseId`: the primary key from the database. In this function: @pull_request_id is the `id` for the pull request. @review_db_id is the `databaseId` for the review. Unfortunately, this requires iterating through all reviews on the given pull request. See https://developer.github.com/v4/object/repository/#fields """ data = _execute_graphql_query(IterateReviews, {"pullRequestId": pull_request_id}) while data["node"]["reviews"]["edges"]: try: match = next((e["node"] for e in data["node"]["reviews"]["edges"] if e["node"]["databaseId"] == review_db_id)) return Review(match) except StopIteration: # no matching reviews, continue. data = _execute_graphql_query( IterateReviews, { "pullRequestId": pull_request_id, "cursor": data["node"]["reviews"]["edges"][-1]["cursor"], }, ) return None
def get_pull_request_and_review(pull_request_id: str, review_id: str) -> Tuple[PullRequest, Review]: data = _execute_graphql_query( GetPullRequestAndReview, { "pullRequestId": pull_request_id, "reviewId": review_id }, ) return PullRequest(data["pullRequest"]), Review(data["review"])
def _handle_pull_request_review_comment(payload: dict): """Handle when a pull request review comment is edited or removed. When comments are added it either hits: 1 _handle_issue_comment_webhook (if the comment is on PR itself) 2 _handle_pull_request_review_webhook (if the comment is on the "Files Changed" tab) Note that it hits (2) even if the comment is inline, and doesn't contain a review; in those cases Github still creates a review object for it. Unfortunately, this payload doesn't contain the node id of the review. Instead, it includes a separate, numeric id which is stored as `databaseId` on each GraphQL object. To get the review, we either: (1) query for the comment, and use the `review` edge in GraphQL. (2) Iterate through all reviews on the pull request, and find the one whose databaseId matches. See get_review_for_database_id() We do (1) for comments that were added or edited, but if a comment was just deleted, we have to do (2). See https://developer.github.com/v4/object/repository/#fields. """ pull_request_id = payload["pull_request"]["node_id"] action = payload["action"] comment_id = payload["comment"]["node_id"] # This is NOT the node_id, but is a numeric string (the databaseId field). review_database_id = payload["comment"]["pull_request_review_id"] with dynamodb_lock(pull_request_id): if action in ("created", "edited"): pull_request, comment = graphql_client.get_pull_request_and_comment( pull_request_id, comment_id) if not isinstance(comment, PullRequestReviewComment): raise Exception( f"Unexpected comment type {type(PullRequestReviewComment)} for pull request review" ) review: Optional[Review] = Review.from_comment(comment) elif action == "deleted": pull_request = graphql_client.get_pull_request(pull_request_id) review = graphql_client.get_review_for_database_id( pull_request_id, review_database_id) if review is None: # If we deleted the last comment from a review, Github might have deleted the review. # If so, we should delete the Asana comment. logger.info( "No review found in Github. Deleting the Asana comment.") github_controller.delete_comment(comment_id) else: raise ValueError(f"Unexpected action: {action}") if review is not None: github_controller.upsert_review(pull_request, review) return HttpResponse("200")
def upsert_github_review_to_task(review: Review, task_id: str): github_review_id = review.id() asana_comment_id = dynamodb_client.get_asana_id_from_github_node_id( github_review_id) if asana_comment_id is None: logger.info(f"Adding review {github_review_id} to task {task_id}") asana_comment_id = asana_client.add_comment( task_id, asana_helpers.asana_comment_from_github_review(review)) dynamodb_client.insert_github_node_to_asana_id_mapping( github_review_id, asana_comment_id) else: logger.info( f"Review {github_review_id} already synced to task {task_id}. Updating." ) asana_client.update_comment( asana_comment_id, asana_helpers.asana_comment_from_github_review(review)) dynamodb_client.bulk_insert_github_node_to_asana_id_mapping([ (c.id(), asana_comment_id) for c in review.comments() ])
def upsert_review(pull_request: PullRequest, review: Review): pull_request_id = pull_request.id() task_id = dynamodb_client.get_asana_id_from_github_node_id(pull_request_id) if task_id is None: logger.info( f"Task not found for pull request {pull_request_id}. Running a full sync!" ) # TODO: Full sync else: logger.info( f"Found task id {task_id} for pull_request {pull_request_id}. Adding review now." ) asana_controller.upsert_github_review_to_task(review, task_id) if review.is_approval_or_changes_requested(): assign_pull_request_to_author(pull_request) asana_controller.update_task(pull_request, task_id)
def asana_comment_from_github_review(review: Review) -> str: """ Extracts the GitHub author and comments from a GitHub Review, and transforms them into a suitable html comment string for Asana. This will involve looking up the GitHub author in DynamoDb to determine the Asana domain user id of the review author and any @mentioned GitHub users. """ user_display_name = _asana_display_name_for_github_user(review.author()) if review.is_just_comments(): # When a user replies to an inline comment, # or writes inline comments without a review, # github still creates a Review object, # even though nothing in github looks like a review # If that's the case, there is no meaningful review state ("commented" isn't helpful) # and the link to it will either not point anywhere, or be less useful than the individual links on each comment. review_action = _wrap_in_tag("strong")("left inline comments:\n") else: review_action = _wrap_in_tag("A", attrs={"href": review.url()})( _review_action_to_text_map.get(review.state(), "commented")) review_body = _format_github_text_for_asana(review.body()) if review_body: header = (_wrap_in_tag("strong") (f"{user_display_name} {review_action} :\n") + review_body) else: header = _wrap_in_tag("strong")(f"{user_display_name} {review_action}") # For each comment, prefix its text with a bracketed number that is a link to the Github comment. inline_comments = [ _wrap_in_tag("li") (_wrap_in_tag("A", attrs={"href": comment.url()})(f"[{i}] ") + _format_github_text_for_asana(comment.body())) for i, comment in enumerate(review.comments(), start=1) ] if inline_comments: comments_html = _wrap_in_tag("ul")("".join(inline_comments)) if not review.is_just_comments(): # If this was an inline reply, we already added "and left inline comments" above. comments_html = ( _wrap_in_tag("strong")("\n\nand left inline comments:\n") + comments_html) else: comments_html = "" return _wrap_in_tag("body")(header + comments_html)
def test_with_status_of_approved__is_just_comments_is_false(self): raw_review = {"state": "APPROVED", "body": ""} review = Review(raw_review) self.assertEqual(review.is_just_comments(), False)
def test_with_status_of_commented_and_populated_body__is_just_comments_is_false( self, ): raw_review = {"state": "COMMENTED", "body": "Here's a body!"} review = Review(raw_review) self.assertEqual(review.is_just_comments(), False)
def test_with_status_of_commented_and_empty_body__is_just_comments_is_true( self): raw_review = {"state": "COMMENTED", "body": ""} review = Review(raw_review) self.assertEqual(review.is_just_comments(), True)
def test_with_status_of_changes_requested__is_just_comments_is_false(self): raw_review = {"state": "CHANGES_REQUESTED", "body": ""} review = Review(raw_review) self.assertEqual(review.is_just_comments(), False)
def build(self) -> Review: return Review(self.raw_review)