From a1ae717c8fd5a2f9dcdca0bf2bfc69b6103b9250 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Thu, 1 Dec 2022 13:47:27 +0000 Subject: [PATCH] Add gitlab webhook support --- .gitignore | 2 + .vscode/settings.json | 7 + README.md | 6 +- ops_bot/gitlab/hook.py | 70 ++ ops_bot/gitlab/types.py | 930 ++++++++++++++++++ ops_bot/main.py | 43 +- ops_bot/util/contrast.py | 62 ++ ops_bot/util/markdown.py | 36 + ops_bot/util/template.py | 132 +++ poetry.lock | 126 ++- pyproject.toml | 4 + pytest.ini | 2 + templates/gitlab/macros.html | 75 ++ templates/gitlab/messages/comment.html | 20 + templates/gitlab/messages/issue_close.html | 2 + templates/gitlab/messages/issue_open.html | 6 + templates/gitlab/messages/issue_reopen.html | 2 + templates/gitlab/messages/issue_update.html | 77 ++ templates/gitlab/messages/job.html | 9 + templates/gitlab/messages/merge_request.html | 11 + templates/gitlab/messages/push.html | 27 + templates/gitlab/messages/tag.html | 13 + templates/gitlab/messages/test.html | 1 + templates/gitlab/messages/wiki.html | 5 + .../gitlab/mixins/repo_sender_prefix.html | 1 + tests/test_gitlab.py | 163 +++ 26 files changed, 1824 insertions(+), 8 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 ops_bot/gitlab/hook.py create mode 100644 ops_bot/gitlab/types.py create mode 100644 ops_bot/util/contrast.py create mode 100644 ops_bot/util/markdown.py create mode 100644 ops_bot/util/template.py create mode 100644 pytest.ini create mode 100644 templates/gitlab/macros.html create mode 100644 templates/gitlab/messages/comment.html create mode 100644 templates/gitlab/messages/issue_close.html create mode 100644 templates/gitlab/messages/issue_open.html create mode 100644 templates/gitlab/messages/issue_reopen.html create mode 100644 templates/gitlab/messages/issue_update.html create mode 100644 templates/gitlab/messages/job.html create mode 100644 templates/gitlab/messages/merge_request.html create mode 100644 templates/gitlab/messages/push.html create mode 100644 templates/gitlab/messages/tag.html create mode 100644 templates/gitlab/messages/test.html create mode 100644 templates/gitlab/messages/wiki.html create mode 100644 templates/gitlab/mixins/repo_sender_prefix.html create mode 100644 tests/test_gitlab.py diff --git a/.gitignore b/.gitignore index 6c0c76a..f52fb92 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ __pycache__ .venv devstate .env* +config.json +dev.data \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b38853 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index 179c16a..df2e014 100644 --- a/README.md +++ b/README.md @@ -90,4 +90,8 @@ GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -``` \ No newline at end of file +``` + + +This project uses pieces of [maubot/gitlab](https://github.com/maubot/gitlab), +which is also AGPL. These files have been noted with a comment header. diff --git a/ops_bot/gitlab/hook.py b/ops_bot/gitlab/hook.py new file mode 100644 index 0000000..d8723da --- /dev/null +++ b/ops_bot/gitlab/hook.py @@ -0,0 +1,70 @@ +import re +import attr +import logging +from typing import Any, Tuple +from jinja2 import TemplateNotFound + +from mautrix.types import (EventType, RoomID, StateEvent, Membership, MessageType, JSON, + TextMessageEventContent, Format, ReactionEventContent, RelationType) +from mautrix.util.formatter import parse_html + +from ..util.template import TemplateManager, TemplateUtil + +from .types import EventParse, OTHER_ENUMS, Action + +from ..common import COLOR_ALARM, COLOR_OK, COLOR_UNKNOWN + +spaces = re.compile(" +") +space = " " + + +messages = TemplateManager("gitlab", "messages") +templates = TemplateManager("gitlab", "mixins") + +async def parse_event(x_gitlab_event: str, payload: Any) -> Tuple[str, str]: + evt = EventParse[x_gitlab_event].deserialize(payload) + print("processing", evt) + try: + tpl = messages[evt.template_name] + except TemplateNotFound as e: + msg = f"Received unhandled gitlab event type {x_gitlab_event}" + logging.info(msg) + logging.info(payload) + return [(msg, msg)] + + aborted = False + + def abort() -> None: + nonlocal aborted + aborted = True + base_args = { + **{field.key: field for field in Action if field.key.isupper()}, + **OTHER_ENUMS, + "util": TemplateUtil, + } + + msgs = [] + for subevt in evt.preprocess(): + print("preprocessing", subevt) + args = { + **attr.asdict(subevt, recurse=False), + **{key: getattr(subevt, key) for key in subevt.event_properties}, + "abort": abort, + **base_args, + } + args["templates"] = templates.proxy(args) + + html = tpl.render(**args) + if not html or aborted: + aborted = False + continue + html = spaces.sub(space, html.strip()) + + content = TextMessageEventContent(msgtype=MessageType.TEXT, format=Format.HTML, + formatted_body=html, body=await parse_html(html)) + content["xyz.maubot.gitlab.webhook"] = { + "event_type": x_gitlab_event, + **subevt.meta, + } + msgs.append((content.body, content.formatted_body)) + return msgs \ No newline at end of file diff --git a/ops_bot/gitlab/types.py b/ops_bot/gitlab/types.py new file mode 100644 index 0000000..63653d1 --- /dev/null +++ b/ops_bot/gitlab/types.py @@ -0,0 +1,930 @@ +# gitlab - A GitLab client and webhook receiver for maubot +# Copyright (C) 2019 Lorenz Steinert +# Copyright (C) 2021 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import List, Union, Dict, Optional, Type, NewType, ClassVar, Tuple, Iterable +from datetime import datetime + +from jinja2 import TemplateNotFound +from attr import dataclass +from yarl import URL +import attr + +from mautrix.types import JSON, ExtensibleEnum, SerializableAttrs, serializer, deserializer + +from ..util.contrast import contrast, hex_to_rgb + + +@serializer(datetime) +def datetime_serializer(dt: datetime) -> JSON: + return dt.strftime('%Y-%m-%dT%H:%M:%S%z') + + +@deserializer(datetime) +def datetime_deserializer(data: JSON) -> datetime: + try: + return datetime.strptime(data, "%Y-%m-%dT%H:%M:%S.%f%z") + except ValueError: + pass + + try: + return datetime.strptime(data, "%Y-%m-%dT%H:%M:%S%z") + except ValueError: + pass + + try: + return datetime.strptime(data, "%Y-%m-%d %H:%M:%S %z") + except ValueError: + pass + + try: + return datetime.strptime(data, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + pass + + try: + return datetime.strptime(data, "%Y-%m-%d %H:%M:%S %Z") + except ValueError: + pass + + try: + return datetime.strptime(data, "%Y-%m-%d") + except ValueError: + pass + + raise ValueError(data) + + +class LabelType(ExtensibleEnum): + PROJECT = "ProjectLabel" + # TODO group? + + +@dataclass(frozen=True) +class GitlabLabel(SerializableAttrs): + contrast_threshold: ClassVar[float] = 2.5 + white_rgb: ClassVar[Tuple[int, int, int]] = (1, 1, 1) + white_hex: ClassVar[str] = "#ffffff" + black_hex: ClassVar[str] = "#000000" + + id: int + title: str + color: str + project_id: int + created_at: datetime + updated_at: datetime + template: bool + description: str + type: LabelType + group_id: Optional[int] + remove_on_close: bool = False + + @property + def foreground_color(self) -> str: + return (self.white_hex + if contrast(hex_to_rgb(self.color), self.white_rgb) >= self.contrast_threshold + else self.black_hex) + + +@dataclass +class GitlabProject(SerializableAttrs): + id: Optional[int] = None + name: Optional[str] = None + description: Optional[str] = None + web_url: Optional[str] = None + avatar_url: Optional[str] = None + git_ssh_url: Optional[str] = None + git_http_url: Optional[str] = None + namespace: Optional[str] = None + visibility_level: Optional[int] = None + path_with_namespace: Optional[str] = None + default_branch: Optional[str] = None + homepage: Optional[str] = None + url: Optional[str] = None + ssh_url: Optional[str] = None + http_url: Optional[str] = None + + @property + def gitlab_base_url(self) -> str: + return self.web_url.split(self.path_with_namespace)[0].rstrip("/") + + +@dataclass(eq=False, hash=False) +class GitlabUser(SerializableAttrs): + name: str + username: Optional[str] = None + avatar_url: Optional[str] = None + email: Optional[str] = None + id: Optional[int] = None + web_url: Optional[str] = None + + def __hash__(self) -> int: + return self.id + + def __eq__(self, other: 'GitlabUser') -> bool: + if not isinstance(other, GitlabUser): + return False + return self.id == other.id + + +@dataclass +class GitlabAuthor(SerializableAttrs): + name: str + email: str + + +@dataclass +class BaseCommit: + message: str + + @property + def cut_message(self) -> str: + max_len = 72 + message = self.message.strip() + if "\n" in message: + message = message.split("\n")[0] + if len(message) <= max_len: + message += " […]" + return message + if len(message) > max_len: + message = message[:max_len] + "…" + return message + + +@dataclass +class GitlabCommit(BaseCommit, SerializableAttrs): + id: str + timestamp: Optional[datetime] = None + url: Optional[str] = None + author: Optional[GitlabAuthor] = None + + added: Optional[List[str]] = None + modified: Optional[List[str]] = None + removed: Optional[List[str]] = None + + +@dataclass +class GitlabRepository(SerializableAttrs): + name: str + url: Optional[str] = None + description: Optional[str] = None + homepage: Optional[str] = None + git_http_url: Optional[str] = None + git_ssh_url: Optional[str] = None + visibility_level: Optional[int] = None + + @property + def path(self) -> str: + return URL(self.homepage).path.strip("/") + + +@dataclass +class GitlabStDiff(SerializableAttrs): + diff: str + new_path: str + old_path: str + a_mode: str + b_mode: str + new_file: bool + renamed_file: bool + deleted_file: bool + + +@dataclass +class GitlabSource(SerializableAttrs): + name: str + namespace: str + + description: Optional[str] = None + web_url: Optional[str] = None + avatar_url: Optional[str] = None + git_ssh_url: Optional[str] = None + git_http_url: Optional[str] = None + visibility_level: Optional[int] = None + path_with_namespace: Optional[str] = None + default_branch: Optional[str] = None + homepage: Optional[str] = None + url: Optional[str] = None + ssh_url: Optional[str] = None + http_url: Optional[str] = None + + +GitlabTarget = NewType('GitlabTarget', GitlabSource) + + +class GitlabChangeWrapper: + previous: list + current: list + + @property + def added(self) -> list: + previous_set = set(self.previous) + return [item for item in self.current if item not in previous_set] + + @property + def removed(self) -> list: + current_set = set(self.current) + return [item for item in self.previous if item not in current_set] + + +@dataclass +class GitlabDatetimeChange(SerializableAttrs): + previous: Optional[datetime] + current: datetime + + +@dataclass +class GitlabAssigneeChanges(GitlabChangeWrapper, SerializableAttrs): + previous: List[GitlabUser] + current: List[GitlabUser] + + +@dataclass +class GitlabLabelChanges(GitlabChangeWrapper, SerializableAttrs): + previous: List[GitlabLabel] + current: List[GitlabLabel] + + +@dataclass +class GitlabIntChange(SerializableAttrs): + previous: Optional[int] + current: Optional[int] + + +@dataclass +class GitlabBoolChange(SerializableAttrs): + previous: Optional[bool] + current: Optional[bool] + + +@dataclass +class GitlabStringChange(SerializableAttrs): + previous: Optional[str] + current: Optional[str] + + +@dataclass +class GitlabChanges(SerializableAttrs): + created_at: Optional[GitlabDatetimeChange] = None + updated_at: Optional[GitlabDatetimeChange] = None + updated_by: Optional[GitlabIntChange] = None + author_id: Optional[GitlabIntChange] = None + id: Optional[GitlabIntChange] = None + iid: Optional[GitlabIntChange] = None + project_id: Optional[GitlabIntChange] = None + milestone_id: Optional[GitlabIntChange] = None + description: Optional[GitlabStringChange] = None + title: Optional[GitlabStringChange] = None + labels: Optional[GitlabLabelChanges] = None + assignees: Optional[GitlabAssigneeChanges] = None + time_estimate: Optional[GitlabIntChange] = None + total_time_spent: Optional[GitlabIntChange] = None + weight: Optional[GitlabIntChange] = None + due_date: Optional[GitlabDatetimeChange] = None + confidential: Optional[GitlabBoolChange] = None + discussion_locked: Optional[GitlabBoolChange] = None + + +@dataclass +class GitlabIssue(SerializableAttrs): + id: int + issue_id: int = attr.ib(metadata={"json": "iid"}) + title: str + description: str + author_id: int + project_id: int + created_at: datetime + updated_at: datetime + assignee_id: Optional[int] = None + assignee_ids: Optional[List[int]] = None + relative_position: Optional[int] = None + branch_name: Optional[str] = None + milestone_id: Optional[int] = None + state: Optional[str] = None + + +@dataclass +class GitlabSnippet(SerializableAttrs): + id: int + title: str + content: str + author_id: int + project_id: int + created_at: datetime + updated_at: datetime + file_name: str + expires_at: datetime + type: str + visibility_level: int + + +class Action(ExtensibleEnum): + OPEN = "open" + REOPEN = "reopen" + CLOSE = "close" + UPDATE = "update" + CREATE = "create" + DELETE = "delete" + + @property + def past_tense(self) -> str: + action = self.value + if not action: + return action + elif action[-2:] != "ed": + if action[-1] == "e": + return f"{action}d" + return f"{action}ed" + return action + + +class NoteableType(ExtensibleEnum): + ISSUE = "Issue" + MERGE_REQUEST = "MergeRequest" + + +class CommentType(ExtensibleEnum): + DISCUSSION_NOTE = "DiscussionNote" + + +@dataclass +class GitlabIssueAttributes(SerializableAttrs): + id: int + project_id: int + issue_id: int = attr.ib(metadata={"json": "iid"}) + state: str + url: str + author_id: int + title: str + description: str + + created_at: datetime + updated_at: datetime + updated_by_id: Optional[int] = None + due_date: Optional[datetime] = None + closed_at: Optional[datetime] = None + + time_estimate: Optional[int] = None + total_time_spent: Optional[int] = None + human_time_estimate: Optional[str] = None + human_total_time_spent: Optional[str] = None + + action: Optional[Action] = None + assignee_id: Optional[int] = None + assignee_ids: Optional[List[int]] = None + branch_name: Optional[str] = None + confidential: bool = False + duplicated_to_id: Optional[int] = None + moved_to_id: Optional[int] = None + state_id: Optional[int] = None + milestone_id: Optional[int] = None + labels: Optional[List[GitlabLabel]] = None + position: Optional[int] = None + original_position: Optional[int] = None + + +@dataclass +class GitlabCommentAttributes(SerializableAttrs): + id: int + note: str + noteable_type: NoteableType + project_id: int + url: str + author_id: int + + created_at: datetime + updated_at: datetime + updated_by_id: Optional[int] = None + resolved_at: Optional[datetime] = None + resolved_by_id: Optional[int] = None + + commit_id: Optional[str] = None + noteable_id: Optional[int] = None + discussion_id: Optional[str] = None + type: Optional[CommentType] = None + + system: Optional[bool] = None + line_code: Optional[str] = None + st_diff: Optional[GitlabStDiff] = None + attachment: Optional[str] = None + position: Optional[int] = None + original_position: Optional[int] = None + + +@dataclass +class GitlabMergeRequestAttributes(SerializableAttrs): + id: int + merge_request_id: int = attr.ib(metadata={"json": "iid"}) + target_branch: str + source_branch: str + source: GitlabProject + source_project_id: int + target: GitlabProject + target_project_id: int + last_commit: GitlabCommit + author_id: int + title: str + description: str + work_in_progress: bool + url: str + state: str + + created_at: datetime + updated_at: datetime + updated_by_id: Optional[int] = None + last_edited_at: Optional[datetime] = None + last_edited_by_id: Optional[int] = None + + merge_commit_sha: Optional[str] = None + merge_error: Optional[str] = None + merge_status: Optional[str] = None + merge_user_id: Optional[int] = None + merge_when_pipeline_succeeds: Optional[bool] = False + + time_estimate: Optional[int] = None + total_time_spent: Optional[int] = None + human_time_estimate: Optional[str] = None + human_total_time_spent: Optional[str] = None + + head_pipeline_id: Optional[int] = None + milestone_id: Optional[int] = None + assignee_id: Optional[int] = None + assignee_ids: Optional[List[int]] = None + assignee: Optional[GitlabUser] = None + action: Optional[Action] = None + + +@dataclass +class GitlabWikiPageAttributes(SerializableAttrs): + title: str + content: str + format: str + slug: str + url: str + action: Action + message: Optional[str] = None + + +@dataclass +class GitlabVariable(SerializableAttrs): + key: str + value: str + + +@dataclass +class GitlabPipelineAttributes(SerializableAttrs): + id: int + ref: str + tag: bool + sha: str + before_sha: str + source: str + status: str + stages: List[str] + + created_at: datetime + finished_at: datetime + duration: int + variables: List[GitlabVariable] + + +@dataclass +class GitlabArtifact(SerializableAttrs): + filename: str + size: int + + +@dataclass +class GitlabWiki(SerializableAttrs): + web_url: str + git_ssh_url: str + git_http_url: str + path_with_namespace: str + default_branch: str + + +@dataclass +class GitlabMergeRequest(SerializableAttrs): + id: int + merge_request_id: int = attr.ib(metadata={"json": "iid"}) + target_branch: str + source_branch: str + source_project_id: int + assignee_id: int + author_id: int + title: str + created_at: datetime + updated_at: datetime + milestone_id: int + state: str + merge_status: str + target_project_id: int + description: str + source: GitlabSource + target: GitlabTarget + last_commit: GitlabCommit + work_in_progress: bool + position: Optional[int] = None + locked_at: Optional[datetime] = None + assignee: Optional[GitlabUser] = None + + +class BuildStatus(ExtensibleEnum): + CREATED = "created" + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + CANCELED = "canceled" + + @property + def color_circle(self) -> str: + return _build_status_circles[self] + + +_build_status_circles: Dict[BuildStatus, str] = { + BuildStatus.CREATED: "🟡", + BuildStatus.RUNNING: "🔵", + BuildStatus.SUCCESS: "🟢", + BuildStatus.FAILED: "🔴", + BuildStatus.CANCELED: "⚫️", +} + + +class FailureReason(ExtensibleEnum): + UNKNOWN = "unknown_failure" + SCRIPT = "script_failure" + + +@dataclass +class GitlabJobCommit(BaseCommit, SerializableAttrs): + author_email: str + author_name: str + author_url: Optional[str] + + id: int + sha: str + status: BuildStatus + started_at: Optional[datetime] + finished_at: Optional[datetime] + duration: Optional[int] + + +@dataclass +class GitlabBuild(SerializableAttrs): + id: int + stage: str + name: str + status: BuildStatus + created_at: datetime + started_at: datetime + finished_at: datetime + when: str + manual: bool + user: GitlabUser + runner: str + artifacts_file: GitlabArtifact + + +@dataclass +class GitlabEvent: + def preprocess(self) -> List['GitlabEvent']: + return [self] + + @property + def template_name(self) -> str: + raise TemplateNotFound("") + + @property + def meta(self) -> JSON: + return {} + + @property + def event_properties(self) -> Iterable[str]: + return [] + + @property + def message_id(self) -> Optional[str]: + return None + + +@dataclass +class GitlabPushEvent(SerializableAttrs, GitlabEvent): + object_kind: str + before: str + after: str + ref: str + checkout_sha: str + message: Optional[str] + user_id: int + user_name: str + user_username: str + user_email: str + user_avatar: str + project_id: int + project: GitlabProject + repository: GitlabRepository + commits: List[GitlabCommit] + total_commits_count: int + + @property + def user(self) -> GitlabUser: + return GitlabUser(id=self.user_id, name=self.user_name, email=self.user_email, + username=self.user_username, avatar_url=self.user_avatar, + web_url=f"{self.project.gitlab_base_url}/{self.user_username}") + + @property + def template_name(self) -> str: + return "tag" if self.ref_type == "tag" else "push" + + @property + def event_properties(self) -> Iterable[str]: + return ("user", "is_new_ref", "is_deleted_ref", "ref_name", "ref_type", "ref_url", + "diff_url") + + @property + def diff_url(self) -> str: + before = self.project.default_branch if self.is_new_ref else self.before + after = self.after + return f"{self.project.web_url}/-/compare/{before}...{after}" + + @property + def is_new_ref(self) -> bool: + return self.before == "0" * len(self.before) + + @property + def is_deleted_ref(self) -> bool: + return self.after == "0" * len(self.after) + + @property + def ref_type(self) -> str: + if self.ref.startswith("refs/heads/"): + return "branch" + elif self.ref.startswith("refs/tags/"): + return "tag" + else: + return "ref" + + @property + def ref_name(self) -> str: + return self.ref.split("/", 2)[2] + + @property + def ref_url(self) -> Optional[str]: + if self.ref.startswith("refs/heads/"): + return f"{self.project.web_url}/-/tree/{self.ref_name}" + elif self.ref.startswith("refs/tags/"): + return f"{self.project.web_url}/-/tags/{self.ref_name}" + else: + return None + + @property + def message_id(self) -> str: + return f"push-{self.project_id}-{self.checkout_sha}-{self.ref_name}" + + +def split_updates(evt: Union['GitlabIssueEvent', 'GitlabMergeRequestEvent']) -> List[GitlabEvent]: + if not evt.changes: + return [evt] + output = [] + # We don't want to handle multiple issue change types in a single Matrix message, + # so split each change into a separate event. + for field in attr.fields(GitlabChanges): + value = getattr(evt.changes, field.name) + if value: + output.append(attr.evolve(evt, changes=GitlabChanges(**{field.name: value}))) + return output + + +@dataclass +class GitlabIssueEvent(SerializableAttrs, GitlabEvent): + object_kind: str + user: GitlabUser + project: GitlabProject + repository: GitlabRepository + object_attributes: GitlabIssueAttributes + assignees: Optional[List[GitlabUser]] = None + labels: Optional[List[GitlabLabel]] = None + changes: Optional[GitlabChanges] = None + + def preprocess(self) -> List['GitlabIssueEvent']: + users_to_mutate = [self.user] + if self.changes and self.changes.assignees: + users_to_mutate += self.changes.assignees.previous + users_to_mutate += self.changes.assignees.current + if self.assignees: + users_to_mutate += self.assignees + for user in users_to_mutate: + user.web_url = f"{self.project.gitlab_base_url}/{user.username}" + + return split_updates(self) if self.action == Action.UPDATE else [self] + + @property + def template_name(self) -> str: + return f"issue_{self.action.key.lower()}" + + @property + def event_properties(self) -> Iterable[str]: + return "action", + + @property + def action(self) -> Action: + return self.object_attributes.action + + +@dataclass +class GitlabCommentEvent(SerializableAttrs, GitlabEvent): + object_kind: str + user: GitlabUser + project_id: int + project: GitlabProject + repository: GitlabRepository + object_attributes: GitlabCommentAttributes + merge_request: Optional[GitlabMergeRequest] = None + commit: Optional[GitlabCommit] = None + issue: Optional[GitlabIssue] = None + snippet: Optional[GitlabSnippet] = None + + def preprocess(self) -> List['GitlabCommentEvent']: + self.user.web_url = f"{self.project.gitlab_base_url}/{self.user.username}" + return [self] + + @property + def template_name(self) -> str: + return "comment" + + +@dataclass +class GitlabMergeRequestEvent(SerializableAttrs, GitlabEvent): + object_kind: str + user: GitlabUser + project: GitlabProject + repository: GitlabRepository + object_attributes: GitlabMergeRequestAttributes + labels: List[GitlabLabel] + changes: GitlabChanges + + def preprocess(self) -> List['GitlabMergeRequestEvent']: + users_to_mutate = [self.user] + if self.changes and self.changes.assignees: + users_to_mutate += self.changes.assignees.previous + users_to_mutate += self.changes.assignees.current + for user in users_to_mutate: + user.web_url = f"{self.project.gitlab_base_url}/{user.username}" + + return split_updates(self) if self.action == Action.UPDATE else [self] + + @property + def template_name(self) -> str: + return "issue_update" if self.action == Action.UPDATE else "merge_request" + + @property + def event_properties(self) -> Iterable[str]: + return "action", + + @property + def action(self) -> Action: + return self.object_attributes.action + + +@dataclass +class GitlabWikiPageEvent(SerializableAttrs, GitlabEvent): + object_kind: str + user: GitlabUser + project: GitlabProject + wiki: GitlabWiki + object_attributes: GitlabWikiPageAttributes + + def preprocess(self) -> List['GitlabWikiPageEvent']: + self.user.web_url = f"{self.project.gitlab_base_url}/{self.user.username}" + return [self] + + @property + def template_name(self) -> str: + return "wiki" + + +@dataclass +class GitlabPipelineEvent(SerializableAttrs, GitlabEvent): + object_kind: str + object_attributes: GitlabPipelineAttributes + user: GitlabUser + project: GitlabProject + commit: GitlabCommit + builds: List[GitlabBuild] + + @property + def message_id(self) -> str: + return f"pipeline-{self.object_attributes.id}" + + +@dataclass +class GitlabRunner(SerializableAttrs): + active: bool + description: str + id: int + tags: List[str] + + +@dataclass +class GitlabJobEvent(SerializableAttrs, GitlabEvent): + object_kind: str + ref: str + tag: str + before_sha: str + sha: str + pipeline_id: int + build_id: int + build_name: str + build_stage: str + build_status: BuildStatus + build_started_at: datetime + build_finished_at: datetime + build_duration: int + build_allow_failure: bool + build_failure_reason: FailureReason + project_id: int + project_name: str + user: GitlabUser + commit: GitlabJobCommit + repository: GitlabRepository + runner: Optional[GitlabRunner] + + def preprocess(self) -> List['GitlabJobEvent']: + base_url = str(URL(self.repository.homepage).with_path("")) + self.user.web_url = f"{base_url}/{self.user.username}" + return [self] + + @property + def template_name(self) -> str: + return "job" + + @property + def push_id(self) -> str: + return f"push-{self.project_id}-{self.sha}-{self.ref}" + + @property + def reaction_id(self) -> str: + return f"job-{self.project_id}-{self.sha}-{self.ref}-{self.build_name}" + + @property + def meta(self) -> JSON: + return { + "build": { + "pipeline_id": self.pipeline_id, + "id": self.build_id, + "name": self.build_name, + "stage": self.build_stage, + "status": self.build_status.value, + "url": self.build_url, + }, + } + + @property + def event_properties(self) -> Iterable[str]: + return "build_url", + + @property + def build_url(self) -> str: + return f"{self.repository.homepage}/-/jobs/{self.build_id}" + + +GitlabEventType = Union[Type[GitlabPushEvent], + Type[GitlabIssueEvent], + Type[GitlabCommentEvent], + Type[GitlabMergeRequestEvent], + Type[GitlabWikiPageEvent], + Type[GitlabPipelineEvent], + Type[GitlabJobEvent]] + +EventParse: Dict[str, GitlabEventType] = { + "Push Hook": GitlabPushEvent, + "Tag Push Hook": GitlabPushEvent, + "Issue Hook": GitlabIssueEvent, + "Confidential Issue Hook": GitlabIssueEvent, + "Note Hook": GitlabCommentEvent, + "Confidential Note Hook": GitlabCommentEvent, + "Merge Request Hook": GitlabMergeRequestEvent, + "Wiki Page Hook": GitlabWikiPageEvent, + "Pipeline Hook": GitlabPipelineEvent, + "Job Hook": GitlabJobEvent +} + +OTHER_ENUMS = { + "NoteableType": NoteableType, + "CommentType": CommentType, + "BuildStatus": BuildStatus, + "FailureReason": FailureReason, +} diff --git a/ops_bot/main.py b/ops_bot/main.py index c3999b0..ee5abc6 100644 --- a/ops_bot/main.py +++ b/ops_bot/main.py @@ -1,21 +1,26 @@ import asyncio import json import logging +from pathlib import Path from typing import Any, Dict, Literal, Optional, Tuple, cast - +from dotenv import load_dotenv import uvicorn -from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi import Depends, FastAPI, HTTPException, Request, status, Header from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import pydantic from pydantic import BaseSettings from ops_bot import aws, pagerduty from ops_bot.matrix import MatrixClient, MatrixClientSettings +from ops_bot.gitlab import hook as gitlab_hook +load_dotenv() class BotSettings(BaseSettings): bearer_token: str routing_keys: Dict[str, str] log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + matrix: MatrixClientSettings class Config: env_prefix = "BOT_" @@ -40,11 +45,14 @@ async def matrix_main(matrix_client: MatrixClient) -> None: @app.on_event("startup") async def startup_event() -> None: - bot_settings = BotSettings(_env_file=".env", _env_file_encoding="utf-8") + # if "config.json" exists read it + if Path("config.json").exists(): + bot_settings = BotSettings.parse_file("config.json") + else: + bot_settings = BotSettings(_env_file=".env", _env_file_encoding="utf-8") logging.getLogger().setLevel(bot_settings.log_level) - matrix_settings = MatrixClientSettings(_env_file=".env", _env_file_encoding="utf-8") - matrix_settings.join_rooms = list(bot_settings.routing_keys.values()) - c = MatrixClient(settings=matrix_settings) + bot_settings.matrix.join_rooms = list(bot_settings.routing_keys.values()) + c = MatrixClient(settings=bot_settings.matrix) app.state.matrix_client = c app.state.bot_settings = bot_settings asyncio.create_task(matrix_main(c)) @@ -120,6 +128,29 @@ async def aws_sns_hook( ) return {"message": msg_plain, "message_formatted": msg_formatted} +@app.post("/hook/gitlab/{routing_key}") +async def gitlab_webhook( + request: Request, + x_gitlab_token: str = Header(default=""), + x_gitlab_event: str = Header(default=""), + matrix_client: MatrixClient = Depends(get_matrix_service) +) -> Dict[str, str]: + bearer_token = request.app.state.bot_settings.bearer_token + if x_gitlab_token != bearer_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect X-Gitlab-Token" + ) + room_id, payload = await receive_helper(request) + messages = await gitlab_hook.parse_event(x_gitlab_event, payload) + for msg_plain, msg_formatted in messages: + await matrix_client.room_send( + room_id, + msg_plain, + message_formatted=msg_formatted, + ) + return {"status": "ok"} + def start_dev() -> None: uvicorn.run("ops_bot.main:app", port=1111, host="127.0.0.1", reload=True) diff --git a/ops_bot/util/contrast.py b/ops_bot/util/contrast.py new file mode 100644 index 0000000..354c155 --- /dev/null +++ b/ops_bot/util/contrast.py @@ -0,0 +1,62 @@ +# Based on https://github.com/gsnedders/wcag-contrast-ratio +# Copyright (c) 2015 Geoffrey Sneddon +# Copyright (c) 2019 Tulir Asokan +# MIT license +from typing import Tuple + +RGB = Tuple[float, float, float] + + +def hex_to_rgb(color: str) -> RGB: + color = color.lstrip("#") + if len(color) != 3 and len(color) != 6: + raise ValueError("Invalid hex length") + step = 1 if len(color) == 3 else 2 + try: + r = int(color[0:step], 16) + g = int(color[step:2 * step], 16) + b = int(color[2 * step:3 * step], 16) + except ValueError as e: + raise ValueError("Invalid hex value") from e + return r / 255, g / 255, b / 255 + + +def rgb_to_hex(rgb: RGB) -> str: + r, g, b = rgb + r = int(r * 255) + g = int(g * 255) + b = int(b * 255) + return f"{r:02x}{g:02x}{b:02x}" + + +def contrast(rgb1: RGB, rgb2: RGB) -> float: + for r, g, b in (rgb1, rgb2): + if not 0.0 <= r <= 1.0: + raise ValueError(f"r {r} is out of valid range (0.0 - 1.0)") + if not 0.0 <= g <= 1.0: + raise ValueError(f"g {g} is out of valid range (0.0 - 1.0)") + if not 0.0 <= b <= 1.0: + raise ValueError(f"b {b} is out of valid range (0.0 - 1.0)") + + l1 = _relative_luminance(*rgb1) + l2 = _relative_luminance(*rgb2) + + if l1 > l2: + return (l1 + 0.05) / (l2 + 0.05) + else: + return (l2 + 0.05) / (l1 + 0.05) + + +def _relative_luminance(r: float, g: float, b: float) -> float: + r = _linearize(r) + g = _linearize(g) + b = _linearize(b) + + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def _linearize(v: float) -> float: + if v <= 0.03928: + return v / 12.92 + else: + return ((v + 0.055) / 1.055) ** 2.4 diff --git a/ops_bot/util/markdown.py b/ops_bot/util/markdown.py new file mode 100644 index 0000000..1f24ab4 --- /dev/null +++ b/ops_bot/util/markdown.py @@ -0,0 +1,36 @@ +# Copyright (c) 2022 Tulir Asokan +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +import commonmark + + +class HtmlEscapingRenderer(commonmark.HtmlRenderer): + def __init__(self, allow_html: bool = False): + super().__init__() + self.allow_html = allow_html + + def lit(self, s): + if self.allow_html: + return super().lit(s) + return super().lit(s.replace("<", "<").replace(">", ">")) + + def image(self, node, entering): + prev = self.allow_html + self.allow_html = True + super().image(node, entering) + self.allow_html = prev + + +md_parser = commonmark.Parser() +yes_html_renderer = commonmark.HtmlRenderer() +no_html_renderer = HtmlEscapingRenderer() + + +def render(message: str, allow_html: bool = False) -> str: + parsed = md_parser.parse(message) + if allow_html: + return yes_html_renderer.render(parsed) + else: + return no_html_renderer.render(parsed) diff --git a/ops_bot/util/template.py b/ops_bot/util/template.py new file mode 100644 index 0000000..306a725 --- /dev/null +++ b/ops_bot/util/template.py @@ -0,0 +1,132 @@ +# gitlab - A GitLab client and webhook receiver for maubot +# Copyright (C) 2021 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Dict, Any, Tuple, Callable, Iterable, List, Union +import os.path + +from jinja2 import Environment as JinjaEnvironment, Template, BaseLoader, TemplateNotFound, FileSystemLoader + +from ops_bot.util import markdown + +def sync_read_file(path: str) -> str: + with open(path) as file: + return file.read() + + +def sync_list_files(directory: str) -> list[str]: + return os.listdir(directory) + +class TemplateUtil: + @staticmethod + def bold_scope(label: str) -> str: + try: + scope, label = label.rsplit("::", 1) + return f"{scope}::{label}" + except ValueError: + return label + + @staticmethod + def pluralize(val: int, unit: str) -> str: + if val == 1: + return f"{val} {unit}" + return f"{val} {unit}s" + + @classmethod + def format_time(cls, seconds: Union[int, float], enable_days: bool = False) -> str: + seconds = abs(seconds) + frac_seconds = round(seconds - int(seconds), 1) + minutes, seconds = divmod(int(seconds), 60) + hours, minutes = divmod(minutes, 60) + if enable_days: + days, hours = divmod(hours, 24) + else: + days = 0 + parts = [] + if days > 0: + parts.append(cls.pluralize(days, "day")) + if hours > 0: + parts.append(cls.pluralize(hours, "hour")) + if minutes > 0: + parts.append(cls.pluralize(minutes, "minute")) + if seconds > 0 or len(parts) == 0: + parts.append(cls.pluralize(seconds + frac_seconds, "second")) + + if len(parts) == 1: + return parts[0] + return ", ".join(parts[:-1]) + f" and {parts[-1]}" + + @staticmethod + def join_human_list(data: List[str], *, joiner: str = ", ", final_joiner: str = " and ", + mutate: Callable[[str], str] = lambda val: val) -> str: + if not data: + return "" + elif len(data) == 1: + return mutate(data[0]) + return joiner.join(mutate(val) for val in data[:-1]) + final_joiner + mutate(data[-1]) + + +class TemplateProxy: + _env: JinjaEnvironment + _args: Dict[str, Any] + + def __init__(self, env: JinjaEnvironment, args: Dict[str, Any]) -> None: + self._env = env + self._args = args + + def __getattr__(self, item: str) -> str: + try: + tpl = self._env.get_template(item) + except TemplateNotFound: + raise AttributeError(item) + return tpl.render(**self._args) + + +class PluginTemplateLoader(BaseLoader): + directory: str + macros: str + + def __init__(self, base: str, directory: str) -> None: + self.directory = os.path.join("templates", base, directory) + self.macros = sync_read_file(os.path.join("templates", base, "macros.html")) + + def get_source(self, environment: Any, name: str) -> Tuple[str, str, Callable[[], bool]]: + path = f"{os.path.join(self.directory, name)}.html" + try: + tpl = sync_read_file(path) + except KeyError: + raise TemplateNotFound(name) + return self.macros + tpl, name, lambda: True + + def list_templates(self) -> Iterable[str]: + return [os.path.splitext(os.path.basename(path))[0] + for path in sync_list_files(self.directory) + if path.endswith(".html")] + +class TemplateManager: + _env: JinjaEnvironment + _loader: PluginTemplateLoader + + def __init__(self, base: str, directory: str) -> None: + #self._loader = FileSystemLoader(os.path.join("templates/", base)) + self._loader = PluginTemplateLoader(base, directory) + self._env = JinjaEnvironment(loader=self._loader, lstrip_blocks=True, trim_blocks=True, + extensions=["jinja2.ext.do"]) + self._env.filters["markdown"] = lambda message: markdown.render(message, allow_html=True) + + def __getitem__(self, item: str) -> Template: + return self._env.get_template(item) + + def proxy(self, args: Dict[str, Any]) -> TemplateProxy: + return TemplateProxy(self._env, args) diff --git a/poetry.lock b/poetry.lock index f30d670..67ac548 100644 --- a/poetry.lock +++ b/poetry.lock @@ -187,6 +187,17 @@ category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +[[package]] +name = "commonmark" +version = "0.9.1" +description = "Python parser for the CommonMark Markdown spec" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] + [[package]] name = "exceptiongroup" version = "1.0.4" @@ -365,6 +376,20 @@ requirements_deprecated_finder = ["pipreqs", "pip-api"] colors = ["colorama (>=0.4.3,<0.5.0)"] plugins = ["setuptools"] +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "jsonschema" version = "3.2.0" @@ -415,6 +440,14 @@ importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} [package.extras] testing = ["coverage", "pyyaml"] +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "matrix-nio" version = "0.19.0" @@ -442,6 +475,25 @@ unpaddedbase64 = ">=2.1.0,<3.0.0" [package.extras] e2e = ["python-olm (>=3.1.3,<4.0.0)", "peewee (>=3.14.4,<4.0.0)", "cachetools (>=4.2.1,<5.0.0)", "atomicwrites (>=1.4.0,<2.0.0)"] +[[package]] +name = "mautrix" +version = "0.18.8" +description = "A Python 3 asyncio Matrix framework." +category = "main" +optional = false +python-versions = "~=3.8" + +[package.dependencies] +aiohttp = ">=3,<4" +attrs = ">=18.1.0" +yarl = ">=1.5,<2" + +[package.extras] +detect_mimetype = ["python-magic (>=0.4.15,<0.5)"] +encryption = ["pycryptodome", "python-olm", "unpaddedbase64"] +lint = ["black (==22.1.0)", "isort"] +test = ["aiosqlite", "asyncpg", "pycryptodome", "pytest", "pytest-asyncio", "python-olm", "sqlalchemy", "unpaddedbase64"] + [[package]] name = "mccabe" version = "0.7.0" @@ -631,6 +683,20 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.20.2" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=6.1.0" + +[package.extras] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + [[package]] name = "python-dotenv" version = "0.21.0" @@ -819,7 +885,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-co [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "8c9dc7f1cfae257da386d35620ae44f55488c1a977c8d7dad2f2cf085d04cd65" +content-hash = "50bb2a7ce02730b129e8bcee3ffad0e1cc7c028ebaff2f9e3d07643907db4f16" [metadata.files] aiofiles = [ @@ -1047,6 +1113,10 @@ colorama = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +commonmark = [ + {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, + {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, +] exceptiongroup = [ {file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"}, {file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"}, @@ -1182,6 +1252,10 @@ isort = [ {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] +jinja2 = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] jsonschema = [ {file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"}, {file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"}, @@ -1201,10 +1275,56 @@ markdown = [ {file = "Markdown-3.4.1-py3-none-any.whl", hash = "sha256:08fb8465cffd03d10b9dd34a5c3fea908e20391a2a90b88d66362cb05beed186"}, {file = "Markdown-3.4.1.tar.gz", hash = "sha256:3b809086bb6efad416156e00a0da66fe47618a5d6918dd688f53f40c8e4cfeff"}, ] +markupsafe = [ + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, +] matrix-nio = [ {file = "matrix-nio-0.19.0.tar.gz", hash = "sha256:fb413e2db457380f6798c90f3caac4730a5e0b0d4ab1b8f4fc4af2e35ac9356c"}, {file = "matrix_nio-0.19.0-py3-none-any.whl", hash = "sha256:e5f43bc1a343f87982b597a5f6aa712468b619363f543a201e5a8c847e518c01"}, ] +mautrix = [ + {file = "mautrix-0.18.8-py3-none-any.whl", hash = "sha256:288d421fc29303c0fbc59827494fe32fedc493ef952aa8d9478983e2274f5831"}, + {file = "mautrix-0.18.8.tar.gz", hash = "sha256:0d1261a87a5e19b0f3aa1ca6eb3f700a6cd3cf75699f024c7432854f9bcc5541"}, +] mccabe = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -1439,6 +1559,10 @@ pytest = [ {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, ] +pytest-asyncio = [ + {file = "pytest-asyncio-0.20.2.tar.gz", hash = "sha256:32a87a9836298a881c0ec637ebcc952cfe23a56436bdc0d09d1511941dd8a812"}, + {file = "pytest_asyncio-0.20.2-py3-none-any.whl", hash = "sha256:07e0abf9e6e6b95894a39f688a4a875d63c2128f76c02d03d16ccbc35bcc0f8a"}, +] python-dotenv = [ {file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"}, {file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"}, diff --git a/pyproject.toml b/pyproject.toml index 2051361..d2dff3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ uvicorn = "^0.18.2" termcolor = "^1.1.0" Markdown = "^3.4.1" pydantic = {extras = ["dotenv"], version = "^1.9.1"} +commonmark = "^0.9.1" +Jinja2 = "^3.1.2" +mautrix = "^0.18.8" [tool.poetry.dev-dependencies] pytest = "^7.2.0" @@ -23,6 +26,7 @@ flake8 = "^6.0.0" flake8-black = "^0.3.5" types-Markdown = "^3.4.0" types-termcolor = "^1.1.5" +pytest-asyncio = "^0.20.2" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/templates/gitlab/macros.html b/templates/gitlab/macros.html new file mode 100644 index 0000000..234f7c0 --- /dev/null +++ b/templates/gitlab/macros.html @@ -0,0 +1,75 @@ +{%- macro repo_link(project) -%} + {{ project.path_with_namespace|e }} +{%- endmacro -%} +{%- macro user_link(user) -%} + {{ user.username|e }} +{%- endmacro -%} + +{%- macro pluralize(value) -%} + {% if value != 1 %}s{% endif %} +{%- endmacro -%} + +{%- macro issue_link(issue, title = true, important = true) -%} + + {%- if issue.confidential and (not changes or not changes.confidential) -%}confidential {% endif -%} + issue #{{ issue.issue_id -}} + + {%- if title -%} + : {{ issue.title|e -}} + {% endif %} +{%- endmacro -%} + +{%- macro merge_request_link(merge_request, title = true, important = true) -%} + + merge request !{{ merge_request.merge_request_id -}} + + {%- if title -%} + : {{ merge_request.title|e -}} + {% endif %} +{%- endmacro -%} + +{%- macro issue_or_merge_link(attrs, title = true, important = true) -%} + {%- if attrs.issue_id -%} + {{- issue_link(attrs, title, important) -}} + {%- elif attrs.merge_request_id -%} + {{- merge_request_link(attrs, title, important) -}} + {%- else -%} + unknown object {{ attrs.title|e }} + {%- endif -%} +{%- endmacro -%} + +{%- macro fancy_label(label) -%} +  {{ util.bold_scope(label.title) }}  +{%- endmacro -%} + +{%- macro fancy_labels(labels) -%} + {% for label in labels %} + {{ fancy_label(label) }} + {% endfor %} +{%- endmacro -%} + +{%- macro list_changes(added, removed, add_word, remove_word, mutate) -%} + {%- if added -%} + {{ add_word }} {{ util.join_human_list(added, mutate=mutate) }} + {% if not removed %} + to + {% endif %} + {%- endif -%} + {%- if removed -%} + {%- if added %} + and + {% endif -%} + {{ remove_word }} {{ util.join_human_list(removed, mutate=mutate) }} + from + {%- endif -%} +{%- endmacro -%} + +{%- macro label_changes(added, removed) -%} + {{ list_changes(added, removed, "added", "removed", fancy_label) }} +{%- endmacro -%} +{%- macro assignee_changes(added, removed) -%} + {{ list_changes(added, removed, "assigned", "unassigned", user_link) }} +{%- endmacro -%} diff --git a/templates/gitlab/messages/comment.html b/templates/gitlab/messages/comment.html new file mode 100644 index 0000000..be88092 --- /dev/null +++ b/templates/gitlab/messages/comment.html @@ -0,0 +1,20 @@ +{{ templates.repo_sender_prefix }} + + {%- if object_attributes.type == CommentType.DISCUSSION_NOTE -%} + replied to a thread + {%- else -%} + commented + {%- endif -%} + on +{% if issue and object_attributes.noteable_type == NoteableType.ISSUE %} + {{ issue_link(issue, important=false) }} +{% elif merge_request and object_attributes.noteable_type == NoteableType.MERGE_REQUEST %} + {{ merge_request_link(merge_request, important=false) }} +{% else %} + {# unsupported comment target #} + {% do abort() %} +{% endif %} +
+{% if object_attributes.description %} +
{{ object_attributes.description|markdown }}
+{% endif %} diff --git a/templates/gitlab/messages/issue_close.html b/templates/gitlab/messages/issue_close.html new file mode 100644 index 0000000..2a3f78f --- /dev/null +++ b/templates/gitlab/messages/issue_close.html @@ -0,0 +1,2 @@ +{{ templates.repo_sender_prefix }} + closed {{ issue_link(object_attributes) }} diff --git a/templates/gitlab/messages/issue_open.html b/templates/gitlab/messages/issue_open.html new file mode 100644 index 0000000..fbebee3 --- /dev/null +++ b/templates/gitlab/messages/issue_open.html @@ -0,0 +1,6 @@ +{{ templates.repo_sender_prefix }} + opened {{ issue_link(object_attributes) }}
+{% if object_attributes.description %} +
{{ object_attributes.description|markdown }}
+{% endif %} +{{ fancy_labels(object_attributes.labels) }} diff --git a/templates/gitlab/messages/issue_reopen.html b/templates/gitlab/messages/issue_reopen.html new file mode 100644 index 0000000..8615afe --- /dev/null +++ b/templates/gitlab/messages/issue_reopen.html @@ -0,0 +1,2 @@ +{{ templates.repo_sender_prefix }} + reopened {{ issue_link(object_attributes) }} diff --git a/templates/gitlab/messages/issue_update.html b/templates/gitlab/messages/issue_update.html new file mode 100644 index 0000000..5377fbe --- /dev/null +++ b/templates/gitlab/messages/issue_update.html @@ -0,0 +1,77 @@ +{{ templates.repo_sender_prefix }} +{% if changes.labels %} + {{ label_changes(changes.labels.added, changes.labels.removed) }} + {{ issue_or_merge_link(object_attributes) }} +{# Milestone webhooks don't have the milestone displayname 3:< #} +{#{% elif changes.milestone_id %}#} +{# {% if not changes.milestone_id.current %}#} +{# removed the milestone from {{ issue_or_merge_link(object_attributes) }}#} +{# {% else %}#} +{# added {{ issue_or_merge_link(object_attributes) }} to milestone#} +{# {% endif %}#} +{% elif changes.assignees %} + {{ assignee_changes(changes.assignees.added, changes.assignees.removed) }} + {{ issue_or_merge_link(object_attributes) }} +{% elif changes.time_estimate %} + {% if not changes.time_estimate.current %} + removed the time estimate of {{ issue_or_merge_link(object_attributes) }} + {% elif not changes.time_estimate.previous %} + set the time estimate of {{ issue_or_merge_link(object_attributes) }} to + {{ util.format_time(changes.time_estimate.current) }} + {% else %} + {% if changes.time_estimate.current > changes.time_estimate.previous %} + increased + {% else %} + decreased + {% endif %} + the time estimate of {{ issue_or_merge_link(object_attributes) }} by + {{ util.format_time(changes.time_estimate.current - changes.time_estimate.previous) }} + {% endif %} +{% elif changes.total_time_spent %} + {% if not changes.total_time_spent.current %} + removed the time spent + {% else %} + {% if changes.total_time_spent.current > (changes.total_time_spent.previous or 0) %} + spent {{ util.format_time(changes.total_time_spent.current - (changes.total_time_spent.previous or 0)) }} + {% else %} + subtracted {{ util.format_time(changes.total_time_spent.current - changes.total_time_spent.previous) }} + from the time spent + {% endif %} + {% endif %} + on {{ issue_or_merge_link(object_attributes) }} +{% elif changes.weight %} + {% if not changes.weight.current %} + removed + {% else %} + changed + {% endif %} + the weight of {{ issue_or_merge_link(object_attributes) }} + {% if changes.weight.current %} + to {{ changes.weight.current }} + {% endif %} +{% elif changes.due_date %} + {% if not changes.due_date.current %} + removed the due date of {{ issue_or_merge_link(object_attributes) }} + {% else %} + set the due date of {{ issue_or_merge_link(object_attributes) }} + to {{ changes.due_date.current.strftime("%B %d, %Y") }} + {% endif %} +{% elif changes.confidential %} + made {{ issue_or_merge_link(object_attributes) }} + {% if changes.confidential.current %} + confidential + {% else %} + non-confidential + {% endif %} +{% elif changes.discussion_locked %} + {% if changes.discussion_locked.current %} + locked discussion in + {% else %} + unlocked discussion in + {% endif %} + {{ issue_or_merge_link(object_attributes) }} +{% elif changes.title %} + changed the title of {{ issue_or_merge_link(object_attributes, title=false) }} to {{ changes.title.current }} +{% else %} + {% do abort() %} +{% endif %} diff --git a/templates/gitlab/messages/job.html b/templates/gitlab/messages/job.html new file mode 100644 index 0000000..5a7abba --- /dev/null +++ b/templates/gitlab/messages/job.html @@ -0,0 +1,9 @@ +{% if build_status == BuildStatus.FAILED and not build_allow_failure %} + [{{ repository.path|e }}] + Job {{ build_id }}: {{ build_name }} + failed + after {{ util.format_time(build_duration) }} + (build triggered by {{ user_link(user) }}) +{% else %} + {% do abort() %} +{% endif %} diff --git a/templates/gitlab/messages/merge_request.html b/templates/gitlab/messages/merge_request.html new file mode 100644 index 0000000..0b3dffc --- /dev/null +++ b/templates/gitlab/messages/merge_request.html @@ -0,0 +1,11 @@ +{{ templates.repo_sender_prefix }} +{% if action == OPEN %} + opened {{ merge_request_link(object_attributes) }} + {% if object_attributes.description %} +
{{ object_attributes.description|markdown }}
+ {% endif %} + {{ fancy_labels(labels) }} +{% else %} + {{ object_attributes.action.past_tense }} + {{ merge_request_link(object_attributes) }} +{% endif %} diff --git a/templates/gitlab/messages/push.html b/templates/gitlab/messages/push.html new file mode 100644 index 0000000..5285023 --- /dev/null +++ b/templates/gitlab/messages/push.html @@ -0,0 +1,27 @@ +{{ templates.repo_sender_prefix }} +{% if is_deleted_ref %} + deleted branch +{% else %} + pushed + + {{- total_commits_count }} commit{{ pluralize(total_commits_count) -}} + + to +{% endif %} + {{ ref_name }} +{%- if is_new_ref %} (new branch){% endif -%} +{%- if commits|length > 0 %}: +
    + {% for commit in commits[-5:] %} +
  • + + {{- commit.id[:8] -}} + + {{ commit.cut_message|e }} + {% if commit.author.name != user_name and commit.author.name != user_username %} + by {{ commit.author.name }} + {% endif %} +
  • + {% endfor %} +
+{% endif -%} diff --git a/templates/gitlab/messages/tag.html b/templates/gitlab/messages/tag.html new file mode 100644 index 0000000..8b3a8bb --- /dev/null +++ b/templates/gitlab/messages/tag.html @@ -0,0 +1,13 @@ +{{ templates.repo_sender_prefix }} +{% if is_deleted_ref %} + deleted tag + {{ ref_name }} +{% else %} + created tag + {{ ref_name }} +{% endif %} +{%- if message -%}: +
+ {{ message | markdown }} +
+{%- endif -%} diff --git a/templates/gitlab/messages/test.html b/templates/gitlab/messages/test.html new file mode 100644 index 0000000..a9c7426 --- /dev/null +++ b/templates/gitlab/messages/test.html @@ -0,0 +1 @@ +{{ templates.repo_sender_prefix }} \ No newline at end of file diff --git a/templates/gitlab/messages/wiki.html b/templates/gitlab/messages/wiki.html new file mode 100644 index 0000000..2d54e71 --- /dev/null +++ b/templates/gitlab/messages/wiki.html @@ -0,0 +1,5 @@ +{{ templates.repo_sender_prefix }} +{{ "edited" if object_attributes.action == UPDATE else object_attributes.action.past_tense }} +{{ object_attributes.title }} +on the wiki +{%- if object_attributes.message -%}: {{ object_attributes.message }}{% endif %} diff --git a/templates/gitlab/mixins/repo_sender_prefix.html b/templates/gitlab/mixins/repo_sender_prefix.html new file mode 100644 index 0000000..144e0cf --- /dev/null +++ b/templates/gitlab/mixins/repo_sender_prefix.html @@ -0,0 +1 @@ +[{{ repo_link(project) }}] {{ user_link(user) }} diff --git a/tests/test_gitlab.py b/tests/test_gitlab.py new file mode 100644 index 0000000..0a3ec81 --- /dev/null +++ b/tests/test_gitlab.py @@ -0,0 +1,163 @@ +import json +from ops_bot.gitlab import hook +from ops_bot.util.template import TemplateUtil + +issue_open_payload_raw = """ +{ + "object_kind": "issue", + "event_type": "issue", + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon", + "email": "admin@example.com" + }, + "project": { + "id": 1, + "name":"Gitlab Test", + "description":"Aut reprehenderit ut est.", + "web_url":"http://example.com/gitlabhq/gitlab-test", + "avatar_url":null, + "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", + "git_http_url":"http://example.com/gitlabhq/gitlab-test.git", + "namespace":"GitlabHQ", + "visibility_level":20, + "path_with_namespace":"gitlabhq/gitlab-test", + "default_branch":"master", + "ci_config_path": null, + "homepage":"http://example.com/gitlabhq/gitlab-test", + "url":"http://example.com/gitlabhq/gitlab-test.git", + "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", + "http_url":"http://example.com/gitlabhq/gitlab-test.git" + }, + "object_attributes": { + "id": 301, + "title": "New API: create/update/delete file", + "assignee_ids": [51], + "assignee_id": 51, + "author_id": 51, + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2013-12-03T17:15:43Z", + "updated_by_id": 1, + "last_edited_at": null, + "last_edited_by_id": null, + "relative_position": 0, + "description": "Create new API for manipulations with repository", + "milestone_id": null, + "state_id": 1, + "confidential": false, + "discussion_locked": true, + "due_date": null, + "moved_to_id": null, + "duplicated_to_id": null, + "time_estimate": 0, + "total_time_spent": 0, + "time_change": 0, + "human_total_time_spent": null, + "human_time_estimate": null, + "human_time_change": null, + "weight": null, + "iid": 23, + "url": "http://example.com/diaspora/issues/23", + "state": "opened", + "action": "open", + "severity": "high", + "escalation_status": "triggered", + "escalation_policy": { + "id": 18, + "name": "Engineering On-call" + }, + "labels": [{ + "id": 206, + "title": "API", + "color": "#ffffff", + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2013-12-03T17:15:43Z", + "template": false, + "description": "API related issues", + "type": "ProjectLabel", + "group_id": 41 + }] + }, + "repository": { + "name": "Gitlab Test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "description": "Aut reprehenderit ut est.", + "homepage": "http://example.com/gitlabhq/gitlab-test" + }, + "assignees": [{ + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }], + "assignee": { + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "labels": [{ + "id": 206, + "title": "API", + "color": "#ffffff", + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2013-12-03T17:15:43Z", + "template": false, + "description": "API related issues", + "type": "ProjectLabel", + "group_id": 41 + }], + "changes": { + "updated_by_id": { + "previous": null, + "current": 1 + }, + "updated_at": { + "previous": "2017-09-15 16:50:55 UTC", + "current": "2017-09-15 16:52:00 UTC" + }, + "labels": { + "previous": [{ + "id": 206, + "title": "API", + "color": "#ffffff", + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2013-12-03T17:15:43Z", + "template": false, + "description": "API related issues", + "type": "ProjectLabel", + "group_id": 41 + }], + "current": [{ + "id": 205, + "title": "Platform", + "color": "#123123", + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2013-12-03T17:15:43Z", + "template": false, + "description": "Platform related issues", + "type": "ProjectLabel", + "group_id": 41 + }] + } + } +}""" +issue_open_payload = json.loads(issue_open_payload_raw) +def test_templates(): + # print(gitlab.messages._loader.list_templates()) + tpl = hook.messages["test"] + args = issue_open_payload | {"util": TemplateUtil} + args["templates"] = hook.templates.proxy(args) + assert tpl.render(**args) == "[gitlabhq/gitlab-test] root" + + +async def test_hook(): + r = await hook.parse_event("Issue Hook", issue_open_payload) + assert r[0] + assert r[0][0] == "[gitlabhq/gitlab-test] root opened [issue #23](http://example.com/diaspora/issues/23): New API: create/update/delete file\n \n> Create new API for manipulations with repository\nAPI" + assert r[0][1] == "[gitlabhq/gitlab-test] root\n opened issue #23: New API: create/update/delete file
\n

Create new API for manipulations with repository

\n
\n  API " \ No newline at end of file