Загрузка данных


import os
import random
import sys
import uuid

from datetime import datetime
from html import escape

from atlassian import Confluence
from bs4 import BeautifulSoup


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

CONFLUENCE_URL = "https://wiki.infotecs.int"
VERIFY_SSL = False

METADATA_PATH = sys.argv[1] if len(sys.argv) > 1 else "metadata"

# Индекс строки, после которой добавляется новая сборка.
OFFSET_ROW = 1

# Название макроса раскрывающегося блока в Confluence.
EXPAND_MACRO_TITLE = "NA"


# Для каждого продукта указываем, какие версии выводить в четвёртой колонке.
# Порядок полей здесь определяет порядок их отображения в таблице.
PRODUCT_CONFIG = {
    "l2kb10": {
        "versions": [
            ("fpga_ver", "БШМ"),
            ("dnsd_ver", "ДНСД"),
        ],
    },

    "l2qd10": {
        "versions": [
            ("fpga_ver", "БШМ"),
            ("dnsd_ver", "ДНСД"),
            ("qss_protoqa", "Protoqa"),
            ("qss_asn_codec", "qss_asn_codec"),
        ],
    },

    "l2qd100": {
        "versions": [
            ("fpga_ver_savona", "БШМ (Savona)"),
            ("fpga_ver_hem100", "БШМ (HEM100)"),
            ("dnsd_ver", "ДНСД"),
            ("qss_protoqa", "Protoqa"),
            ("qss_asn_codec", "qss_asn_codec"),
        ],
    },
}


# Поддержка альтернативных названий некоторых полей.
# Первым указывается актуальное имя, последним — старое.
FIELD_ALIASES = {
    "bug": ("bug", "bugs"),
    "qss_protoqa": ("qss_protoqa", "protoqa_ver"),
    "qss_asn_codec": ("qss_asn_codec", "qss_asn_codec_ver"),
}


# ---------------------------------------------------------------------------
# Metadata
# ---------------------------------------------------------------------------

def read_metadata(path):
    """Читает metadata в формате key=value."""

    metadata = {}

    with open(path, "r", encoding="utf-8") as file:
        for line_number, raw_line in enumerate(file, start=1):
            line = raw_line.strip()

            # Пропускаем пустые строки и комментарии.
            if not line or line.startswith("#"):
                continue

            # Разделяем только по первому '='.
            # Всё остальное, включая пробелы и '=', остаётся в значении.
            key, separator, value = line.partition("=")

            if not separator:
                raise ValueError(
                    f"Некорректная строка в metadata, "
                    f"строка {line_number}: {raw_line.rstrip()!r}. "
                    "Ожидается формат key=value."
                )

            key = key.strip()
            value = value.strip()

            if not key:
                raise ValueError(
                    f"Пустое имя поля в metadata, строка {line_number}."
                )

            if key in metadata:
                raise ValueError(
                    f"Поле {key!r} повторяется в metadata, "
                    f"строка {line_number}."
                )

            metadata[key] = value

    return metadata


def get_value(metadata, key):
    """Возвращает непустое значение поля с учётом известных алиасов."""

    aliases = FIELD_ALIASES.get(key, (key,))

    for alias in aliases:
        value = metadata.get(alias, "").strip()

        if value:
            return value

    return ""


def require_value(metadata, key):
    """Возвращает обязательное поле или сообщает об ошибке."""

    value = get_value(metadata, key)

    if not value:
        raise ValueError(
            f"В metadata отсутствует обязательное поле {key!r}."
        )

    return value


# ---------------------------------------------------------------------------
# Confluence
# ---------------------------------------------------------------------------

def create_confluence():
    """Создаёт клиент Confluence."""

    confluence = Confluence(
        url=CONFLUENCE_URL,
        token=os.environ["confluence_token"],
        verify_ssl=VERIFY_SSL,
    )

    if VERIFY_SSL:
        print("SSL verification is on")
    else:
        print("SSL verification is off")

        import urllib3
        urllib3.disable_warnings(
            urllib3.exceptions.InsecureRequestWarning
        )

    return confluence


def make_user_link(confluence, username):
    """Формирует ссылку на пользователя Confluence."""

    if username == "svc_l2_teamcity":
        return "<strong>Teamcity dependency</strong>"

    user_details = confluence.get_user_details_by_username(username)
    user_key = user_details.get("userKey")

    if not user_key:
        raise ValueError(
            f"Не удалось получить userKey пользователя {username!r}."
        )

    return (
        '<ac:link>'
        f'<ri:user ri:userkey="{escape(user_key, quote=True)}"/>'
        '</ac:link>'
    )


# ---------------------------------------------------------------------------
# Confluence macros
# ---------------------------------------------------------------------------

def macro_expand(title, body, macro_type="expand"):
    """Формирует макрос expand или ui-expand."""

    macro_id = str(uuid.uuid4())

    result = (
        '<ac:structured-macro'
        f' ac:macro-id="{macro_id}"'
        f' ac:name="{escape(macro_type, quote=True)}"'
        ' ac:schema-version="1">'
    )

    if macro_type == "ui-expand":
        result += (
            '<ac:parameter ac:name="expanded">true</ac:parameter>'
        )

    result += (
        f'<ac:parameter ac:name="title">{escape(title)}</ac:parameter>'
        '<ac:rich-text-body>'
        f'{body}'
        '</ac:rich-text-body>'
        '</ac:structured-macro>'
    )

    return result


def macro_tfs(item):
    """Формирует макрос ссылки на задачу TFS."""

    return (
        '<p>'
        '<ac:structured-macro'
        ' ac:name="work-item-tfs"'
        ' ac:schema-version="2">'
        f'<ac:parameter ac:name="itemID">{escape(item)}</ac:parameter>'
        '<ac:parameter ac:name="schemaVersion">1</ac:parameter>'
        '<ac:parameter ac:name="host">0</ac:parameter>'
        '<ac:parameter ac:name="title">true</ac:parameter>'
        '<ac:parameter ac:name="status">true</ac:parameter>'
        '</ac:structured-macro>'
        '</p>'
    )


def macro_checkbox():
    """Формирует незавершённый пункт task-list."""

    task_id = str(random.randint(1_000_000, 9_999_999))

    return (
        '<ac:task-list>'
        f'<ac:task><ac:task-id>{task_id}</ac:task-id>'
        '<ac:task-status>incomplete</ac:task-status>'
        '</ac:task></ac:task-list>'
    )


# ---------------------------------------------------------------------------
# HTML / storage format
# ---------------------------------------------------------------------------

def append_fragment(parent, html_fragment):
    """
    Добавляет HTML-фрагмент в тег.

    Содержимое сначала разбирается BeautifulSoup, после чего его дочерние
    элементы вставляются непосредственно в родительский тег.
    """

    fragment = BeautifulSoup(html_fragment, "html.parser")

    for element in list(fragment.contents):
        parent.append(element.extract())


def append_cell_content(cell, content):
    """Добавляет содержимое ячейки, оборачивая обычный текст в <p>."""

    if not content:
        return

    if "<" in content:
        append_fragment(cell, content)
    else:
        append_fragment(cell, f"<p>{escape(content)}</p>")


def make_text_paragraph(label, value):
    """Формирует абзац с выделенным значением."""

    return (
        f'<p>{escape(label)}: '
        f'<strong>{escape(value)}</strong></p>'
    )


# ---------------------------------------------------------------------------
# Build information
# ---------------------------------------------------------------------------

def make_build_info(metadata, confluence):
    """
    Формирует информацию о сборке для третьей колонки.

    Добавляются только непустые поля, присутствующие в metadata.
    """

    parts = []

    # Разработчик, поставивший сборку.
    username = get_value(metadata, "user")

    if username:
        user_link = make_user_link(confluence, username)
        parts.append(f"<p>Поставил сборку: {user_link}</p>")

    # Ветка.
    hw_branch = get_value(metadata, "hw_branch")

    if hw_branch:
        parts.append(make_text_paragraph("Из ветки", hw_branch))

    # Путь выгрузки.
    upload_path = get_value(metadata, "upload_path")

    if upload_path:
        parts.append(make_text_paragraph("Путь выгрузки", upload_path))

    # Тип сборки.
    #
    # Если присутствуют feature и build_type, сохраняем старый формат:
    # base (test-release).
    # Если feature отсутствует, выводим только build_type.
    feature = get_value(metadata, "feature")
    build_type = get_value(metadata, "build_type")

    if feature and build_type:
        build_type_text = f"{feature} ({build_type})"
    else:
        build_type_text = feature or build_type

    if build_type_text:
        parts.append(
            make_text_paragraph("Тип сборки", build_type_text)
        )

    return "".join(parts)


def make_versions(metadata, hwtarget):
    """Формирует содержимое четвёртой колонки по конфигурации продукта."""

    parts = []

    for field, label in PRODUCT_CONFIG[hwtarget]["versions"]:
        value = get_value(metadata, field)

        # Если версия не указана, не создаём для неё абзац.
        if not value:
            continue

        parts.append(make_text_paragraph(label, value))

    return "".join(parts)


def make_task_and_bug_links(metadata):
    """Формирует ссылки на задачу и баг, если указанные номера числовые."""

    parts = []

    task = get_value(metadata, "task")
    bug = get_value(metadata, "bug")

    if task.isnumeric():
        parts.append(macro_tfs(task))

    if bug.isnumeric():
        parts.append(macro_tfs(bug))

    return "".join(parts)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    metadata = read_metadata(METADATA_PATH)

    # Проверяем продукт и номер сборки.
    hwtarget = require_value(metadata, "hwtarget")
    hw_ver = require_value(metadata, "hw_ver")

    if hwtarget not in PRODUCT_CONFIG:
        supported_targets = ", ".join(PRODUCT_CONFIG)

        raise ValueError(
            f"Неизвестный hwtarget: {hwtarget!r}. "
            f"Поддерживаемые значения: {supported_targets}."
        )

    # Не выгружаем сборку, предназначенную не для тестирования.
    comment = get_value(metadata, "comment")

    if comment == "Не тестировать":
        print(
            "Сборка помечена как 'Не тестировать'. "
            "Пропускаем обновление таблицы в Confluence."
        )
        return

    confluence = create_confluence()

    # Для третьей колонки.
    build_info = make_build_info(metadata, confluence)

    info_build_link = (
        f'<p>Номер сборки: <strong>{escape(hw_ver)}</strong></p>'
        f'{build_info}'
    )

    # Для четвёртой колонки.
    ver_link = make_versions(metadata, hwtarget)

    # Для пятой колонки.
    task_and_bug_link = make_task_and_bug_links(metadata)

    # Для шестой колонки.
    comment_link = (
        f'<p>{escape(comment)}</p>' if comment else ""
    )

    # Дата сборки.
    date = datetime.today().strftime("%d-%m-%Y")

    # Данные новой строки таблицы.
    #
    # hwtarget намеренно не выводится.
    # Порядок колонок соответствует существующей таблице.
    row_data = [
        date,                  # 1. Дата сборки
        macro_checkbox(),      # 2. Проверка разработчиком
        info_build_link,       # 3. Информация о сборке
        ver_link,              # 4. Версии компонентов
        task_and_bug_link,     # 5. Задачи и баги
        comment_link,          # 6. Примечание
        "",                    # 7. Тестирование автостендом
        macro_checkbox(),      # 8. Проверка тестировщиком
        "",                    # 9. Сценарий
        "",                    # 10. Результат
        "",                    # 11. Примечания
    ]

    # Получаем страницу Confluence.
    page_id = os.environ["wiki_page_thematic_id"]

    wiki_page = confluence.get_page_by_id(
        page_id,
        expand="body.storage",
        status=None,
        version=None,
    )

    body_page = wiki_page["body"]["storage"]["value"]
    main_title = wiki_page["title"]

    main_body = BeautifulSoup(body_page, "html.parser")

    # -----------------------------------------------------------------------
    # Ищем существующую запись с этим номером сборки.
    # -----------------------------------------------------------------------

    find_flag = False

    rows = main_body.find_all("tr")

    for row in rows[OFFSET_ROW + 1:]:
        cells = row.find_all("td")

        if len(cells) < 3:
            continue

        strong_tags = cells[2].find_all("strong")
        strong_values = [
            tag.get_text(strip=True)
            for tag in strong_tags
        ]

        if hw_ver not in strong_values:
            continue

        find_flag = True

        if len(cells) < 6:
            raise ValueError(
                f"Строка сборки {hw_ver} содержит меньше 6 ячеек."
            )

        # Обновляем только те данные, которые действительно есть.
        # Номер сборки уже находится в существующей строке.

        if build_info:
            append_fragment(
                cells[2],
                (
                    "<p>"
                    f"{macro_expand(EXPAND_MACRO_TITLE, build_info, 'ui-expand')}"
                    "</p>"
                ),
            )

        if ver_link:
            append_fragment(
                cells[3],
                (
                    "<p>"
                    f"{macro_expand(EXPAND_MACRO_TITLE, ver_link, 'ui-expand')}"
                    "</p>"
                ),
            )

        if task_and_bug_link:
            append_fragment(cells[4], task_and_bug_link)

        if comment_link:
            append_fragment(cells[5], comment_link)

        print(f"Сборка {hw_ver} уже существует. Обновляем запись.")
        break

    # -----------------------------------------------------------------------
    # Если сборка не найдена, создаём новую строку.
    # -----------------------------------------------------------------------

    if not find_flag:
        new_row = main_body.new_tag("tr")

        for content in row_data:
            new_cell = main_body.new_tag("td")

            append_cell_content(new_cell, content)

            new_row.append(new_cell)

        rows = main_body.find_all("tr")

        if len(rows) <= OFFSET_ROW:
            raise ValueError(
                "Не удалось найти строку таблицы, "
                "после которой следует вставить новую сборку."
            )

        rows[OFFSET_ROW].insert_after(new_row)

        print(f"Добавлена новая сборка {hw_ver} ({hwtarget}).")

    # Сохраняем страницу.
    confluence.update_page(
        page_id,
        main_title,
        str(main_body).replace("\n", ""),
        type="page",
        representation="storage",
        minor_edit=False,
        full_width=False,
    )

    print("Страница Confluence обновлена.")


if __name__ == "__main__":
    main()