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