CommitFiles

This script gets content files as input from the context, commits the files in the correct folder and creates the pull request text.

python · XSOAR CI/CD

Source

import io
import os
from contextlib import redirect_stderr, redirect_stdout
from os.path import exists
from pathlib import Path

import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401
from demisto_sdk.commands.common.constants import ENTITY_TYPE_TO_DIR
from demisto_sdk.commands.common.tools import find_type
from demisto_sdk.commands.split.ymlsplitter import YmlSplitter

PR_TEMPLATE = (
    "### Pull Request created in Cortex XSOAR\n"
    "**Created by:** {}\n"
    "**Pack:** {}\n"
    "**Branch:** {}\n"
    "**Link to incident in Cortex XSOAR:** {}\n\n"
    "{}\n\n"
    "---"
)

file_path_to_sha: dict[str, str] = {}

files_path = []


class ContentFile:  # pragma: no cover
    path_to_file: str = ""
    file_name: str = ""
    content_type: str = ""
    file_text: str = ""
    entry_id: str = ""

    def __init__(self, pack_name=None, file=None):
        if not pack_name and not file:
            return

        # read the file from entry id
        file_object = demisto.getFilePath(file["EntryID"])
        with open(file_object["path"]) as f:
            file_contents = f.read()

        self.file_text = file_contents
        self.file_name = file["Name"]
        self.entry_id = file["EntryID"]

        if self.file_name == "metadata.json":
            self.file_name = "pack_metadata.json"
            self.path_to_file = os.path.join("Packs", pack_name)
            self.content_type = "metadata"
        else:
            with open(file_object["name"], "w") as f:
                f.write(file_contents)

            file_type = find_type(file_object["name"])
            os.remove(file_object["name"])
            self.content_type = file_type.value if file_type else file_type
            folder = ENTITY_TYPE_TO_DIR.get(self.content_type, "")
            self.path_to_file = os.path.join("Packs", pack_name, folder)


def get_file_sha(branch_name: str, content_file: ContentFile, get_files_command: str):
    full_path = os.path.join(content_file.path_to_file, content_file.file_name)

    sha = file_path_to_sha.get(full_path)
    if sha:
        return sha

    # try to get the file from branch
    status, list_files_res = execute_command(
        get_files_command, {"branch": branch_name, "path": content_file.path_to_file}, fail_on_error=False
    )
    if status:
        for file in list_files_res:
            file_path_to_sha[file["path"]] = file["sha"]

    return file_path_to_sha.get(full_path)


def escape_placeholders(content: str) -> str:
    """
    Escapes XSOAR-style placeholders ${somecontext.value} to prevent context substitution.

    Args:
        content (str): The input string containing placeholders.

    Returns:
        str: The string with placeholders escaped.
    """
    return re.sub(r"\$(\{[^}]+})", r"$\\\1", content)  # Prepend a backslash to preserve the placeholder


def commit_content_item(
    branch_name: str, content_file: ContentFile, new_files: List, modified_files: List, keep_placeholders_in_files: bool = False
):
    commit_args = {
        "commit_message": f"Added {content_file.file_name}",
        "path_to_file": f"{content_file.path_to_file}/{content_file.file_name}",
        "branch_name": branch_name,
        "file_text": content_file.file_text,
    }

    file_sha = get_file_sha(branch_name, content_file, "Github-list-files")

    # dont commit pack_metadata.json if already exists in the branch
    if file_sha and content_file.file_name == "pack_metadata.json":
        return
    elif file_sha:
        # update existing file
        commit_args["file_sha"] = file_sha
        commit_args["commit_message"] = f"Updated {content_file.file_name}"
        modified_files.append(content_file.file_name)
    else:
        # new file added
        if keep_placeholders_in_files:
            demisto.debug(f"Got {keep_placeholders_in_files=}, escaping placeholders with \\")
            commit_args["file_text"] = escape_placeholders(content_file.file_text)
            commit_args["placeholders_escaped"] = "True"
        new_files.append(content_file.file_name)

    status, commit_res = execute_command("Github-commit-file", commit_args, fail_on_error=False)
    if not status:
        raise DemistoException(commit_res)


def commit_content_item_gitlab(branch_name: str, content_file: ContentFile, new_files: List, modified_files: List):
    commit_args = {
        "commit_message": f"Added {content_file.file_name}",
        "file_path": f"{content_file.path_to_file}/{content_file.file_name}",
        "branch": branch_name,
        "file_content": content_file.file_text,
    }
    status, commit_res = execute_command("gitlab-file-create", commit_args, fail_on_error=False)
    if isinstance(commit_res, dict):
        new_files.append(content_file.file_name)
    elif isinstance(commit_res, str) and "already exists" in commit_res:
        demisto.debug(f"The file {content_file.file_name} already exist, running update command")
        if content_file.file_name == "pack_metadata.json":
            return
        commit_args["commit_message"] = f"Updated {content_file.file_name}"
        modified_files.append(content_file.file_name)
        status, commit_res = execute_command("gitlab-file-update", commit_args, fail_on_error=False)
    if not status:
        raise DemistoException(commit_res)


def does_file_exist(branch_name: str, content_file: ContentFile) -> bool:
    full_path = os.path.join(content_file.path_to_file, content_file.file_name)

    if full_path in files_path:
        return True
    # try to get the file from branch
    status, list_commit_res = execute_command(
        "bitbucket-commit-list", {"included_branches": branch_name, "file_path": full_path}, fail_on_error=False
    )
    if list_commit_res and len(list_commit_res) > 0:
        files_path.append(full_path)
        return True
    return False


def searched_file_path(branch_name: str, content_file: ContentFile) -> bool:
    full_path = Path(content_file.path_to_file, content_file.file_name)

    if str(full_path) in files_path:  # the files list, check if the file already exists in the list
        return True
    # try to get the file from branch
    response = execute_command(
        "azure-devops-file-list", args={"branch_name": branch_name.split("/")[-1], "recursion_level": "Full"}
    )
    files_set = {file["path"] for file in response.get("value", [])}
    for file in files_set:
        if str(full_path) in file:
            files_path.append(str(full_path))
            return True
    return False


def commit_content_item_bitbucket(branch_name: str, content_file: ContentFile, new_files: List, modified_files: List):
    commit_args = {
        "message": f"Added {content_file.file_name}",
        "file_name": f"{content_file.path_to_file}/{content_file.file_name}",
        "branch": f"{branch_name}",
        "file_content": f"{content_file.file_text}",
    }
    file_status = does_file_exist(branch_name, content_file)
    # dont commit pack_metadata.json if already exists in the branch
    if file_status and content_file.file_name == "pack_metadata.json":
        return
    elif file_status:
        # update existing file
        commit_args["message"] = f"Updated {content_file.file_name}"
        modified_files.append(content_file.file_name)
    else:
        # new file added
        new_files.append(content_file.file_name)
    try:
        demisto.executeCommand("bitbucket-commit-create", args=commit_args)
    except DemistoException as e:
        raise DemistoException(f"Failed to execute bitbucket-commit-create command. Error: {e}, {traceback.format_exc()}")


def commit_content_item_azure_devops(branch_name: str, content_file: ContentFile, new_files: List, modified_files: List):
    file_args = {
        "commit_comment": f"{content_file.file_name} was added.",
        "file_path": f"{content_file.path_to_file}/{content_file.file_name}",
        "branch_name": f"{branch_name}",
        "file_content": f"{content_file.file_text}",
    }

    file_exists = searched_file_path(branch_name, content_file)

    # don't commit pack_metadata.json if already exists in the branch
    if file_exists and content_file.file_name == "pack_metadata.json":
        return
    response = demisto.executeCommand("azure-devops-branch-list", args={})
    branches_list = response[0].get("Contents", {}).get("value", []) if response and isinstance(response, list) else []
    for branch in branches_list:
        if branch["name"] == branch_name:
            branch_id = branch["objectId"]
            break
    else:
        raise DemistoException("Failed to find a corresponding branch id to the given branch name.")
    file_args["branch_id"] = branch_id

    if file_exists:
        # update existing file
        file_args["commit_comment"] = f"{content_file.file_name} was updated."
        modified_files.append(content_file.file_name)
        command_to_execute = "azure-devops-file-update"
    else:
        # new file added
        new_files.append(content_file.file_name)
        command_to_execute = "azure-devops-file-create"

    try:
        demisto.executeCommand(command_to_execute, args=file_args)
    except DemistoException as e:
        raise DemistoException(
            f"Failed to execute azure-devops-file-update or azure-devops-file-create commands. Error: {e}, "
        ) from e


def split_yml_file(content_file: ContentFile):  # pragma: no cover
    content_files = []

    if content_file.content_type == "script":
        base_name = content_file.file_name.replace("automation-", "").replace(".yml", "")
    else:
        base_name = content_file.file_name.replace("integration-", "").replace(".yml", "")

    # create the yml file from entry id
    with open(content_file.file_name, "w") as f:
        f.write(content_file.file_text)

    output_capture = io.StringIO()

    # split the yml file
    yml_splitter = YmlSplitter(
        content_file.file_name,
        base_name=base_name,
        output=base_name,  # pylint: disable=E1123
        file_type=content_file.content_type,
        no_pipenv=True,
        no_code_formatting=True,
        no_logging=True,
        no_readme=True,
    )

    script_type = yml_splitter.yml_data.get("type")
    if not script_type:
        script_type = yml_splitter.yml_data.get("script", {}).get("type")

    if script_type == "python":
        script_extention = "py"
    elif script_type == "javascript":
        script_extention = "js"
    elif script_type == "powershell":
        script_extention = "ps1"
    else:
        script_extention = ""
        demisto.debug(f"{script_type=} didn't match any condition. {script_extention=}")

    with redirect_stdout(output_capture), redirect_stderr(output_capture):
        yml_splitter.extract_to_package_format()

    yml_file_path = f"{base_name}/{base_name}.yml"
    script_file_path = f"{base_name}/{base_name}.{script_extention}"
    path_to_file = os.path.join(content_file.path_to_file, base_name)

    # read the py and yml files content
    with open(yml_file_path) as f:
        yml_txt = f.read()

    with open(script_file_path) as f:
        script_txt = f.read()

    # create the yml file
    yml_file = ContentFile()
    yml_file.file_text = yml_txt
    yml_file.file_name = f"{base_name}.yml"
    yml_file.path_to_file = path_to_file
    content_files.append(yml_file)

    # create the script file
    script_file = ContentFile()
    script_file.file_text = script_txt
    script_file.file_name = f"{base_name}.{script_extention}"
    script_file.path_to_file = path_to_file
    content_files.append(script_file)

    # create the description file
    description_file_path = f"{base_name}/{base_name}_description.md"
    if exists(description_file_path):
        with open(description_file_path) as f:
            description_txt = f.read()

        description_file = ContentFile()
        description_file.file_text = description_txt
        description_file.file_name = f"{base_name}_description.md"
        description_file.path_to_file = path_to_file
        content_files.append(description_file)

    return content_files


def commit_git(
    git_integration: str,
    branch_name: str,
    content_file: ContentFile,
    new_files: List,
    modified_files: List,
    keep_placeholders_in_files: bool,
):
    if git_integration == "Gitlab":
        commit_content_item_gitlab(branch_name, content_file, new_files, modified_files)
    elif git_integration == "GitHub":
        commit_content_item(branch_name, content_file, new_files, modified_files, keep_placeholders_in_files)
    elif git_integration == "Bitbucket":
        commit_content_item_bitbucket(branch_name, content_file, new_files, modified_files)
    elif git_integration == "AzureDevOps":
        commit_content_item_azure_devops(branch_name, content_file, new_files, modified_files)
    else:
        raise DemistoException(f"Unexpected {git_integration=}. Possible values: Gitlab, GitHub, Bitbucket and AzureDevOps.")


""" MAIN FUNCTION """


def main():  # pragma: no cover
    try:
        files = demisto.getArg("files")
        branch_name = demisto.getArg("branch")
        pack_name = demisto.getArg("pack")
        user = demisto.getArg("user")
        comment = demisto.getArg("comment")
        template = demisto.getArg("template")
        git_integration = demisto.getArg("git_integration")
        keep_placeholders_in_files = demisto.getArg("cicd_keep_placeholders_in_files") or False
        new_files: List[str] = []
        modified_files: List[str] = []

        if not template:
            template = PR_TEMPLATE

        if not comment:
            comment = ""

        username = user.get("username")
        if user.get("email"):
            username = f'{username} ({user.get("email")})'

        # commit the files from the input
        for file in files:
            if file.get("Unzipped"):
                continue

            # create ContentFile item
            content_file = ContentFile(pack_name=pack_name, file=file)

            if content_file.content_type in ("script", "integration"):
                # split automation file to yml and script files
                content_files = split_yml_file(content_file)
                for file_to_commit in content_files:
                    commit_git(
                        git_integration, branch_name, file_to_commit, new_files, modified_files, keep_placeholders_in_files
                    )

            else:
                commit_git(git_integration, branch_name, content_file, new_files, modified_files, keep_placeholders_in_files)
        incident_url = demisto.demistoUrls().get("investigation")

        # create the PR text
        pr_body = template.format(f"{username}\n", f"{pack_name}\n", f"{branch_name}\n", f"{incident_url}\n", f"{comment}\n")
        if new_files:
            pr_body = f"{pr_body}\n\n### New files\n- "
            pr_body = pr_body + "\n- ".join(new_files)

        if modified_files:
            pr_body = f"{pr_body}\n\n### Modified files\n- "
            pr_body = pr_body + "\n- ".join(modified_files)

        return_results(CommandResults(readable_output=pr_body, outputs_prefix="PR_text", outputs=pr_body))

    except Exception as ex:
        return_error(f"Failed to execute CommitFiles script. Error: {ex}", error=ex)


""" ENTRY POINT """

if __name__ in ("__main__", "__builtin__", "builtins"):
    main()

README

This script gets content files as input from the context, commits the files in the correct folder and creates the pull request text.

Script Data


Name Description
Script Type python3
Cortex XSOAR Version 6.0.0

Used In


This script is used in the following playbooks and scripts.

  • Pull Request Creation - Github
  • Pull Request Creation - Bitbucket

Inputs


Argument Name Description
files The files to commit.
branch The branch name to commit.
git_integration Which version control integration to use.
pack The name of the pack.
user The current user details from the command “getUsers”.
comment Short description to add to the pull request text.
template Pull request description template.

Outputs


Path Description Type
PR_text The pull request text. string