mirror of
https://github.com/sartography/spiff-arena.git
synced 2025-03-03 10:30:37 +00:00
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Git_service."""
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess # noqa we need the subprocess module to safely run the git commands
|
|
import uuid
|
|
from typing import Optional
|
|
from typing import Union
|
|
|
|
from flask import current_app
|
|
from flask import g
|
|
|
|
from spiffworkflow_backend.config import ConfigurationError
|
|
from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
|
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
|
|
|
|
|
class MissingGitConfigsError(Exception):
|
|
"""MissingGitConfigsError."""
|
|
|
|
|
|
class InvalidGitWebhookBodyError(Exception):
|
|
"""InvalidGitWebhookBodyError."""
|
|
|
|
|
|
class GitCloneUrlMismatchError(Exception):
|
|
"""GitCloneUrlMismatchError."""
|
|
|
|
|
|
class GitCommandError(Exception):
|
|
"""GitCommandError."""
|
|
|
|
|
|
# TOOD: check for the existence of git and configs on bootup if publishing is enabled
|
|
class GitService:
|
|
"""GitService."""
|
|
|
|
@classmethod
|
|
def get_current_revision(cls) -> str:
|
|
"""Get_current_revision."""
|
|
bpmn_spec_absolute_dir = current_app.config["SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR"]
|
|
# The value includes a carriage return character at the end, so we don't grab the last character
|
|
with FileSystemService.cd(bpmn_spec_absolute_dir):
|
|
return cls.run_shell_command_to_get_stdout(["git", "rev-parse", "--short", "HEAD"])
|
|
|
|
@classmethod
|
|
def get_instance_file_contents_for_revision(
|
|
cls,
|
|
process_model: ProcessModelInfo,
|
|
revision: str,
|
|
file_name: Optional[str] = None,
|
|
) -> str:
|
|
"""Get_instance_file_contents_for_revision."""
|
|
bpmn_spec_absolute_dir = current_app.config["SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR"]
|
|
process_model_relative_path = FileSystemService.process_model_relative_path(process_model)
|
|
file_name_to_use = file_name
|
|
if file_name_to_use is None:
|
|
file_name_to_use = process_model.primary_file_name
|
|
with FileSystemService.cd(bpmn_spec_absolute_dir):
|
|
shell_command = [
|
|
"git",
|
|
"show",
|
|
f"{revision}:{process_model_relative_path}/{file_name_to_use}",
|
|
]
|
|
return cls.run_shell_command_to_get_stdout(shell_command)
|
|
|
|
@classmethod
|
|
def commit(
|
|
cls,
|
|
message: str,
|
|
repo_path: Optional[str] = None,
|
|
branch_name: Optional[str] = None,
|
|
) -> str:
|
|
"""Commit."""
|
|
cls.check_for_basic_configs()
|
|
branch_name_to_use = branch_name
|
|
if branch_name_to_use is None:
|
|
branch_name_to_use = current_app.config["SPIFFWORKFLOW_BACKEND_GIT_SOURCE_BRANCH"]
|
|
repo_path_to_use = repo_path
|
|
if repo_path is None:
|
|
repo_path_to_use = current_app.config["SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR"]
|
|
if repo_path_to_use is None:
|
|
raise ConfigurationError("SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR config must be set")
|
|
|
|
shell_command_path = os.path.join(current_app.root_path, "..", "..", "bin", "git_commit_bpmn_models_repo")
|
|
shell_command = [
|
|
shell_command_path,
|
|
repo_path_to_use,
|
|
message,
|
|
branch_name_to_use,
|
|
]
|
|
return cls.run_shell_command_to_get_stdout(shell_command)
|
|
|
|
@classmethod
|
|
def check_for_basic_configs(cls) -> None:
|
|
"""Check_for_basic_configs."""
|
|
if current_app.config["SPIFFWORKFLOW_BACKEND_GIT_SOURCE_BRANCH"] is None:
|
|
raise MissingGitConfigsError(
|
|
"Missing config for SPIFFWORKFLOW_BACKEND_GIT_SOURCE_BRANCH. "
|
|
"This is required for publishing process models"
|
|
)
|
|
|
|
@classmethod
|
|
def check_for_publish_configs(cls) -> None:
|
|
"""Check_for_configs."""
|
|
cls.check_for_basic_configs()
|
|
if current_app.config["SPIFFWORKFLOW_BACKEND_GIT_PUBLISH_TARGET_BRANCH"] is None:
|
|
raise MissingGitConfigsError(
|
|
"Missing config for SPIFFWORKFLOW_BACKEND_GIT_PUBLISH_TARGET_BRANCH. "
|
|
"This is required for publishing process models"
|
|
)
|
|
if current_app.config["SPIFFWORKFLOW_BACKEND_GIT_PUBLISH_CLONE_URL"] is None:
|
|
raise MissingGitConfigsError(
|
|
"Missing config for SPIFFWORKFLOW_BACKEND_GIT_PUBLISH_CLONE_URL."
|
|
" This is required for publishing process models"
|
|
)
|
|
|
|
@classmethod
|
|
def run_shell_command_as_boolean(cls, command: list[str]) -> bool:
|
|
"""Run_shell_command_as_boolean."""
|
|
# we know result will be a bool here
|
|
result: bool = cls.run_shell_command(command, return_success_state=True) # type: ignore
|
|
return result
|
|
|
|
@classmethod
|
|
def run_shell_command_to_get_stdout(cls, command: list[str]) -> str:
|
|
"""Run_shell_command_to_get_stdout."""
|
|
# we know result will be a CompletedProcess here
|
|
result: subprocess.CompletedProcess[bytes] = cls.run_shell_command(
|
|
command, return_success_state=False
|
|
) # type: ignore
|
|
return result.stdout.decode("utf-8").strip()
|
|
|
|
@classmethod
|
|
def run_shell_command(
|
|
cls, command: list[str], return_success_state: bool = False
|
|
) -> Union[subprocess.CompletedProcess[bytes], bool]:
|
|
"""Run_shell_command."""
|
|
my_env = os.environ.copy()
|
|
my_env["GIT_COMMITTER_NAME"] = current_app.config.get("SPIFFWORKFLOW_BACKEND_GIT_USERNAME") or "unknown"
|
|
|
|
my_env["GIT_COMMITTER_EMAIL"] = (
|
|
current_app.config.get("SPIFFWORKFLOW_BACKEND_GIT_USER_EMAIL") or "unknown@example.org"
|
|
)
|
|
|
|
# SSH authentication can be also provided via gitconfig.
|
|
ssh_key_path = current_app.config.get("SPIFFWORKFLOW_BACKEND_GIT_SSH_PRIVATE_KEY_PATH")
|
|
if ssh_key_path is not None:
|
|
my_env["GIT_SSH_COMMAND"] = (
|
|
"ssh -F /dev/null -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i %s" % ssh_key_path
|
|
)
|
|
|
|
# this is fine since we pass the commands directly
|
|
result = subprocess.run(command, check=False, capture_output=True, env=my_env) # noqa
|
|
|
|
if return_success_state:
|
|
return result.returncode == 0
|
|
|
|
if result.returncode != 0:
|
|
stdout = result.stdout.decode("utf-8")
|
|
stderr = result.stderr.decode("utf-8")
|
|
raise GitCommandError(f"Failed to execute git command: {command}Stdout: {stdout}Stderr: {stderr}")
|
|
|
|
return result
|
|
|
|
# only supports github right now
|
|
@classmethod
|
|
def handle_web_hook(cls, webhook: dict) -> bool:
|
|
"""Handle_web_hook."""
|
|
cls.check_for_publish_configs()
|
|
|
|
if "repository" not in webhook or "clone_url" not in webhook["repository"]:
|
|
raise InvalidGitWebhookBodyError(
|
|
f"Cannot find required keys of 'repository:clone_url' from webhook body: {webhook}"
|
|
)
|
|
|
|
config_clone_url = current_app.config["SPIFFWORKFLOW_BACKEND_GIT_PUBLISH_CLONE_URL"]
|
|
repo = webhook["repository"]
|
|
valid_clone_urls = [repo["clone_url"], repo["git_url"], repo["ssh_url"]]
|
|
if config_clone_url not in valid_clone_urls:
|
|
raise GitCloneUrlMismatchError(
|
|
"Configured clone url does not match the repo URLs from webhook: %s =/= %s"
|
|
% (config_clone_url, valid_clone_urls)
|
|
)
|
|
|
|
# Test webhook requests have a zen koan and hook info.
|
|
if "zen" in webhook or "hook_id" in webhook:
|
|
return False
|
|
|
|
if "ref" not in webhook:
|
|
raise InvalidGitWebhookBodyError(f"Could not find the 'ref' arg in the webhook boy: {webhook}")
|
|
|
|
if current_app.config["SPIFFWORKFLOW_BACKEND_GIT_SOURCE_BRANCH"] is None:
|
|
raise MissingGitConfigsError(
|
|
"Missing config for SPIFFWORKFLOW_BACKEND_GIT_SOURCE_BRANCH. This is"
|
|
" required for updating the repository as a result of the webhook"
|
|
)
|
|
|
|
ref = webhook["ref"]
|
|
git_branch = current_app.config["SPIFFWORKFLOW_BACKEND_GIT_SOURCE_BRANCH"]
|
|
if ref != f"refs/heads/{git_branch}":
|
|
return False
|
|
|
|
with FileSystemService.cd(current_app.config["SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR"]):
|
|
cls.run_shell_command(["git", "pull", "--rebase"])
|
|
return True
|
|
|
|
@classmethod
|
|
def publish(cls, process_model_id: str, branch_to_update: str) -> str:
|
|
"""Publish."""
|
|
cls.check_for_publish_configs()
|
|
source_process_model_root = FileSystemService.root_path()
|
|
source_process_model_path = os.path.join(source_process_model_root, process_model_id)
|
|
unique_hex = uuid.uuid4().hex
|
|
clone_dir = f"sample-process-models.{unique_hex}"
|
|
|
|
# clone new instance of sample-process-models, checkout branch_to_update
|
|
# we are adding a guid to this so the flake8 issue has been mitigated
|
|
destination_process_root = f"/tmp/{clone_dir}" # noqa
|
|
|
|
git_clone_url = current_app.config["SPIFFWORKFLOW_BACKEND_GIT_PUBLISH_CLONE_URL"]
|
|
cmd = ["git", "clone", git_clone_url, destination_process_root]
|
|
|
|
cls.run_shell_command(cmd)
|
|
with FileSystemService.cd(destination_process_root):
|
|
# create publish branch from branch_to_update
|
|
cls.run_shell_command(["git", "checkout", branch_to_update])
|
|
branch_to_pull_request = f"publish-{process_model_id}"
|
|
|
|
# check if branch exists and checkout appropriately
|
|
command = [
|
|
"git",
|
|
"show-ref",
|
|
"--verify",
|
|
f"refs/remotes/origin/{branch_to_pull_request}",
|
|
]
|
|
if cls.run_shell_command_as_boolean(command):
|
|
cls.run_shell_command(["git", "checkout", branch_to_pull_request])
|
|
else:
|
|
cls.run_shell_command(["git", "checkout", "-b", branch_to_pull_request])
|
|
|
|
# copy files from process model into the new publish branch
|
|
destination_process_model_path = os.path.join(destination_process_root, process_model_id)
|
|
if os.path.exists(destination_process_model_path):
|
|
shutil.rmtree(destination_process_model_path)
|
|
shutil.copytree(source_process_model_path, destination_process_model_path)
|
|
|
|
# add and commit files to branch_to_pull_request, then push
|
|
commit_message = (
|
|
f"Request to publish changes to {process_model_id}, "
|
|
f"from {g.user.username} on {current_app.config['ENV_IDENTIFIER']}"
|
|
)
|
|
cls.commit(commit_message, destination_process_root, branch_to_pull_request)
|
|
|
|
# build url for github page to open PR
|
|
git_remote = cls.run_shell_command_to_get_stdout(["git", "config", "--get", "remote.origin.url"])
|
|
git_remote = re.sub(pattern=r"^git@([^:]+):", repl="https://\\1/", string=git_remote)
|
|
|
|
remote_url = git_remote.strip().replace(".git", "")
|
|
pr_url = f"{remote_url}/compare/{branch_to_update}...{branch_to_pull_request}?expand=1"
|
|
|
|
# try to clean up
|
|
if os.path.exists(destination_process_root):
|
|
shutil.rmtree(destination_process_root)
|
|
|
|
return pr_url
|