def rescan_repository(self, repo): """ rescans a single repo for new prs """ github = github_bp.session sentry_extra_context({"repo": repo}) url = "/repos/{repo}/pulls".format(repo=repo) created = {} if not self.request.called_directly: self.update_state(state='STARTED', meta={'repo': repo}) def page_callback(response): if not response.ok or self.request.called_directly: return current_url = URLObject(response.url) current_page = int(current_url.query_dict.get("page", 1)) link_last = response.links.get("last") if link_last: last_url = URLObject(link_last['url']) last_page = int(last_url.query_dict["page"]) else: last_page = current_page state_meta = { "repo": repo, "current_page": current_page, "last_page": last_page } self.update_state(state='STARTED', meta=state_meta) for pull_request in paginated_get(url, session=github, callback=page_callback): sentry_extra_context({"pull_request": pull_request}) issue_key = get_jira_issue_key(pull_request) is_internal = is_internal_pull_request(pull_request) if not issue_key and not is_internal: # `pull_request_opened()` is a celery task, but by calling it as # a function instead of calling `.delay()` on it, we're eagerly # executing the task now, instead of adding it to the task queue # so it is executed later. As a result, this will return the values # that the `pull_request_opened()` function returns, rather than # return an AsyncResult object. issue_key, issue_created = pull_request_opened(pull_request) # pylint: disable=no-value-for-parameter if issue_created: created[pull_request["number"]] = issue_key logger.info( "Created {num} JIRA issues on repo {repo}. PRs are {prs}".format( num=len(created), repo=repo, prs=created.keys(), ), ) info = {"repo": repo} if created: info["created"] = created return info
def rescan_repository(self, repo): """ rescans a single repo for new prs """ github = github_bp.session sentry.client.extra_context({"repo": repo}) url = "/repos/{repo}/pulls".format(repo=repo) created = {} if not self.request.called_directly: self.update_state(state='STARTED', meta={'repo': repo}) def page_callback(response): if not response.ok or self.request.called_directly: return current_url = URLObject(response.url) current_page = int(current_url.query_dict.get("page", 1)) link_last = response.links.get("last") if link_last: last_url = URLObject(link_last['url']) last_page = int(last_url.query_dict["page"]) else: last_page = current_page state_meta = { "repo": repo, "current_page": current_page, "last_page": last_page } self.update_state(state='STARTED', meta=state_meta) for pull_request in paginated_get(url, session=github, callback=page_callback): sentry.client.extra_context({"pull_request": pull_request}) issue_key = get_jira_issue_key(pull_request) is_internal = is_internal_pull_request(pull_request) if not issue_key and not is_internal: # `pull_request_opened()` is a celery task, but by calling it as # a function instead of calling `.delay()` on it, we're eagerly # executing the task now, instead of adding it to the task queue # so it is executed later. As a result, this will return the values # that the `pull_request_opened()` function returns, rather than # return an AsyncResult object. issue_key, issue_created = pull_request_opened(pull_request) if issue_created: created[pull_request["number"]] = issue_key logger.info( "Created {num} JIRA issues on repo {repo}. PRs are {prs}".format( num=len(created), repo=repo, prs=created.keys(), ), ) info = {"repo": repo} if created: info["created"] = created return info
def pull_request_comment(comment_event): """ Process a comment on a pull request. """ # Creates object that works with get_jira_issue_key, may want to refactor that # function later. repo = comment_event["repository"]["name"] issue_number = comment_event["issue"]["number"] pr = {"base": {"repo": {"full_name": repo}}, "number": issue_number } issue_key = get_jira_issue_key(pr) # Currently we stop quietly if there's no pre-existing ticket in OSPR if not issue_key: msg = "No OSPR ticket for PR #{num}".format(num=issue_number,) logger.info(msg) return None, False triaged_issue = issue_to_triage(issue_key) # Move JIRA ticket to triage if not triaged_issue: msg = "Unable to move PR #{num} to 'Needs Triage on Jira'".format(num=issue_number,) logger.info(msg) return None, False # Add the "Needs Triage" label to the PR on Github issue_url = "/repos/{repo}/issues/{num}".format(repo=repo, num=issue_number) label_resp = github.patch(issue_url, data=json.dumps({"labels": ["needs triage", "open-source-contribution"]})) label_resp.raise_for_status() # Comment on Github comment = { "body": "Thanks for the nudge! Your pull request has been added to our list for review. You can see " + \ "your ticket on JIRA here: " + str(triaged_issue), } url = "/repos/{repo}/issues/{num}/comments".format( repo=repo, num=issue_number, ) comment_resp = github.post(url, json=comment) comment_resp.raise_for_status() return issue_key, True
def pull_request_opened(pull_request, ignore_internal=True, check_contractor=True): """ Process a pull request. This is called when a pull request is opened, or when the pull requests of a repo are re-scanned. By default, this function will ignore internal pull requests, and will add a comment to pull requests made by contractors (if if has not yet added a comment). However, this function can be called in such a way that it processes those pull requests anyway. This function must be idempotent. Every time the repositories are re-scanned, this function will be called for pull requests that have already been opened. As a result, it should not comment on the pull request without checking to see if it has *already* commented on the pull request. Returns a 2-tuple. The first element in the tuple is the key of the JIRA issue associated with the pull request, if any, as a string. The second element in the tuple is a boolean indicating if this function did any work, such as making a JIRA issue or commenting on the pull request. """ github = github_bp.session pr = pull_request user = pr["user"]["login"].decode('utf-8') repo = pr["base"]["repo"]["full_name"] num = pr["number"] is_internal_pr = is_internal_pull_request(pr) has_cl = has_internal_cover_letter(pr) is_beta = is_beta_tester_pull_request(pr) if is_internal_pr and not has_cl and is_beta: logger.info( "Adding cover letter template to PR #{num} against {repo}".format( repo=repo, num=num, ), ) comment = { "body": github_internal_cover_letter(pr), } url = "/repos/{repo}/issues/{num}/comments".format( repo=repo, num=num, ) comment_resp = github.post(url, json=comment) comment_resp.raise_for_status() if ignore_internal and is_internal_pr: # not an open source pull request, don't create an issue for it logger.info( "@{user} opened PR #{num} against {repo} (internal PR)".format( user=user, repo=repo, num=num, ), ) return None, False if check_contractor and is_contractor_pull_request(pr): # have we already left a contractor comment? if has_contractor_comment(pr): return None, False # don't create a JIRA issue, but leave a comment comment = { "body": github_contractor_pr_comment(pr), } url = "/repos/{repo}/issues/{num}/comments".format( repo=repo, num=num, ) comment_resp = github.post(url, json=comment) comment_resp.raise_for_status() return None, True issue_key = get_jira_issue_key(pr) if issue_key: msg = "Already created {key} for PR #{num} against {repo}".format( key=issue_key, num=pr["number"], repo=pr["base"]["repo"]["full_name"], ) logger.info(msg) return issue_key, False repo = pr["base"]["repo"]["full_name"].decode('utf-8') people = get_people_file() custom_fields = get_jira_custom_fields(jira_bp.session) user_name = None if user in people: user_name = people[user].get("name", "") if not user_name: user_resp = github.get(pr["user"]["url"]) if user_resp.ok: user_name = user_resp.json().get("name", user) else: user_name = user # create an issue on JIRA! new_issue = { "fields": { "project": { "key": "OSPR", }, "issuetype": { "name": "Pull Request Review", }, "summary": pr["title"], "description": pr["body"], custom_fields["URL"]: pr["html_url"], custom_fields["PR Number"]: pr["number"], custom_fields["Repo"]: pr["base"]["repo"]["full_name"], custom_fields["Contributor Name"]: user_name, } } institution = people.get(user, {}).get("institution", None) if institution: new_issue["fields"][custom_fields["Customer"]] = [institution] sentry.client.extra_context({"new_issue": new_issue}) resp = jira_bp.session.post("/rest/api/2/issue", json=new_issue) resp.raise_for_status() new_issue_body = resp.json() issue_key = new_issue_body["key"].decode('utf-8') new_issue["key"] = issue_key sentry.client.extra_context({"new_issue": new_issue}) # add a comment to the Github pull request with a link to the JIRA issue comment = { "body": github_community_pr_comment(pr, new_issue_body, people), } url = "/repos/{repo}/issues/{num}/comments".format( repo=repo, num=pr["number"], ) comment_resp = github.post(url, json=comment) comment_resp.raise_for_status() # Add the "Needs Triage" label to the PR issue_url = "/repos/{repo}/issues/{num}".format(repo=repo, num=pr["number"]) label_resp = github.patch(issue_url, data=json.dumps({"labels": ["needs triage", "open-source-contribution"]})) label_resp.raise_for_status() logger.info( "@{user} opened PR #{num} against {repo}, created {issue} to track it".format( user=user, repo=repo, num=pr["number"], issue=issue_key, ), ) return issue_key, True
def pull_request_closed(pull_request): """ A GitHub pull request has been merged or closed. Synchronize the JIRA issue to also be in the "merged" or "closed" state. Returns a boolean: True if the JIRA issue was correctly synchronized, False otherwise. (However, these booleans are ignored.) """ jira = jira_bp.session pr = pull_request repo = pr["base"]["repo"]["full_name"].decode('utf-8') merged = pr["merged"] issue_key = get_jira_issue_key(pr) if not issue_key: logger.info( "Couldn't find JIRA issue for PR #{num} against {repo}".format( num=pr["number"], repo=repo, ), ) return "no JIRA issue :(" sentry.client.extra_context({"jira_key": issue_key}) # close the issue on JIRA transition_url = ( "/rest/api/2/issue/{key}/transitions" "?expand=transitions.fields".format(key=issue_key) ) transitions_resp = jira.get(transition_url) if transitions_resp.status_code == 404: # JIRA issue has been deleted return False transitions_resp.raise_for_status() transitions = transitions_resp.json()["transitions"] sentry.client.extra_context({"transitions": transitions}) transition_name = "Merged" if merged else "Rejected" transition_id = None for t in transitions: if t["to"]["name"] == transition_name: transition_id = t["id"] break if not transition_id: # maybe the issue is *already* in the right status? issue_url = "/rest/api/2/issue/{key}".format(key=issue_key) issue_resp = jira.get(issue_url) issue_resp.raise_for_status() issue = issue_resp.json() sentry.client.extra_context({"jira_issue": issue}) current_status = issue["fields"]["status"]["name"].decode("utf-8") if current_status == transition_name: msg = "{key} is already in status {status}".format( key=issue_key, status=transition_name ) logger.info(msg) return False # nope, raise an error message fail_msg = ( "{key} cannot be transitioned directly from status {curr_status} " "to status {new_status}. Valid status transitions are: {valid}".format( key=issue_key, new_status=transition_name, curr_status=current_status, valid=", ".join(t["to"]["name"].decode('utf-8') for t in transitions), ) ) raise Exception(fail_msg) transition_resp = jira.post(transition_url, json={ "transition": { "id": transition_id, } }) transition_resp.raise_for_status() logger.info( "PR #{num} against {repo} was {action}, moving {issue} to status {status}".format( num=pr["number"], repo=repo, action="merged" if merged else "closed", issue=issue_key, status="Merged" if merged else "Rejected", ), ) return True
def pull_request_closed(pull_request): pr = pull_request repo = pr["base"]["repo"]["full_name"].decode("utf-8") merged = pr["merged"] issue_key = get_jira_issue_key(pr) if not issue_key: logger.info("Couldn't find JIRA issue for PR #{num} against {repo}".format(num=pr["number"], repo=repo)) return "no JIRA issue :(" sentry.client.extra_context({"jira_key": issue_key}) # close the issue on JIRA transition_url = "/rest/api/2/issue/{key}/transitions" "?expand=transitions.fields".format(key=issue_key) transitions_resp = jira.get(transition_url) transitions_resp.raise_for_status() transitions = transitions_resp.json()["transitions"] sentry.client.extra_context({"transitions": transitions}) transition_name = "Merged" if merged else "Rejected" transition_id = None for t in transitions: if t["to"]["name"] == transition_name: transition_id = t["id"] break if not transition_id: # maybe the issue is *already* in the right status? issue_url = "/rest/api/2/issue/{key}".format(key=issue_key) issue_resp = jira.get(issue_url) issue_resp.raise_for_status() issue = issue_resp.json() sentry.client.extra_context({"jira_issue": issue}) current_status = issue["fields"]["status"]["name"].decode("utf-8") if current_status == transition_name: msg = "{key} is already in status {status}".format(key=issue_key, status=transition_name) logger.info(msg) return False # nope, raise an error message fail_msg = ( "{key} cannot be transitioned directly from status {curr_status} " "to status {new_status}. Valid status transitions are: {valid}".format( key=issue_key, new_status=transition_name, curr_status=current_status, valid=", ".join(t["to"]["name"].decode("utf-8") for t in transitions), ) ) raise Exception(fail_msg) transition_resp = jira.post(transition_url, json={"transition": {"id": transition_id}}) transition_resp.raise_for_status() logger.info( "PR #{num} against {repo} was {action}, moving {issue} to status {status}".format( num=pr["number"], repo=repo, action="merged" if merged else "closed", issue=issue_key, status="Merged" if merged else "Rejected", ) ) return True