Add gitlab webhook support

This commit is contained in:
Abel Luck 2022-12-01 13:47:27 +00:00
parent 9d41d56e0c
commit a1ae717c8f
26 changed files with 1824 additions and 8 deletions

2
.gitignore vendored
View file

@ -4,3 +4,5 @@ __pycache__
.venv
devstate
.env*
config.json
dev.data

7
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

View file

@ -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
View 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
View 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,
}

View file

@ -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
View 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
View 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("<", "&lt;").replace(">", "&gt;"))
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
View 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
View file

@ -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"},

View file

@ -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
View file

@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

View 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 }}"
>&nbsp;{{ util.bold_scope(label.title) }}&nbsp;</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 -%}

View 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 %}

View file

@ -0,0 +1,2 @@
{{ templates.repo_sender_prefix }}
closed {{ issue_link(object_attributes) }}

View 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) }}

View file

@ -0,0 +1,2 @@
{{ templates.repo_sender_prefix }}
reopened {{ issue_link(object_attributes) }}

View 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 %}

View 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 %}

View 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 %}

View 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 -%}

View 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 -%}

View file

@ -0,0 +1 @@
{{ templates.repo_sender_prefix }}

View 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 %}

View file

@ -0,0 +1 @@
<strong data-mautrix-exclude-plaintext>[{{ repo_link(project) }}]</strong> {{ user_link(user) }}

163
tests/test_gitlab.py Normal file
View 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 >&nbsp;API&nbsp;</span>"