2021-09-30 17:47:25 +02:00
|
|
|
from rspec_tools.errors import InvalidArgumentError
|
2021-02-18 12:50:55 +01:00
|
|
|
import click
|
2021-06-10 11:03:05 +02:00
|
|
|
import tempfile
|
2021-09-30 17:47:25 +02:00
|
|
|
import fs
|
2021-02-16 21:21:46 +01:00
|
|
|
from git import Repo
|
2021-02-18 12:50:55 +01:00
|
|
|
from git.remote import PushInfo
|
2021-02-16 21:21:46 +01:00
|
|
|
from github import Github
|
|
|
|
from github.PullRequest import PullRequest
|
|
|
|
from pathlib import Path
|
2021-06-10 11:03:05 +02:00
|
|
|
from typing import Final, Iterable, Optional, Callable
|
2021-02-16 21:21:46 +01:00
|
|
|
from contextlib import contextmanager
|
2021-09-30 17:47:25 +02:00
|
|
|
from rspec_tools.utils import parse_and_validate_language_list, get_labels_for_languages, validate_language, get_label_for_language, resolve_rule, swap_metadata_files, is_empty_metadata
|
2021-02-16 21:21:46 +01:00
|
|
|
|
2022-02-08 17:34:53 +01:00
|
|
|
from rspec_tools.utils import copy_directory_content, LANG_TO_SOURCE
|
2021-02-16 21:21:46 +01:00
|
|
|
|
2021-02-18 12:34:32 +01:00
|
|
|
def build_github_repository_url(token: str, user: Optional[str]):
|
2021-02-16 21:21:46 +01:00
|
|
|
'Builds the rspec repository url'
|
2021-02-18 12:34:32 +01:00
|
|
|
if user:
|
|
|
|
return f'https://{user}:{token}@github.com/SonarSource/rspec.git'
|
|
|
|
else:
|
|
|
|
return f'https://{token}@github.com/SonarSource/rspec.git'
|
2021-02-16 21:21:46 +01:00
|
|
|
|
|
|
|
def extract_repository_name(url):
|
|
|
|
url_end = url.split('/')[-2:]
|
|
|
|
return '/'.join(url_end).removesuffix('.git')
|
|
|
|
|
2022-01-13 09:25:17 +01:00
|
|
|
def auto_github(token: str) -> Callable[[Optional[str]], Github]:
|
2021-06-10 11:03:05 +02:00
|
|
|
def ret(user: Optional[str]):
|
|
|
|
if user:
|
|
|
|
return Github(user, token)
|
|
|
|
else:
|
|
|
|
return Github(token)
|
|
|
|
return ret
|
|
|
|
|
|
|
|
def create_new_rule(languages: str, token: str, user: Optional[str]):
|
|
|
|
url = build_github_repository_url(token, user)
|
|
|
|
config = {}
|
|
|
|
if user:
|
|
|
|
config['user.name'] = user
|
|
|
|
config['user.email'] = f'{user}@users.noreply.github.com'
|
|
|
|
lang_list = parse_and_validate_language_list(languages)
|
|
|
|
label_list = get_labels_for_languages(lang_list)
|
|
|
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdirname:
|
|
|
|
rule_creator = RuleCreator(url, tmpdirname, config)
|
|
|
|
rule_number = rule_creator.reserve_rule_number()
|
2022-01-13 09:25:17 +01:00
|
|
|
rule_creator.create_new_rule_pull_request(auto_github(token), rule_number, lang_list, label_list, user=user)
|
2021-06-10 11:03:05 +02:00
|
|
|
|
2021-09-30 17:47:25 +02:00
|
|
|
def add_language_to_rule(language: str, rule: str, token: str, user: Optional[str]):
|
|
|
|
url = build_github_repository_url(token, user)
|
|
|
|
config = {}
|
|
|
|
if user:
|
|
|
|
config['user.name'] = user
|
|
|
|
config['user.email'] = f'{user}@users.noreply.github.com'
|
|
|
|
validate_language(language)
|
|
|
|
label = get_label_for_language(language)
|
|
|
|
rule_number = resolve_rule(rule)
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdirname:
|
|
|
|
rule_creator = RuleCreator(url, tmpdirname, config)
|
2022-01-13 09:25:17 +01:00
|
|
|
rule_creator.add_language_pull_request(auto_github(token), rule_number, language, label, user=user)
|
2021-09-30 17:47:25 +02:00
|
|
|
|
2021-02-16 21:21:46 +01:00
|
|
|
class RuleCreator:
|
|
|
|
''' Create a new Rule in a repository following the official Github 'rspec' repository structure.'''
|
|
|
|
MASTER_BRANCH: Final[str] = 'master'
|
|
|
|
ID_COUNTER_BRANCH: Final[str] = 'rspec-id-counter'
|
|
|
|
ID_COUNTER_FILENAME: Final[str] = 'next_rspec_id.txt'
|
|
|
|
TEMPLATE_PATH: Final[Path] = Path(__file__).parent.parent.joinpath('rspec_template')
|
|
|
|
|
|
|
|
repository: Final[Repo]
|
|
|
|
origin_url: Final[str]
|
|
|
|
|
|
|
|
def __init__(self, repository_url: str, clone_directory: str, configuration: dict[str, str]):
|
|
|
|
self.repository = Repo.clone_from(repository_url, clone_directory)
|
|
|
|
self.origin_url = repository_url
|
|
|
|
|
|
|
|
# create local branches tracking remote ones
|
|
|
|
for branch in [self.MASTER_BRANCH, self.ID_COUNTER_BRANCH]:
|
|
|
|
self.repository.remote().fetch(branch)
|
|
|
|
self.repository.git.checkout('-B', branch, f'origin/{branch}')
|
|
|
|
|
|
|
|
# update repository config
|
|
|
|
with self.repository.config_writer() as config_writer:
|
|
|
|
for key, value in configuration.items():
|
|
|
|
split_key = key.split('.')
|
|
|
|
config_writer.set_value(*split_key, value)
|
|
|
|
|
|
|
|
def reserve_rule_number(self) -> int:
|
|
|
|
'''Reserve an id on the id counter branch of the repository.'''
|
|
|
|
with self._current_git_branch(self.ID_COUNTER_BRANCH):
|
|
|
|
counter_file_path = Path(self.repository.working_dir).joinpath(self.ID_COUNTER_FILENAME)
|
|
|
|
counter = int(counter_file_path.read_text())
|
|
|
|
counter_file_path.write_text(str(counter + 1))
|
2021-09-30 17:47:25 +02:00
|
|
|
|
2021-02-16 21:21:46 +01:00
|
|
|
self.repository.index.add([str(counter_file_path)])
|
|
|
|
self.repository.index.commit('Increment RSPEC ID counter')
|
|
|
|
|
|
|
|
origin = self.repository.remote(name='origin')
|
|
|
|
origin.push()
|
2021-02-18 14:51:20 +01:00
|
|
|
|
|
|
|
click.echo(f'Reserved Rule ID S{counter}')
|
2021-02-16 21:21:46 +01:00
|
|
|
return counter
|
|
|
|
|
2021-09-30 17:47:25 +02:00
|
|
|
def add_language_branch(self, rule_number: int, language: str) -> str:
|
|
|
|
'''Create and move files to add a new language to an existing rule.'''
|
|
|
|
branch_name = f'rule/S{rule_number}-add-{language}'
|
|
|
|
with self._current_git_branch(self.MASTER_BRANCH, branch_name):
|
|
|
|
repo_dir = Path(self.repository.working_dir)
|
|
|
|
rule_dir = repo_dir.joinpath('rules', f'S{rule_number}')
|
|
|
|
if not rule_dir.is_dir():
|
|
|
|
raise InvalidArgumentError(f"Rule \"S{rule_number}\" does not exist.")
|
|
|
|
lang_dir = rule_dir.joinpath(language)
|
|
|
|
if lang_dir.is_dir():
|
|
|
|
lang_url = f"https://github.com/SonarSource/rspec/tree/master/rules/S{rule_number}/{language}"
|
2021-10-01 10:25:35 +02:00
|
|
|
raise InvalidArgumentError(f"Rule \"S{rule_number}\" is already defined for language {language}. Modify the definition here: {lang_url}")
|
|
|
|
lang_dirs = [d for d in rule_dir.glob('*/') if d.is_dir()]
|
|
|
|
if 1 == len(list(lang_dirs)) and is_empty_metadata(rule_dir):
|
|
|
|
swap_metadata_files(rule_dir, lang_dirs[0])
|
2021-09-30 17:47:25 +02:00
|
|
|
lang_dir.mkdir()
|
|
|
|
|
|
|
|
lang_specific_template = self.TEMPLATE_PATH.joinpath('multi_language', 'language_specific')
|
|
|
|
copy_directory_content(lang_specific_template, lang_dir)
|
|
|
|
self._fill_in_the_blanks_in_the_template(lang_dir, rule_number)
|
2022-02-08 17:34:53 +01:00
|
|
|
self._fill_language_name_in_the_template(lang_dir, language)
|
2021-09-30 17:47:25 +02:00
|
|
|
self.repository.git.add('--all')
|
|
|
|
self.repository.index.commit(f'Add {language} to rule S{rule_number}')
|
|
|
|
self.repository.git.push('origin', branch_name)
|
|
|
|
return branch_name
|
|
|
|
|
2021-02-16 21:21:46 +01:00
|
|
|
def create_new_rule_branch(self, rule_number: int, languages: Iterable[str]) -> str:
|
|
|
|
'''Create all the files required for a new rule.'''
|
2021-06-01 17:41:02 +02:00
|
|
|
branch_name = f'rule/add-RSPEC-S{rule_number}'
|
2021-02-16 21:21:46 +01:00
|
|
|
with self._current_git_branch(self.MASTER_BRANCH, branch_name):
|
|
|
|
repo_dir = Path(self.repository.working_dir)
|
|
|
|
rule_dir = repo_dir.joinpath('rules', f'S{rule_number}')
|
|
|
|
rule_dir.mkdir()
|
2022-01-13 09:25:17 +01:00
|
|
|
lang_count = sum(1 for _ in languages)
|
2021-05-03 09:24:46 +02:00
|
|
|
if lang_count > 1:
|
|
|
|
self._fill_multi_lang_template_files(rule_dir, rule_number, languages)
|
|
|
|
else:
|
|
|
|
self._fill_single_lang_template_files(rule_dir, rule_number, next(iter(languages)))
|
|
|
|
|
2021-02-16 21:21:46 +01:00
|
|
|
self.repository.git.add('--all')
|
|
|
|
self.repository.index.commit(f'Create rule S{rule_number}')
|
2021-02-18 13:18:24 +01:00
|
|
|
self.repository.git.push('origin', branch_name)
|
2021-02-16 21:21:46 +01:00
|
|
|
return branch_name
|
2021-05-03 09:24:46 +02:00
|
|
|
|
|
|
|
def _fill_in_the_blanks_in_the_template(self, rule_dir: Path, rule_number: int):
|
|
|
|
for rule_item in rule_dir.glob('**/*'):
|
|
|
|
if rule_item.is_file():
|
|
|
|
template_content = rule_item.read_text()
|
|
|
|
final_content = template_content.replace('${RSPEC_ID}', str(rule_number))
|
|
|
|
rule_item.write_text(final_content)
|
|
|
|
|
2022-02-08 17:34:53 +01:00
|
|
|
def _fill_language_name_in_the_template(self, lang_dir: Path, language: str):
|
|
|
|
for rule_item in lang_dir.glob('*.adoc'):
|
|
|
|
if rule_item.is_file():
|
|
|
|
template_content = rule_item.read_text()
|
|
|
|
lang = LANG_TO_SOURCE[language]
|
|
|
|
final_content = template_content.replace('[source,text]', f'[source,{lang}]')
|
|
|
|
rule_item.write_text(final_content)
|
|
|
|
|
2021-05-03 09:24:46 +02:00
|
|
|
def _fill_multi_lang_template_files(self, rule_dir: Path, rule_number: int, languages: Iterable[str]):
|
|
|
|
common_template = self.TEMPLATE_PATH.joinpath('multi_language', 'common')
|
|
|
|
lang_specific_template = self.TEMPLATE_PATH.joinpath('multi_language', 'language_specific')
|
|
|
|
copy_directory_content(common_template, rule_dir)
|
|
|
|
|
|
|
|
for lang in languages:
|
|
|
|
lang_dir = rule_dir.joinpath(lang)
|
|
|
|
lang_dir.mkdir()
|
|
|
|
copy_directory_content(lang_specific_template, lang_dir)
|
2022-02-08 17:34:53 +01:00
|
|
|
self._fill_language_name_in_the_template(lang_dir, lang)
|
2021-05-03 09:24:46 +02:00
|
|
|
|
|
|
|
self._fill_in_the_blanks_in_the_template(rule_dir, rule_number)
|
|
|
|
|
|
|
|
def _fill_single_lang_template_files(self, rule_dir: Path, rule_number: int, language: str):
|
|
|
|
common_template = self.TEMPLATE_PATH.joinpath('single_language', 'common')
|
|
|
|
lang_specific_template = self.TEMPLATE_PATH.joinpath('single_language', 'language_specific')
|
|
|
|
copy_directory_content(common_template, rule_dir)
|
|
|
|
|
|
|
|
lang_dir = rule_dir.joinpath(language)
|
|
|
|
lang_dir.mkdir()
|
|
|
|
copy_directory_content(lang_specific_template, lang_dir)
|
|
|
|
|
|
|
|
self._fill_in_the_blanks_in_the_template(rule_dir, rule_number)
|
2022-02-08 17:34:53 +01:00
|
|
|
self._fill_language_name_in_the_template(lang_dir, language)
|
2021-05-03 09:24:46 +02:00
|
|
|
|
2022-01-13 09:25:17 +01:00
|
|
|
def _create_pull_request(self, github_api: Callable[[Optional[str]], Github], branch_name: str, title: str, body: str, labels: Iterable[str], user: Optional[str]):
|
2021-02-16 21:21:46 +01:00
|
|
|
repository_url = extract_repository_name(self.origin_url)
|
2022-01-13 09:25:17 +01:00
|
|
|
github = github_api(user)
|
2021-02-16 21:21:46 +01:00
|
|
|
github_repo = github.get_repo(repository_url)
|
2021-02-18 14:51:20 +01:00
|
|
|
pull_request = github_repo.create_pull(
|
2021-09-30 17:47:25 +02:00
|
|
|
title=title,
|
|
|
|
body=body,
|
2021-06-01 11:46:52 +02:00
|
|
|
head=branch_name, base=self.MASTER_BRANCH,
|
2021-02-16 21:21:46 +01:00
|
|
|
draft=True, maintainer_can_modify=True
|
|
|
|
)
|
2021-02-18 14:51:20 +01:00
|
|
|
click.echo(f'Created rule Pull Request {pull_request.html_url}')
|
2021-02-18 17:47:40 +01:00
|
|
|
|
2021-02-18 18:00:42 +01:00
|
|
|
# Note: It is not possible to get the authenticated user using get_user() from a github action.
|
|
|
|
login = user if user else github.get_user().login
|
|
|
|
pull_request.add_to_assignees(login)
|
2021-06-10 11:03:05 +02:00
|
|
|
pull_request.add_to_labels(*labels)
|
2021-02-18 18:00:42 +01:00
|
|
|
click.echo(f'Pull request assigned to {login}')
|
2021-02-18 14:51:20 +01:00
|
|
|
return pull_request
|
2021-02-16 21:21:46 +01:00
|
|
|
|
2022-01-13 09:25:17 +01:00
|
|
|
def add_language_pull_request(self, github_api: Callable[[Optional[str]], Github], rule_number: int, language: str, label: str, user: Optional[str]):
|
2021-09-30 17:47:25 +02:00
|
|
|
branch_name = self.add_language_branch(rule_number, language)
|
|
|
|
click.echo(f'Created rule branch {branch_name}')
|
|
|
|
return self._create_pull_request(
|
2022-01-13 09:25:17 +01:00
|
|
|
github_api,
|
2021-09-30 17:47:25 +02:00
|
|
|
branch_name,
|
2022-01-13 17:03:40 +01:00
|
|
|
f'Create rule S{rule_number}',
|
2021-09-30 17:47:25 +02:00
|
|
|
f'You can preview this rule [here](https://sonarsource.github.io/rspec/#/rspec/S{rule_number}/{language}) (updated a few minutes after each push).',
|
|
|
|
[label],
|
|
|
|
user
|
|
|
|
)
|
|
|
|
|
2022-01-13 09:25:17 +01:00
|
|
|
def create_new_rule_pull_request(self, github_api: Callable[[Optional[str]], Github], rule_number: int, languages: Iterable[str], labels: Iterable[str], *, user: Optional[str]) -> PullRequest:
|
2021-09-30 17:47:25 +02:00
|
|
|
branch_name = self.create_new_rule_branch(rule_number, languages)
|
|
|
|
click.echo(f'Created rule branch {branch_name}')
|
|
|
|
first_lang = next(iter(languages))
|
|
|
|
return self._create_pull_request(
|
2022-01-13 09:25:17 +01:00
|
|
|
github_api,
|
2021-09-30 17:47:25 +02:00
|
|
|
branch_name,
|
|
|
|
f'Create rule S{rule_number}',
|
|
|
|
f'You can preview this rule [here](https://sonarsource.github.io/rspec/#/rspec/S{rule_number}/{first_lang}) (updated a few minutes after each push).',
|
|
|
|
labels,
|
|
|
|
user
|
|
|
|
)
|
|
|
|
|
2021-02-16 21:21:46 +01:00
|
|
|
@contextmanager
|
|
|
|
def _current_git_branch(self, base_branch: str, new_branch: Optional[str] = None):
|
|
|
|
'''Checkout a given branch before yielding, then revert to the previous branch.'''
|
|
|
|
past_branch = self.repository.active_branch
|
|
|
|
try:
|
|
|
|
self.repository.git.checkout(base_branch)
|
|
|
|
origin = self.repository.remote(name='origin')
|
|
|
|
origin.pull()
|
|
|
|
if new_branch is not None:
|
|
|
|
self.repository.git.checkout('-B', new_branch)
|
|
|
|
yield
|
|
|
|
finally:
|
2021-05-03 09:24:46 +02:00
|
|
|
self.repository.git.checkout(past_branch)
|