Add gitlab webhook support
This commit is contained in:
parent
9d41d56e0c
commit
a1ae717c8f
26 changed files with 1824 additions and 8 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,3 +4,5 @@ __pycache__
|
|||
.venv
|
||||
devstate
|
||||
.env*
|
||||
config.json
|
||||
dev.data
|
||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
|
|
@ -91,3 +91,7 @@ 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 <https://www.gnu.org/licenses/>.
|
||||
```
|
||||
|
||||
|
||||
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.
|
||||
|
|
|
|||
70
ops_bot/gitlab/hook.py
Normal file
70
ops_bot/gitlab/hook.py
Normal file
|
|
@ -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
|
||||
930
ops_bot/gitlab/types.py
Normal file
930
ops_bot/gitlab/types.py
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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,
|
||||
}
|
||||
|
|
@ -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:
|
||||
# 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)
|
||||
|
|
|
|||
62
ops_bot/util/contrast.py
Normal file
62
ops_bot/util/contrast.py
Normal file
|
|
@ -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
|
||||
36
ops_bot/util/markdown.py
Normal file
36
ops_bot/util/markdown.py
Normal file
|
|
@ -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)
|
||||
132
ops_bot/util/template.py
Normal file
132
ops_bot/util/template.py
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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}::<strong>{label}</strong>"
|
||||
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)
|
||||
126
poetry.lock
generated
126
poetry.lock
generated
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[pytest]
|
||||
asyncio_mode = auto
|
||||
75
templates/gitlab/macros.html
Normal file
75
templates/gitlab/macros.html
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
{%- macro repo_link(project) -%}
|
||||
<a data-mautrix-exclude-plaintext href="{{ project.web_url }}">{{ project.path_with_namespace|e }}</a>
|
||||
{%- endmacro -%}
|
||||
{%- macro user_link(user) -%}
|
||||
<a data-mautrix-exclude-plaintext href="{{ user.web_url }}">{{ user.username|e }}</a>
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- macro pluralize(value) -%}
|
||||
{% if value != 1 %}s{% endif %}
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- macro issue_link(issue, title = true, important = true) -%}
|
||||
<a href="{{ issue.url }}" {% if not important %}data-mautrix-exclude-plaintext{% endif %}>
|
||||
{%- if issue.confidential and (not changes or not changes.confidential) -%}confidential {% endif -%}
|
||||
issue #{{ issue.issue_id -}}
|
||||
</a>
|
||||
{%- if title -%}
|
||||
: {{ issue.title|e -}}
|
||||
{% endif %}
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- macro merge_request_link(merge_request, title = true, important = true) -%}
|
||||
<a href="{{ merge_request.url }}" {% if not important %}data-mautrix-exclude-plaintext{% endif %}>
|
||||
merge request !{{ merge_request.merge_request_id -}}
|
||||
</a>
|
||||
{%- 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) -%}
|
||||
<span data-mx-color="{{ label.foreground_color }}"
|
||||
data-mx-bg-color="{{ label.color }}"
|
||||
title="{{ label.description }}"
|
||||
> {{ util.bold_scope(label.title) }} </span>
|
||||
{%- 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 -%}
|
||||
20
templates/gitlab/messages/comment.html
Normal file
20
templates/gitlab/messages/comment.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
<a data-mautrix-exclude-plaintext href="{{ object_attributes.url }}">
|
||||
{%- if object_attributes.type == CommentType.DISCUSSION_NOTE -%}
|
||||
replied to a thread
|
||||
{%- else -%}
|
||||
commented
|
||||
{%- endif -%}
|
||||
</a> 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 %}
|
||||
<br/>
|
||||
{% if object_attributes.description %}
|
||||
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
|
||||
{% endif %}
|
||||
2
templates/gitlab/messages/issue_close.html
Normal file
2
templates/gitlab/messages/issue_close.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
closed {{ issue_link(object_attributes) }}
|
||||
6
templates/gitlab/messages/issue_open.html
Normal file
6
templates/gitlab/messages/issue_open.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
opened {{ issue_link(object_attributes) }}<br/>
|
||||
{% if object_attributes.description %}
|
||||
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
|
||||
{% endif %}
|
||||
{{ fancy_labels(object_attributes.labels) }}
|
||||
2
templates/gitlab/messages/issue_reopen.html
Normal file
2
templates/gitlab/messages/issue_reopen.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
reopened {{ issue_link(object_attributes) }}
|
||||
77
templates/gitlab/messages/issue_update.html
Normal file
77
templates/gitlab/messages/issue_update.html
Normal file
|
|
@ -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
|
||||
<strong>{{ util.format_time(changes.time_estimate.current) }}</strong>
|
||||
{% 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
|
||||
<strong>{{ util.format_time(changes.time_estimate.current - changes.time_estimate.previous) }}</strong>
|
||||
{% 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 <strong>{{ util.format_time(changes.total_time_spent.current - (changes.total_time_spent.previous or 0)) }}</strong>
|
||||
{% else %}
|
||||
subtracted <strong>{{ util.format_time(changes.total_time_spent.current - changes.total_time_spent.previous) }}</strong>
|
||||
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 <strong>{{ changes.weight.current }}</strong>
|
||||
{% 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 <strong>{{ changes.due_date.current.strftime("%B %d, %Y") }}</strong>
|
||||
{% 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 %}
|
||||
9
templates/gitlab/messages/job.html
Normal file
9
templates/gitlab/messages/job.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% if build_status == BuildStatus.FAILED and not build_allow_failure %}
|
||||
<strong>[<a data-mautrix-exclude-plaintext href="{{ repository.homepage }}">{{ repository.path|e }}</a>]</strong>
|
||||
<a href="{{ build_url }}">Job {{ build_id }}: {{ build_name }}</a>
|
||||
<strong><font color="#ff0000">failed</font></strong>
|
||||
after {{ util.format_time(build_duration) }}
|
||||
(build triggered by {{ user_link(user) }})
|
||||
{% else %}
|
||||
{% do abort() %}
|
||||
{% endif %}
|
||||
11
templates/gitlab/messages/merge_request.html
Normal file
11
templates/gitlab/messages/merge_request.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
{% if action == OPEN %}
|
||||
opened {{ merge_request_link(object_attributes) }}
|
||||
{% if object_attributes.description %}
|
||||
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
|
||||
{% endif %}
|
||||
{{ fancy_labels(labels) }}
|
||||
{% else %}
|
||||
{{ object_attributes.action.past_tense }}
|
||||
{{ merge_request_link(object_attributes) }}
|
||||
{% endif %}
|
||||
27
templates/gitlab/messages/push.html
Normal file
27
templates/gitlab/messages/push.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
{% if is_deleted_ref %}
|
||||
deleted branch
|
||||
{% else %}
|
||||
pushed
|
||||
<a href="{{ diff_url }}" data-mautrix-exclude-plaintext>
|
||||
{{- total_commits_count }} commit{{ pluralize(total_commits_count) -}}
|
||||
</a>
|
||||
to
|
||||
{% endif %}
|
||||
<a data-mautrix-exclude-plaintext href="{{ ref_url }}">{{ ref_name }}</a>
|
||||
{%- if is_new_ref %} (new branch){% endif -%}
|
||||
{%- if commits|length > 0 %}:
|
||||
<ul>
|
||||
{% for commit in commits[-5:] %}
|
||||
<li>
|
||||
<code><a data-mautrix-exclude-plaintext href="{{ commit.url }}">
|
||||
{{- commit.id[:8] -}}
|
||||
</a></code>
|
||||
{{ commit.cut_message|e }}
|
||||
{% if commit.author.name != user_name and commit.author.name != user_username %}
|
||||
by {{ commit.author.name }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif -%}
|
||||
13
templates/gitlab/messages/tag.html
Normal file
13
templates/gitlab/messages/tag.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
{% if is_deleted_ref %}
|
||||
deleted tag
|
||||
{{ ref_name }}
|
||||
{% else %}
|
||||
created tag
|
||||
<a data-mautrix-exclude-plaintext href="{{ ref_url }}">{{ ref_name }}</a>
|
||||
{% endif %}
|
||||
{%- if message -%}:
|
||||
<blockquote>
|
||||
{{ message | markdown }}
|
||||
</blockquote>
|
||||
{%- endif -%}
|
||||
1
templates/gitlab/messages/test.html
Normal file
1
templates/gitlab/messages/test.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
5
templates/gitlab/messages/wiki.html
Normal file
5
templates/gitlab/messages/wiki.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{{ templates.repo_sender_prefix }}
|
||||
{{ "edited" if object_attributes.action == UPDATE else object_attributes.action.past_tense }}
|
||||
<a href="{{ object_attributes.url }}">{{ object_attributes.title }}</a>
|
||||
on the wiki
|
||||
{%- if object_attributes.message -%}: {{ object_attributes.message }}{% endif %}
|
||||
1
templates/gitlab/mixins/repo_sender_prefix.html
Normal file
1
templates/gitlab/mixins/repo_sender_prefix.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<strong data-mautrix-exclude-plaintext>[{{ repo_link(project) }}]</strong> {{ user_link(user) }}
|
||||
163
tests/test_gitlab.py
Normal file
163
tests/test_gitlab.py
Normal file
|
|
@ -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) == "<strong data-mautrix-exclude-plaintext>[<a data-mautrix-exclude-plaintext href=\"http://example.com/gitlabhq/gitlab-test\">gitlabhq/gitlab-test</a>]</strong> <a data-mautrix-exclude-plaintext href=\"\">root</a>"
|
||||
|
||||
|
||||
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] == "<strong data-mautrix-exclude-plaintext>[<a data-mautrix-exclude-plaintext href=\"http://example.com/gitlabhq/gitlab-test\">gitlabhq/gitlab-test</a>]</strong> <a data-mautrix-exclude-plaintext href=\"http://example.com/root\">root</a>\n opened <a href=\"http://example.com/diaspora/issues/23\" >issue #23</a>: New API: create/update/delete file<br/>\n <blockquote><p>Create new API for manipulations with repository</p>\n</blockquote>\n <span data-mx-color=\"#000000\"\n data-mx-bg-color=\"#ffffff\"\n title=\"API related issues\"\n > API </span>"
|
||||
Loading…
Add table
Add a link
Reference in a new issue