Add RuleCreator to rspec-tools

This commit is contained in:
Nicolas Harraudeau 2021-02-16 21:21:46 +01:00 committed by nicolas-harraudeau-sonarsource
parent 2c121f494f
commit de024c5f96
13 changed files with 364 additions and 95 deletions

View File

@ -14,52 +14,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
#repository: 'nicolas-harraudeau-sonarsource/test-github-actions'
#token: ${{ secrets.NHARRAUD_PUBLIC_REPOS }}
persist-credentials: true
ref: rspec-id-counter
path: 'rspec-id-counter'
- uses: actions/checkout@v2
with:
persist-credentials: true
ref: master
path: 'master'
- name: 'Configure Git'
working-directory: 'rspec-id-counter'
path: 'rspec'
- name: 'Install Pipenv'
run: |
git config user.name "${{ github.actor }}"
git config user.email "<${{ github.actor }}@users.noreply.github.com>"
pip install pipenv
- name: 'Increment the RSPEC ID counter'
id: increment-rspec-id
working-directory: 'rspec-id-counter'
run: ../master/scripts/reserve_rspec_id.sh next_rspec_id.txt
- name: 'Test RSPEC ID'
run: echo $RSPEC_ID
- name: 'print actor'
run: echo $GITHUB_ACTOR
- name: 'Create new rule in a branch'
working-directory: 'master'
run: ./scripts/create_new_rspec_branch.sh $RSPEC_ID "${{ github.event.inputs.languages }}" rules
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v3
with:
path: 'master'
commit-message: Create rule ${{ steps.increment-rspec-id.outputs.rspec-id }}
title: Create rule ${{ steps.increment-rspec-id.outputs.rspec-id }}
committer: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
assignees: "${{ github.actor }}"
branch: add-RSPEC-${{ steps.increment-rspec-id.outputs.rspec-id }}
draft: true
- name: Check outputs
- name: 'Create Rule'
working-directory: 'rspec/rspec-tools'
run: |
echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}"
echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}"
pipenv install -e .
pipenv run create_rule --languages "${{ github.event.inputs.languages }}"

View File

@ -7,6 +7,8 @@ name = "pypi"
click = ">=7.1.2"
bs4 = "*"
rspec-tools = {editable = true, path = "."}
gitpython = "*"
pygithub = "*"
[dev-packages]
pytest = ">=6.2.2"

View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "c21b312520580b430e4d8e52d6619f9e729c8de14b73cc3a072bd146d341c622"
"sha256": "74591c9934ebe1adbe00466ef69a05fd1d91a857d6a8354c3da8f17b65108eff"
},
"pipfile-spec": 6,
"requires": {
@ -31,6 +31,21 @@
"index": "pypi",
"version": "==0.0.1"
},
"certifi": {
"hashes": [
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
],
"version": "==2020.12.5"
},
"chardet": {
"hashes": [
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==4.0.0"
},
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
@ -39,10 +54,73 @@
"index": "pypi",
"version": "==7.1.2"
},
"deprecated": {
"hashes": [
"sha256:471ec32b2755172046e28102cd46c481f21c6036a0ec027521eba8521aa4ef35",
"sha256:924b6921f822b64ec54f49be6700a126bab0640cfafca78f22c9d429ed590560"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.2.11"
},
"gitdb": {
"hashes": [
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
"sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"
],
"markers": "python_version >= '3.4'",
"version": "==4.0.5"
},
"gitpython": {
"hashes": [
"sha256:8621a7e777e276a5ec838b59280ba5272dd144a18169c36c903d8b38b99f750a",
"sha256:c5347c81d232d9b8e7f47b68a83e5dc92e7952127133c5f2df9133f2c75a1b29"
],
"index": "pypi",
"version": "==3.1.13"
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
},
"pygithub": {
"hashes": [
"sha256:300bc16e62886ca6537b0830e8f516ea4bc3ef12d308e0c5aff8bdbd099173d4",
"sha256:87afd6a67ea582aa7533afdbf41635725f13d12581faed7e3e04b1579c0c0627"
],
"index": "pypi",
"version": "==1.54.1"
},
"pyjwt": {
"hashes": [
"sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e",
"sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"
],
"version": "==1.7.1"
},
"requests": {
"hashes": [
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.25.1"
},
"rspec-tools": {
"editable": true,
"path": "."
},
"smmap": {
"hashes": [
"sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714",
"sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.5"
},
"soupsieve": {
"hashes": [
"sha256:407fa1e8eb3458d1b5614df51d9651a1180ea5fedf07feb46e45d7e25e6d6cdd",
@ -50,6 +128,20 @@
],
"markers": "python_version >= '3.0'",
"version": "==2.2"
},
"urllib3": {
"hashes": [
"sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80",
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.26.3"
},
"wrapt": {
"hashes": [
"sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"
],
"version": "==1.12.1"
}
},
"develop": {

View File

@ -1,6 +1,11 @@
import os
import tempfile
from typing import Optional
import click
from .checklinks import check_html_links
from .errors import RuleNotFoundError
from rspec_tools.checklinks import check_html_links
from rspec_tools.errors import InvalidArgumenError, RuleNotFoundError
from rspec_tools.create_rule import RuleCreator, build_github_repository_url
@click.group()
@click.option('--debug/--no-debug', default=False)
@ -20,6 +25,30 @@ def validate(rule):
def check_links(d):
'''Check links in html.'''
check_html_links(d)
@cli.command()
@click.option('--languages', required=True)
@click.option('--user', required=False)
def create_rule(languages: str, user: Optional[str]):
'''Create a new rule.'''
token = os.environ.get('GITHUB_TOKEN')
url = build_github_repository_url(token)
config = {}
if user:
config['user.name'] = user
config['user.email'] = f'<{user}@users.noreply.github.com>'
lang_list = [lang.strip() for lang in languages.split(',')]
if len(languages.strip()) == 0 or len(lang_list) == 0:
raise InvalidArgumenError('Invalid argument for "languages". At least one language should be provided.')
# TODO: accept only valid languages
with tempfile.TemporaryDirectory() as tmpdirname:
rule_creator = RuleCreator(url, tmpdirname, config)
rule_number = rule_creator.reserve_rule_number()
click.echo(f'Reserved Rule ID S{rule_number}')
pull_request = rule_creator.create_new_rule_pull_request(token, rule_number, lang_list)
click.echo(f'Created Rule Pull Request branch: {pull_request.head} url: {pull_request.html_url}')
__all__=['cli']

View File

@ -0,0 +1,108 @@
from git import Repo
from github import Github
from github.PullRequest import PullRequest
from pathlib import Path
from typing import Final, Iterable, Optional
from contextlib import contextmanager
from rspec_tools.utils import copy_directory_content
def build_github_repository_url(token):
'Builds the rspec repository url'
return f'https://{token}@github.com/SonarSource/rspec.git'
def extract_repository_name(url):
url_end = url.split('/')[-2:]
return '/'.join(url_end).removesuffix('.git')
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))
self.repository.index.add([str(counter_file_path)])
self.repository.index.commit('Increment RSPEC ID counter')
origin = self.repository.remote(name='origin')
origin.push()
return counter
def create_new_rule_branch(self, rule_number: int, languages: Iterable[str]) -> str:
'''Create all the files required for a new rule.'''
branch_name = f'add-RSPEC-S{rule_number}'
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()
common_template = self.TEMPLATE_PATH.joinpath('common')
lang_specific_template = self.TEMPLATE_PATH.joinpath('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)
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)
self.repository.git.add('--all')
self.repository.index.commit(f'Create rule S{rule_number}')
origin = self.repository.remote(name='origin')
origin.push(f'refs/heads/{branch_name}:refs/heads/{branch_name}')
return branch_name
def create_new_rule_pull_request(self, token: str, rule_number: int, languages: Iterable[str]) -> PullRequest:
branch_name = self.create_new_rule_branch(rule_number, languages)
repository_url = extract_repository_name(self.origin_url)
github = Github(token)
github_repo = github.get_repo(repository_url)
return github_repo.create_pull(
title=f'Create rule S{rule_number}', body='', head=branch_name, base=self.MASTER_BRANCH,
draft=True, maintainer_can_modify=True
)
@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:
self.repository.git.checkout(past_branch)

View File

@ -3,3 +3,8 @@ from click import ClickException
class RuleNotFoundError(ClickException):
def __init__(self, id):
super().__init__(f'No rule has ID {id}')
class InvalidArgumenError(ClickException):
'''Exception raised when an invalid argument is given to a CLI command.'''
def __init__(self, message):
super().__init__(message)

View File

@ -0,0 +1,9 @@
from pathlib import Path
import shutil
def copy_directory_content(src:Path, dest:Path):
for item in src.iterdir():
if (item.is_dir()):
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)

View File

@ -0,0 +1,107 @@
from git import Repo, Head
from pathlib import Path
import pytest
from rspec_tools.create_rule import RuleCreator
@pytest.fixture
def git_config():
'''Create a mock git configuration.'''
return {
'user.name': 'testuser',
'user.email': 'testuser@mock.mock'
}
@pytest.fixture
def mock_rspec_repo(tmpdir):
repo_dir = tmpdir.mkdir("mock_rspec")
repo = Repo.init(str(repo_dir))
repo.init()
with repo.config_writer() as config_writer:
config_writer.set_value('user', 'name', 'originuser')
config_writer.set_value('user', 'email', 'originuser@mock.mock')
rules_dir = repo_dir.mkdir('rules')
# create a file just to have a "rules" directory
gitignore = rules_dir.join('.gitignore')
gitignore.ensure()
repo.index.add([str(gitignore)])
repo.index.commit('init rules')
# Create the id counter branch. Note that it is an orphan branch.
repo.head.reference = Head(repo, f'refs/heads/{RuleCreator.ID_COUNTER_BRANCH}')
repo.git.reset('--hard')
counter_file = repo_dir.join(RuleCreator.ID_COUNTER_FILENAME)
counter_file.write('0')
repo.index.add([str(counter_file)])
commit = repo.index.commit('init counter', parent_commits=None)
# Checkout a specific commit so that the repo can be pushed to without
# making the index and work tree inconsistent.
repo.git.checkout(commit.hexsha)
return repo
@pytest.fixture
def rule_creator(tmpdir, mock_rspec_repo: Repo, git_config: dict[str, str]):
cloned_repo = tmpdir.mkdir("cloned_repo")
return RuleCreator(mock_rspec_repo.working_dir, str(cloned_repo), git_config)
def test_reserve_rule_number_simple(rule_creator: RuleCreator, mock_rspec_repo: Repo):
'''Test that RuleCreator.reserve_rule_id() increments the id and returns the old value.'''
assert rule_creator.reserve_rule_number() == 0
assert read_counter_file(mock_rspec_repo) == '1'
def test_reserve_rule_number_parallel_reservations(tmpdir, mock_rspec_repo: Repo, git_config):
'''Test that RuleCreator.reserve_rule_id() works when multiple reservations are done in parallel.'''
cloned_repo1 = tmpdir.mkdir("cloned_repo1")
rule_creator1 = RuleCreator(mock_rspec_repo.working_dir, str(cloned_repo1), git_config)
cloned_repo2 = tmpdir.mkdir("cloned_repo2")
rule_creator2 = RuleCreator(mock_rspec_repo.working_dir, str(cloned_repo2), git_config)
assert rule_creator1.reserve_rule_number() == 0
assert rule_creator2.reserve_rule_number() == 1
assert rule_creator1.reserve_rule_number() == 2
assert read_counter_file(mock_rspec_repo) == '3'
def read_counter_file(repo):
'''Reads the counter file from the provided repository and returns its content.'''
repo.git.checkout(RuleCreator.ID_COUNTER_BRANCH)
counter_path = Path(repo.working_dir).joinpath(RuleCreator.ID_COUNTER_FILENAME)
return counter_path.read_text()
def test_create_new_rule_branch(rule_creator: RuleCreator, mock_rspec_repo: Repo):
'''Test create_new_rule_branch.'''
rule_number = rule_creator.reserve_rule_number()
languages = ['java', 'javascript']
branch = rule_creator.create_new_rule_branch(rule_number, languages)
# Check that the branch was pushed successfully to the origin
mock_rspec_repo.git.checkout(branch)
rule_dir = Path(mock_rspec_repo.working_dir).joinpath('rules', f'S{rule_number}')
assert rule_dir.exists()
common_root = rule_creator.TEMPLATE_PATH.joinpath('common')
for common_item in common_root.glob('**/*'):
if common_item.is_file():
expected_content = common_item.read_text().replace('${RSPEC_ID}', str(rule_number))
relative_path = common_item.relative_to(common_root)
actual_content = rule_dir.joinpath(relative_path).read_text()
assert actual_content == expected_content
lang_root = rule_creator.TEMPLATE_PATH.joinpath('language_specific')
for lang in languages:
for lang_item in lang_root.glob('**/*'):
if lang_item.is_file():
expected_content = lang_item.read_text().replace('${RSPEC_ID}', str(rule_number))
relative_path = lang_item.relative_to(lang_root)
actual_content = rule_dir.joinpath(lang, relative_path).read_text()
assert actual_content == expected_content

View File

@ -1,28 +0,0 @@
#!/bin/sh
# Create the new Rule in a branch
# It is used by the Github Action script "create_new_rspec.yml".
# Stop script in case of error.
set -e
RSPEC_ID=$1
LANGUAGES=$2
RULES_DIRECTORY=$3
scripts_dir=$(dirname "$0")
template_dir="${scripts_dir}/rspec_template"
rule_directory="${RULES_DIRECTORY}/S${RSPEC_ID}"
mkdir $rule_directory
cp $template_dir/common/* $rule_directory/
for language in $(echo $LANGUAGES | sed "s/,/ /g")
do
mkdir $rule_directory/$language
cp $template_dir/language_specific/* $rule_directory/$language/
done
cd $rule_directory
grep -rl '${RSPEC_ID}' . | xargs sed -i "s/\${RSPEC_ID}/${RSPEC_ID}/g"

View File

@ -1,21 +0,0 @@
#!/bin/sh
# Script reserving a RSPEC identifier.
# It is used by the Github Action script "create_new_rspec.yml".
# Stop script in case of error.
set -e
RSPEC_ID_COUNTER_FILE=$1
# Increment the next_id counter.
next_id=`cat ${RSPEC_ID_COUNTER_FILE}`
new_next_id=`expr $next_id + 1`
echo $new_next_id > ${RSPEC_ID_COUNTER_FILE}
git add ${RSPEC_ID_COUNTER_FILE}
git commit -m "Increment RSPEC ID counter"
git push origin rspec-id-counter
# Set the Environment variable for the next Github Action.
echo "::set-env name=RSPEC_ID::${next_id}"
echo "::set-output name=rspec-id::${next_id}"