import argparse
from git import Repo import github
import os
import re
import requests
import sys
import time
from typing import *
class IssueSubscriber:
@property
def team_name(self) -> str:
return self._team_name
def __init__(self, token:str, repo:str, issue_number:int, label_name:str):
self.repo = github.Github(token).get_repo(repo)
self.org = github.Github(token).get_organization(self.repo.organization.login)
self.issue = self.repo.get_issue(issue_number)
self._team_name = 'issue-subscribers-{}'.format(label_name).lower()
def run(self) -> bool:
for team in self.org.get_teams():
if self.team_name != team.name.lower():
continue
comment = '@llvm/{}'.format(team.slug)
self.issue.create_comment(comment)
return True
return False
def setup_llvmbot_git(git_dir = '.'):
repo = Repo(git_dir)
with repo.config_writer() as config:
config.set_value('user', 'name', 'llvmbot')
config.set_value('user', 'email', 'llvmbot@llvm.org')
def phab_api_call(phab_token:str, url:str, args:dict) -> dict:
data = { "api.token" : phab_token }
data.update(args)
response = requests.post(url, data = data)
return response.json()
def phab_login_to_github_login(phab_token:str, repo:github.Repository.Repository, phab_login:str) -> str:
args = {
"constraints[authors][0]" : phab_login,
"constraints[repositories][0]" : "PHID-REPO-f4scjekhnkmh7qilxlcy",
"limit" : 1
}
r = phab_api_call(phab_token, "https://reviews.llvm.org/api/diffusion.commit.search", args)
data = r['result']['data']
if len(data) == 0:
return None
commit_sha = data[0]['fields']['identifier']
return repo.get_commit(commit_sha).committer.login
def phab_get_commit_approvers(phab_token:str, repo:github.Repository.Repository, commit:github.Commit.Commit) -> list:
args = { "corpus" : commit.commit.message }
r = phab_api_call(phab_token, "https://reviews.llvm.org/api/differential.parsecommitmessage", args)
review_id = r['result']['revisionIDFieldInfo']['value']
args = {
'constraints[ids][0]' : review_id,
'attachments[reviewers]' : True
}
r = phab_api_call(phab_token, "https://reviews.llvm.org/api/differential.revision.search", args)
reviewers = r['result']['data'][0]['attachments']['reviewers']['reviewers']
accepted = []
for reviewer in reviewers:
if reviewer['status'] != 'accepted':
continue
phid = reviewer['reviewerPHID']
args = { 'constraints[phids][0]' : phid }
r = phab_api_call(phab_token, "https://reviews.llvm.org/api/user.search", args)
accepted.append(r['result']['data'][0]['fields']['username'])
return accepted
class ReleaseWorkflow:
CHERRY_PICK_FAILED_LABEL = 'release:cherry-pick-failed'
def __init__(self, token:str, repo:str, issue_number:int,
branch_repo_name:str, branch_repo_token:str,
llvm_project_dir:str, phab_token:str) -> None:
self._token = token
self._repo_name = repo
self._issue_number = issue_number
self._branch_repo_name = branch_repo_name
if branch_repo_token:
self._branch_repo_token = branch_repo_token
else:
self._branch_repo_token = self.token
self._llvm_project_dir = llvm_project_dir
self._phab_token = phab_token
@property
def token(self) -> str:
return self._token
@property
def repo_name(self) -> str:
return self._repo_name
@property
def issue_number(self) -> int:
return self._issue_number
@property
def branch_repo_name(self) -> str:
return self._branch_repo_name
@property
def branch_repo_token(self) -> str:
return self._branch_repo_token
@property
def llvm_project_dir(self) -> str:
return self._llvm_project_dir
@property
def phab_token(self) -> str:
return self._phab_token
@property
def repo(self) -> github.Repository.Repository:
return github.Github(self.token).get_repo(self.repo_name)
@property
def issue(self) -> github.Issue.Issue:
return self.repo.get_issue(self.issue_number)
@property
def push_url(self) -> str:
return 'https://{}@github.com/{}'.format(self.branch_repo_token, self.branch_repo_name)
@property
def branch_name(self) -> str:
return 'issue{}'.format(self.issue_number)
@property
def release_branch_for_issue(self) -> Optional[str]:
issue = self.issue
milestone = issue.milestone
if milestone is None:
return None
m = re.search('branch: (.+)',milestone.description)
if m:
return m.group(1)
return None
def print_release_branch(self) -> None:
print(self.release_branch_for_issue)
def issue_notify_branch(self) -> None:
self.issue.create_comment('/branch {}/{}'.format(self.branch_repo_name, self.branch_name))
def issue_notify_pull_request(self, pull:github.PullRequest.PullRequest) -> None:
self.issue.create_comment('/pull-request {}#{}'.format(self.branch_repo_name, pull.number))
def make_ignore_comment(self, comment: str) -> str:
return "<!--IGNORE-->\n"+comment
def issue_notify_no_milestone(self, comment:List[str]) -> None:
message = "{}\n\nError: Command failed due to missing milestone.".format(''.join(['>' + line for line in comment]))
self.issue.create_comment(self.make_ignore_comment(message))
@property
def action_url(self) -> str:
if os.getenv('CI'):
return 'https://github.com/{}/actions/runs/{}'.format(os.getenv('GITHUB_REPOSITORY'), os.getenv('GITHUB_RUN_ID'))
return ""
def issue_notify_cherry_pick_failure(self, commit:str) -> github.IssueComment.IssueComment:
message = self.make_ignore_comment("Failed to cherry-pick: {}\n\n".format(commit))
action_url = self.action_url
if action_url:
message += action_url + "\n\n"
message += "Please manually backport the fix and push it to your github fork. Once this is done, please add a comment like this:\n\n`/branch <user>/<repo>/<branch>`"
issue = self.issue
comment = issue.create_comment(message)
issue.add_to_labels(self.CHERRY_PICK_FAILED_LABEL)
return comment
def issue_notify_pull_request_failure(self, branch:str) -> github.IssueComment.IssueComment:
message = "Failed to create pull request for {} ".format(branch)
message += self.action_url
return self.issue.create_comment(message)
def issue_remove_cherry_pick_failed_label(self):
if self.CHERRY_PICK_FAILED_LABEL in [l.name for l in self.issue.labels]:
self.issue.remove_from_labels(self.CHERRY_PICK_FAILED_LABEL)
def pr_request_review(self, pr:github.PullRequest.PullRequest):
reviewers = []
for commit in pr.get_commits():
approvers = phab_get_commit_approvers(self.phab_token, self.repo, commit)
for a in approvers:
login = phab_login_to_github_login(self.phab_token, self.repo, a)
if not login:
continue
reviewers.append(login)
if len(reviewers):
message = "{} What do you think about merging this PR to the release branch?".format(
" ".join(["@" + r for r in reviewers]))
pr.create_issue_comment(message)
pr.add_to_assignees(*reviewers)
def create_branch(self, commits:List[str]) -> bool:
print('cherry-picking', commits)
branch_name = self.branch_name
local_repo = Repo(self.llvm_project_dir)
local_repo.git.checkout(self.release_branch_for_issue)
for c in commits:
try:
local_repo.git.cherry_pick('-x', c)
except Exception as e:
self.issue_notify_cherry_pick_failure(c)
raise e
push_url = self.push_url
print('Pushing to {} {}'.format(push_url, branch_name))
local_repo.git.push(push_url, 'HEAD:{}'.format(branch_name), force=True)
self.issue_notify_branch()
self.issue_remove_cherry_pick_failed_label()
return True
def check_if_pull_request_exists(self, repo:github.Repository.Repository, head:str) -> bool:
pulls = repo.get_pulls(head=head)
return pulls.totalCount != 0
def create_pull_request(self, owner:str, branch:str) -> bool:
repo = github.Github(self.token).get_repo(self.branch_repo_name)
issue_ref = '{}#{}'.format(self.repo_name, self.issue_number)
pull = None
release_branch_for_issue = self.release_branch_for_issue
if release_branch_for_issue is None:
return False
head_branch = branch
if not repo.fork:
head_branch = f'{owner}-{branch}'
local_repo = Repo(self.llvm_project_dir)
push_done = False
for i in range(0,5):
try:
local_repo.git.fetch(f'https://github.com/{owner}/llvm-project', f'{branch}:{branch}')
local_repo.git.push(self.push_url, f'{branch}:{head_branch}', force=True)
push_done = True
break
except Exception as e:
print(e)
time.sleep(30)
continue
if not push_done:
raise Exception("Failed to mirror branch into {}".format(self.push_url))
owner = repo.owner.login
head = f"{owner}:{head_branch}"
if self.check_if_pull_request_exists(repo, head):
print("PR already exists...")
return True
try:
pull = repo.create_pull(title=f"PR for {issue_ref}",
body='resolves {}'.format(issue_ref),
base=release_branch_for_issue,
head=head,
maintainer_can_modify=False)
try:
if self.phab_token:
self.pr_request_review(pull)
except Exception as e:
print("error: Failed while searching for reviewers", e)
except Exception as e:
self.issue_notify_pull_request_failure(branch)
raise e
if pull is None:
return False
self.issue_notify_pull_request(pull)
self.issue_remove_cherry_pick_failed_label()
return True
def execute_command(self) -> bool:
for line in sys.stdin:
line.rstrip()
m = re.search("/([a-z-]+)\s(.+)", line)
if not m:
continue
command = m.group(1)
args = m.group(2)
if command == 'cherry-pick':
return self.create_branch(args.split())
if command == 'branch':
m = re.match('([^/]+)/([^/]+)/(.+)', args)
if m:
owner = m.group(1)
branch = m.group(3)
return self.create_pull_request(owner, branch)
print("Do not understand input:")
print(sys.stdin.readlines())
return False
parser = argparse.ArgumentParser()
parser.add_argument('--token', type=str, required=True, help='GitHub authentiation token')
parser.add_argument('--repo', type=str, default=os.getenv('GITHUB_REPOSITORY', 'llvm/llvm-project'),
help='The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)')
subparsers = parser.add_subparsers(dest='command')
issue_subscriber_parser = subparsers.add_parser('issue-subscriber')
issue_subscriber_parser.add_argument('--label-name', type=str, required=True)
issue_subscriber_parser.add_argument('--issue-number', type=int, required=True)
release_workflow_parser = subparsers.add_parser('release-workflow')
release_workflow_parser.add_argument('--llvm-project-dir', type=str, default='.', help='directory containing the llvm-project checout')
release_workflow_parser.add_argument('--issue-number', type=int, required=True, help='The issue number to update')
release_workflow_parser.add_argument('--phab-token', type=str, help='Phabricator conduit API token. See https://reviews.llvm.org/settings/user/<USER>/page/apitokens/')
release_workflow_parser.add_argument('--branch-repo-token', type=str,
help='GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.')
release_workflow_parser.add_argument('--branch-repo', type=str, default='llvm/llvm-project-release-prs',
help='The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)')
release_workflow_parser.add_argument('sub_command', type=str, choices=['print-release-branch', 'auto'],
help='Print to stdout the name of the release branch ISSUE_NUMBER should be backported to')
llvmbot_git_config_parser = subparsers.add_parser('setup-llvmbot-git', help='Set the default user and email for the git repo in LLVM_PROJECT_DIR to llvmbot')
args = parser.parse_args()
if args.command == 'issue-subscriber':
issue_subscriber = IssueSubscriber(args.token, args.repo, args.issue_number, args.label_name)
issue_subscriber.run()
elif args.command == 'release-workflow':
release_workflow = ReleaseWorkflow(args.token, args.repo, args.issue_number,
args.branch_repo, args.branch_repo_token,
args.llvm_project_dir, args.phab_token)
if not release_workflow.release_branch_for_issue:
release_workflow.issue_notify_no_milestone(sys.stdin.readlines())
sys.exit(1)
if args.sub_command == 'print-release-branch':
release_workflow.print_release_branch()
else:
if not release_workflow.execute_command():
sys.exit(1)
elif args.command == 'setup-llvmbot-git':
setup_llvmbot_git()