diff --git a/.gitignore b/.gitignore
index 6c0c76a..f52fb92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ __pycache__
.venv
devstate
.env*
+config.json
+dev.data
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 9d3e1e7..6d98489 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -30,7 +30,6 @@ build-test:
- docker pull registry.gitlab.com/$CI_PROJECT_NAMESPACE/${CI_PROJECT_NAME}:main
- docker build -t $UNIQUE_IMAGE .
- docker push $UNIQUE_IMAGE
- - docker run --entrypoint /test $UNIQUE_IMAGE
except:
- main
- tags
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..9b38853
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "python.testing.pytestArgs": [
+ "tests"
+ ],
+ "python.testing.unittestEnabled": false,
+ "python.testing.pytestEnabled": true
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 179c16a..8a9a6a9 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,34 @@
# matrix-ops-bot
-a bot for ops in matrix
+> a bot for ops in matrix
-Current features:
+This bot catches webhooks and forwards them as messages to matrix rooms.
-* Catch PagerDuty webhooks and forward them to a matrix room
+Current supported webhooks:
+
+* PagerDuty
+* AWS SNS
+* Gitlab
## Usage
-Note: Register your bot user manually. This program does not register a new user.
+See [config.json.sample](config.json.sample) for a sample config file.
+
+Once you have a basic config (leave the routing_keys an empty list), you can easily add new webhooks with
+
+```console
+$ poetry run config add-hook --name my-hook-name --hook-type gitlab --room-id '!abcd1234:matrix.org'
+
+Hook added successfully
+
+Your webhook URL is:
+ /hook/vLyPN5mqXnIGE-4o9IKJ3vsOMU1xYEKBW8r4WMvP
+The secret token is:
+ 6neuYcncR2zaeQiEoawXdu6a4olsfH447tFetfvv
+```
+
+Note: Register your bot user manually. This program does not register a new
+user. You must also accept invitations to join the room automatically.
```
docker build -t registry.gitlab.com/guardianproject-ops/matrix-ops-bot .
@@ -90,4 +110,8 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
-```
\ No newline at end of file
+```
+
+
+This project uses pieces of [maubot/gitlab](https://github.com/maubot/gitlab),
+which is also AGPL. These files have been noted with a comment header.
diff --git a/config.json.sample b/config.json.sample
new file mode 100644
index 0000000..1bec7a5
--- /dev/null
+++ b/config.json.sample
@@ -0,0 +1,20 @@
+{
+ "routing_keys": [
+ {
+ "name": "gp-ops-alert-dev",
+ "path_key": "eePh0Gaedaing8yoogoh",
+ "secret_token": "ooLeoquaeGh9yaNgoh5u",
+ "room_id": "!ABCD123:matrix.org",
+ "hook_type": "gitlab"
+ },
+ ],
+ "log_level": "INFO",
+ "matrix": {
+ "homeserver": "https://matrix.org",
+ "user_id": "@my-bot-name:matrix.org",
+ "password": "hunter2",
+ "device_name": "bot.mydomain.com",
+ "store_path": "dev.data/",
+ "verify_ssl": true
+ }
+}
\ No newline at end of file
diff --git a/ops_bot/aws.py b/ops_bot/aws.py
index ab5c34b..ad150ab 100644
--- a/ops_bot/aws.py
+++ b/ops_bot/aws.py
@@ -1,18 +1,21 @@
import json
import logging
-from typing import Any, Tuple
+from typing import Any, List, Tuple
+
+from fastapi import Request
from ops_bot.common import COLOR_ALARM, COLOR_OK, COLOR_UNKNOWN
+from ops_bot.config import RoutingKey
-def handle_subscribe_confirm(payload: Any) -> Tuple[str, str]:
+def handle_subscribe_confirm(payload: Any) -> List[Tuple[str, str]]:
message = payload.get("Message")
url = payload.get("SubscribeURL")
plain = f"{message}\n\n{url}"
- return plain, plain
+ return [(plain, plain)]
-def handle_notification(payload: Any) -> Tuple[str, str]:
+def handle_notification(payload: Any) -> List[Tuple[str, str]]:
message = payload.get("Message")
subject = payload.get("Subject")
@@ -20,15 +23,15 @@ def handle_notification(payload: Any) -> Tuple[str, str]:
formatted = (
f"{subject}\n
{message}
"
)
- return plain, formatted
+ return [(plain, formatted)]
-def handle_json_notification(payload: Any, body: Any) -> Tuple[str, str]:
+def handle_json_notification(payload: Any, body: Any) -> List[Tuple[str, str]]:
if "AlarmName" not in body:
msg = "Received unknown json payload type over AWS SNS"
logging.info(msg)
logging.info(payload.get("Message"))
- return msg, msg
+ return [(msg, msg)]
description = body.get("AlarmDescription")
subject = payload.get("Subject")
@@ -50,10 +53,14 @@ def handle_json_notification(payload: Any, body: Any) -> Tuple[str, str]:
else:
plain += "\n{description}"
formatted += f"\n{description}
"
- return plain, formatted
+ return [(plain, formatted)]
-def parse_sns_event(payload: Any) -> Tuple[str, str]:
+async def parse_sns_event(
+ route: RoutingKey,
+ payload: Any,
+ request: Request,
+) -> List[Tuple[str, str]]:
if payload.get("Type") == "SubscriptionConfirmation":
return handle_subscribe_confirm(payload)
elif payload.get("Type") == "UnsubscribeConfirmation":
diff --git a/ops_bot/cli.py b/ops_bot/cli.py
new file mode 100644
index 0000000..11cfe37
--- /dev/null
+++ b/ops_bot/cli.py
@@ -0,0 +1,56 @@
+import secrets
+import sys
+from typing import Any
+
+import click
+
+from ops_bot.config import RoutingKey, load_config, save_config
+
+
+@click.group()
+@click.option(
+ "--config-file", help="the path to the config file", default="config.json"
+)
+@click.pass_context
+def cli(ctx: Any, config_file: str) -> None:
+ ctx.obj = config_file
+ pass
+
+
+@cli.command(help="Add a new routing key to the configuration file")
+@click.option(
+ "--name", help="a friendly detailed name for the hook so you can remember it later"
+)
+@click.option(
+ "--hook-type",
+ help="The type of webhook to add",
+ type=click.Choice(["gitlab", "pagerduty", "aws-sns"], case_sensitive=False),
+)
+@click.option("--room-id", help="The room ID to send the messages to")
+@click.pass_obj
+def add_hook(config_file: str, name: str, hook_type: str, room_id: str) -> None:
+ settings = load_config(config_file)
+ path_key = secrets.token_urlsafe(30)
+ secret_token = secrets.token_urlsafe(30)
+ if name in set([key.name for key in settings.routing_keys]):
+ print("Error: A hook with that name already exists")
+ sys.exit(1)
+ settings.routing_keys.append(
+ RoutingKey(
+ name=name,
+ path_key=path_key,
+ secret_token=secret_token,
+ room_id=room_id,
+ hook_type=hook_type,
+ )
+ )
+ save_config(settings)
+
+ url = f"/hook/{path_key}"
+
+ print("Hook added successfully")
+ print()
+ print("Your webhook URL is:")
+ print(f"\t{url}")
+ print("The secret token is:")
+ print(f"\t{secret_token}")
diff --git a/ops_bot/config.py b/ops_bot/config.py
new file mode 100644
index 0000000..bfa9fc7
--- /dev/null
+++ b/ops_bot/config.py
@@ -0,0 +1,47 @@
+import json
+import logging
+from pathlib import Path
+from typing import List, Literal
+
+from pydantic import BaseSettings
+
+from ops_bot.matrix import MatrixClientSettings
+
+
+class RoutingKey(BaseSettings):
+ name: str
+ path_key: str
+ secret_token: str
+ room_id: str
+ hook_type: Literal["gitlab", "pagerduty", "aws-sns"]
+
+
+class BotSettings(BaseSettings):
+ routing_keys: List[RoutingKey]
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
+ matrix: MatrixClientSettings
+
+ class Config:
+ env_prefix = "BOT_"
+ case_sensitive = False
+
+ def get_rooms(self) -> List[str]:
+ return list(set([key.room_id for key in self.routing_keys]))
+
+
+def config_file_exists(filename: str) -> bool:
+ return Path(filename).exists()
+
+
+def load_config(filename: str = "config.json") -> BotSettings:
+ if config_file_exists(filename):
+ bot_settings = BotSettings.parse_file(filename)
+ else:
+ bot_settings = BotSettings(_env_file=".env", _env_file_encoding="utf-8")
+ logging.getLogger().setLevel(bot_settings.log_level)
+ return bot_settings
+
+
+def save_config(settings: BotSettings, filename: str = "config.json") -> None:
+ with open(filename, "w") as f:
+ f.write(json.dumps(settings.dict(), indent=2))
diff --git a/ops_bot/gitlab/hook.py b/ops_bot/gitlab/hook.py
new file mode 100644
index 0000000..0f46bff
--- /dev/null
+++ b/ops_bot/gitlab/hook.py
@@ -0,0 +1,90 @@
+import logging
+import re
+from typing import Any, List, Optional, Tuple
+
+import attr
+from fastapi import Request
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBasicCredentials
+from jinja2 import TemplateNotFound
+from mautrix.types import Format, MessageType, TextMessageEventContent
+from mautrix.util.formatter import parse_html
+
+from ..config import RoutingKey
+from ..util.template import TemplateManager, TemplateUtil
+from .types import OTHER_ENUMS, Action, EventParse # type: ignore
+
+spaces = re.compile(" +")
+space = " "
+
+
+messages = TemplateManager("gitlab", "messages")
+templates = TemplateManager("gitlab", "mixins")
+
+
+async def authorize(
+ route: RoutingKey,
+ request: Request,
+ basic_credentials: Optional[HTTPBasicCredentials],
+ bearer_credentials: Optional[HTTPAuthorizationCredentials],
+) -> bool:
+ provided: Optional[str] = request.headers.get("x-gitlab-token")
+ return provided == route.secret_token
+
+
+async def handle_event(x_gitlab_event: str, payload: Any) -> List[Tuple[str, str]]:
+ evt = EventParse[x_gitlab_event].deserialize(payload)
+ try:
+ tpl = messages[evt.template_name]
+ except TemplateNotFound:
+ msg = f"Received unhandled gitlab event type {x_gitlab_event}"
+ logging.error(msg)
+ logging.debug(payload)
+ return []
+
+ 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():
+ args = {
+ **attr.asdict(subevt, recurse=False),
+ **{key: getattr(subevt, key) for key in subevt.event_properties},
+ "abort": abort,
+ **base_args, # type: ignore
+ }
+ args["templates"] = templates.proxy(args) # type: ignore
+
+ 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
+
+
+async def parse_event(
+ route: RoutingKey, payload: Any, request: Request
+) -> List[Tuple[str, str]]:
+ x_gitlab_event = request.headers.get("x-gitlab-event")
+ return await handle_event(x_gitlab_event, payload)
diff --git a/ops_bot/gitlab/types.py b/ops_bot/gitlab/types.py
new file mode 100644
index 0000000..63d23a3
--- /dev/null
+++ b/ops_bot/gitlab/types.py
@@ -0,0 +1,957 @@
+# type: ignore
+# gitlab - A GitLab client and webhook receiver for maubot
+# Copyright (C) 2019 Lorenz Steinert
+# Copyright (C) 2021 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+from datetime import datetime
+from typing import ClassVar, Dict, Iterable, List, NewType, Optional, Tuple, Type, Union
+
+import attr
+from attr import dataclass
+from jinja2 import TemplateNotFound
+from mautrix.types import (
+ JSON,
+ ExtensibleEnum,
+ SerializableAttrs,
+ deserializer,
+ serializer,
+)
+from yarl import URL
+
+from ..util.contrast import contrast, hex_to_rgb
+
+
+@serializer(datetime)
+def datetime_serializer(dt: datetime) -> JSON:
+ return dt.strftime("%Y-%m-%dT%H:%M:%S%z")
+
+
+@deserializer(datetime)
+def datetime_deserializer(data: JSON) -> datetime:
+ try:
+ return datetime.strptime(data, "%Y-%m-%dT%H:%M:%S.%f%z")
+ except ValueError:
+ pass
+
+ try:
+ return datetime.strptime(data, "%Y-%m-%dT%H:%M:%S%z")
+ except ValueError:
+ pass
+
+ try:
+ return datetime.strptime(data, "%Y-%m-%d %H:%M:%S %z")
+ except ValueError:
+ pass
+
+ try:
+ return datetime.strptime(data, "%Y-%m-%dT%H:%M:%SZ")
+ except ValueError:
+ pass
+
+ try:
+ return datetime.strptime(data, "%Y-%m-%d %H:%M:%S %Z")
+ except ValueError:
+ pass
+
+ try:
+ return datetime.strptime(data, "%Y-%m-%d")
+ except ValueError:
+ pass
+
+ raise ValueError(data)
+
+
+class LabelType(ExtensibleEnum):
+ PROJECT = "ProjectLabel"
+ # TODO group?
+
+
+@dataclass(frozen=True)
+class GitlabLabel(SerializableAttrs):
+ contrast_threshold: ClassVar[float] = 2.5
+ white_rgb: ClassVar[Tuple[int, int, int]] = (1, 1, 1)
+ white_hex: ClassVar[str] = "#ffffff"
+ black_hex: ClassVar[str] = "#000000"
+
+ id: int
+ title: str
+ color: str
+ project_id: int
+ created_at: datetime
+ updated_at: datetime
+ template: bool
+ description: str
+ type: LabelType
+ group_id: Optional[int]
+ remove_on_close: bool = False
+
+ @property
+ def foreground_color(self) -> str:
+ return (
+ self.white_hex
+ if contrast(hex_to_rgb(self.color), self.white_rgb)
+ >= self.contrast_threshold
+ else self.black_hex
+ )
+
+
+@dataclass
+class GitlabProject(SerializableAttrs):
+ id: Optional[int] = None
+ name: Optional[str] = None
+ description: Optional[str] = None
+ web_url: Optional[str] = None
+ avatar_url: Optional[str] = None
+ git_ssh_url: Optional[str] = None
+ git_http_url: Optional[str] = None
+ namespace: Optional[str] = None
+ visibility_level: Optional[int] = None
+ path_with_namespace: Optional[str] = None
+ default_branch: Optional[str] = None
+ homepage: Optional[str] = None
+ url: Optional[str] = None
+ ssh_url: Optional[str] = None
+ http_url: Optional[str] = None
+
+ @property
+ def gitlab_base_url(self) -> str:
+ return self.web_url.split(self.path_with_namespace)[0].rstrip("/")
+
+
+@dataclass(eq=False, hash=False)
+class GitlabUser(SerializableAttrs):
+ name: str
+ username: Optional[str] = None
+ avatar_url: Optional[str] = None
+ email: Optional[str] = None
+ id: Optional[int] = None
+ web_url: Optional[str] = None
+
+ def __hash__(self) -> int:
+ return self.id
+
+ def __eq__(self, other: "GitlabUser") -> bool:
+ if not isinstance(other, GitlabUser):
+ return False
+ return self.id == other.id
+
+
+@dataclass
+class GitlabAuthor(SerializableAttrs):
+ name: str
+ email: str
+
+
+@dataclass
+class BaseCommit:
+ message: str
+
+ @property
+ def cut_message(self) -> str:
+ max_len = 72
+ message = self.message.strip()
+ if "\n" in message:
+ message = message.split("\n")[0]
+ if len(message) <= max_len:
+ message += " […]"
+ return message
+ if len(message) > max_len:
+ message = message[:max_len] + "…"
+ return message
+
+
+@dataclass
+class GitlabCommit(BaseCommit, SerializableAttrs):
+ id: str
+ timestamp: Optional[datetime] = None
+ url: Optional[str] = None
+ author: Optional[GitlabAuthor] = None
+
+ added: Optional[List[str]] = None
+ modified: Optional[List[str]] = None
+ removed: Optional[List[str]] = None
+
+
+@dataclass
+class GitlabRepository(SerializableAttrs):
+ name: str
+ url: Optional[str] = None
+ description: Optional[str] = None
+ homepage: Optional[str] = None
+ git_http_url: Optional[str] = None
+ git_ssh_url: Optional[str] = None
+ visibility_level: Optional[int] = None
+
+ @property
+ def path(self) -> str:
+ return URL(self.homepage).path.strip("/")
+
+
+@dataclass
+class GitlabStDiff(SerializableAttrs):
+ diff: str
+ new_path: str
+ old_path: str
+ a_mode: str
+ b_mode: str
+ new_file: bool
+ renamed_file: bool
+ deleted_file: bool
+
+
+@dataclass
+class GitlabSource(SerializableAttrs):
+ name: str
+ namespace: str
+
+ description: Optional[str] = None
+ web_url: Optional[str] = None
+ avatar_url: Optional[str] = None
+ git_ssh_url: Optional[str] = None
+ git_http_url: Optional[str] = None
+ visibility_level: Optional[int] = None
+ path_with_namespace: Optional[str] = None
+ default_branch: Optional[str] = None
+ homepage: Optional[str] = None
+ url: Optional[str] = None
+ ssh_url: Optional[str] = None
+ http_url: Optional[str] = None
+
+
+GitlabTarget = NewType("GitlabTarget", GitlabSource)
+
+
+class GitlabChangeWrapper:
+ previous: list
+ current: list
+
+ @property
+ def added(self) -> list:
+ previous_set = set(self.previous)
+ return [item for item in self.current if item not in previous_set]
+
+ @property
+ def removed(self) -> list:
+ current_set = set(self.current)
+ return [item for item in self.previous if item not in current_set]
+
+
+@dataclass
+class GitlabDatetimeChange(SerializableAttrs):
+ previous: Optional[datetime]
+ current: datetime
+
+
+@dataclass
+class GitlabAssigneeChanges(GitlabChangeWrapper, SerializableAttrs):
+ previous: List[GitlabUser]
+ current: List[GitlabUser]
+
+
+@dataclass
+class GitlabLabelChanges(GitlabChangeWrapper, SerializableAttrs):
+ previous: List[GitlabLabel]
+ current: List[GitlabLabel]
+
+
+@dataclass
+class GitlabIntChange(SerializableAttrs):
+ previous: Optional[int]
+ current: Optional[int]
+
+
+@dataclass
+class GitlabBoolChange(SerializableAttrs):
+ previous: Optional[bool]
+ current: Optional[bool]
+
+
+@dataclass
+class GitlabStringChange(SerializableAttrs):
+ previous: Optional[str]
+ current: Optional[str]
+
+
+@dataclass
+class GitlabChanges(SerializableAttrs):
+ created_at: Optional[GitlabDatetimeChange] = None
+ updated_at: Optional[GitlabDatetimeChange] = None
+ updated_by: Optional[GitlabIntChange] = None
+ author_id: Optional[GitlabIntChange] = None
+ id: Optional[GitlabIntChange] = None
+ iid: Optional[GitlabIntChange] = None
+ project_id: Optional[GitlabIntChange] = None
+ milestone_id: Optional[GitlabIntChange] = None
+ description: Optional[GitlabStringChange] = None
+ title: Optional[GitlabStringChange] = None
+ labels: Optional[GitlabLabelChanges] = None
+ assignees: Optional[GitlabAssigneeChanges] = None
+ time_estimate: Optional[GitlabIntChange] = None
+ total_time_spent: Optional[GitlabIntChange] = None
+ weight: Optional[GitlabIntChange] = None
+ due_date: Optional[GitlabDatetimeChange] = None
+ confidential: Optional[GitlabBoolChange] = None
+ discussion_locked: Optional[GitlabBoolChange] = None
+
+
+@dataclass
+class GitlabIssue(SerializableAttrs):
+ id: int
+ issue_id: int = attr.ib(metadata={"json": "iid"})
+ title: str
+ description: str
+ author_id: int
+ project_id: int
+ created_at: datetime
+ updated_at: datetime
+ assignee_id: Optional[int] = None
+ assignee_ids: Optional[List[int]] = None
+ relative_position: Optional[int] = None
+ branch_name: Optional[str] = None
+ milestone_id: Optional[int] = None
+ state: Optional[str] = None
+
+
+@dataclass
+class GitlabSnippet(SerializableAttrs):
+ id: int
+ title: str
+ content: str
+ author_id: int
+ project_id: int
+ created_at: datetime
+ updated_at: datetime
+ file_name: str
+ expires_at: datetime
+ type: str
+ visibility_level: int
+
+
+class Action(ExtensibleEnum):
+ OPEN = "open"
+ REOPEN = "reopen"
+ CLOSE = "close"
+ UPDATE = "update"
+ CREATE = "create"
+ DELETE = "delete"
+
+ @property
+ def past_tense(self) -> str:
+ action = self.value
+ if not action:
+ return action
+ elif action[-2:] != "ed":
+ if action[-1] == "e":
+ return f"{action}d"
+ return f"{action}ed"
+ return action
+
+
+class NoteableType(ExtensibleEnum):
+ ISSUE = "Issue"
+ MERGE_REQUEST = "MergeRequest"
+
+
+class CommentType(ExtensibleEnum):
+ DISCUSSION_NOTE = "DiscussionNote"
+
+
+@dataclass
+class GitlabIssueAttributes(SerializableAttrs):
+ id: int
+ project_id: int
+ issue_id: int = attr.ib(metadata={"json": "iid"})
+ state: str
+ url: str
+ author_id: int
+ title: str
+ description: str
+
+ created_at: datetime
+ updated_at: datetime
+ updated_by_id: Optional[int] = None
+ due_date: Optional[datetime] = None
+ closed_at: Optional[datetime] = None
+
+ time_estimate: Optional[int] = None
+ total_time_spent: Optional[int] = None
+ human_time_estimate: Optional[str] = None
+ human_total_time_spent: Optional[str] = None
+
+ action: Optional[Action] = None
+ assignee_id: Optional[int] = None
+ assignee_ids: Optional[List[int]] = None
+ branch_name: Optional[str] = None
+ confidential: bool = False
+ duplicated_to_id: Optional[int] = None
+ moved_to_id: Optional[int] = None
+ state_id: Optional[int] = None
+ milestone_id: Optional[int] = None
+ labels: Optional[List[GitlabLabel]] = None
+ position: Optional[int] = None
+ original_position: Optional[int] = None
+
+
+@dataclass
+class GitlabCommentAttributes(SerializableAttrs):
+ id: int
+ note: str
+ noteable_type: NoteableType
+ project_id: int
+ url: str
+ author_id: int
+
+ created_at: datetime
+ updated_at: datetime
+ updated_by_id: Optional[int] = None
+ resolved_at: Optional[datetime] = None
+ resolved_by_id: Optional[int] = None
+
+ commit_id: Optional[str] = None
+ noteable_id: Optional[int] = None
+ discussion_id: Optional[str] = None
+ type: Optional[CommentType] = None
+
+ system: Optional[bool] = None
+ line_code: Optional[str] = None
+ st_diff: Optional[GitlabStDiff] = None
+ attachment: Optional[str] = None
+ position: Optional[int] = None
+ original_position: Optional[int] = None
+
+
+@dataclass
+class GitlabMergeRequestAttributes(SerializableAttrs):
+ id: int
+ merge_request_id: int = attr.ib(metadata={"json": "iid"})
+ target_branch: str
+ source_branch: str
+ source: GitlabProject
+ source_project_id: int
+ target: GitlabProject
+ target_project_id: int
+ last_commit: GitlabCommit
+ author_id: int
+ title: str
+ description: str
+ work_in_progress: bool
+ url: str
+ state: str
+
+ created_at: datetime
+ updated_at: datetime
+ updated_by_id: Optional[int] = None
+ last_edited_at: Optional[datetime] = None
+ last_edited_by_id: Optional[int] = None
+
+ merge_commit_sha: Optional[str] = None
+ merge_error: Optional[str] = None
+ merge_status: Optional[str] = None
+ merge_user_id: Optional[int] = None
+ merge_when_pipeline_succeeds: Optional[bool] = False
+
+ time_estimate: Optional[int] = None
+ total_time_spent: Optional[int] = None
+ human_time_estimate: Optional[str] = None
+ human_total_time_spent: Optional[str] = None
+
+ head_pipeline_id: Optional[int] = None
+ milestone_id: Optional[int] = None
+ assignee_id: Optional[int] = None
+ assignee_ids: Optional[List[int]] = None
+ assignee: Optional[GitlabUser] = None
+ action: Optional[Action] = None
+
+
+@dataclass
+class GitlabWikiPageAttributes(SerializableAttrs):
+ title: str
+ content: str
+ format: str
+ slug: str
+ url: str
+ action: Action
+ message: Optional[str] = None
+
+
+@dataclass
+class GitlabVariable(SerializableAttrs):
+ key: str
+ value: str
+
+
+@dataclass
+class GitlabPipelineAttributes(SerializableAttrs):
+ id: int
+ ref: str
+ tag: bool
+ sha: str
+ before_sha: str
+ source: str
+ status: str
+ stages: List[str]
+
+ created_at: datetime
+ finished_at: datetime
+ duration: int
+ variables: List[GitlabVariable]
+
+
+@dataclass
+class GitlabArtifact(SerializableAttrs):
+ filename: str
+ size: int
+
+
+@dataclass
+class GitlabWiki(SerializableAttrs):
+ web_url: str
+ git_ssh_url: str
+ git_http_url: str
+ path_with_namespace: str
+ default_branch: str
+
+
+@dataclass
+class GitlabMergeRequest(SerializableAttrs):
+ id: int
+ merge_request_id: int = attr.ib(metadata={"json": "iid"})
+ target_branch: str
+ source_branch: str
+ source_project_id: int
+ assignee_id: int
+ author_id: int
+ title: str
+ created_at: datetime
+ updated_at: datetime
+ milestone_id: int
+ state: str
+ merge_status: str
+ target_project_id: int
+ description: str
+ source: GitlabSource
+ target: GitlabTarget
+ last_commit: GitlabCommit
+ work_in_progress: bool
+ position: Optional[int] = None
+ locked_at: Optional[datetime] = None
+ assignee: Optional[GitlabUser] = None
+
+
+class BuildStatus(ExtensibleEnum):
+ CREATED = "created"
+ PENDING = "pending"
+ RUNNING = "running"
+ SUCCESS = "success"
+ FAILED = "failed"
+ CANCELED = "canceled"
+
+ @property
+ def color_circle(self) -> str:
+ return _build_status_circles[self]
+
+
+_build_status_circles: Dict[BuildStatus, str] = {
+ BuildStatus.CREATED: "🟡",
+ BuildStatus.RUNNING: "🔵",
+ BuildStatus.SUCCESS: "🟢",
+ BuildStatus.FAILED: "🔴",
+ BuildStatus.CANCELED: "⚫️",
+}
+
+
+class FailureReason(ExtensibleEnum):
+ UNKNOWN = "unknown_failure"
+ SCRIPT = "script_failure"
+
+
+@dataclass
+class GitlabJobCommit(BaseCommit, SerializableAttrs):
+ author_email: str
+ author_name: str
+ author_url: Optional[str]
+
+ id: int
+ sha: str
+ status: BuildStatus
+ started_at: Optional[datetime]
+ finished_at: Optional[datetime]
+ duration: Optional[int]
+
+
+@dataclass
+class GitlabBuild(SerializableAttrs):
+ id: int
+ stage: str
+ name: str
+ status: BuildStatus
+ created_at: datetime
+ started_at: datetime
+ finished_at: datetime
+ when: str
+ manual: bool
+ user: GitlabUser
+ runner: str
+ artifacts_file: GitlabArtifact
+
+
+@dataclass
+class GitlabEvent:
+ def preprocess(self) -> List["GitlabEvent"]:
+ return [self]
+
+ @property
+ def template_name(self) -> str:
+ raise TemplateNotFound("")
+
+ @property
+ def meta(self) -> JSON:
+ return {}
+
+ @property
+ def event_properties(self) -> Iterable[str]:
+ return []
+
+ @property
+ def message_id(self) -> Optional[str]:
+ return None
+
+
+@dataclass
+class GitlabPushEvent(SerializableAttrs, GitlabEvent):
+ object_kind: str
+ before: str
+ after: str
+ ref: str
+ checkout_sha: str
+ message: Optional[str]
+ user_id: int
+ user_name: str
+ user_username: str
+ user_email: str
+ user_avatar: str
+ project_id: int
+ project: GitlabProject
+ repository: GitlabRepository
+ commits: List[GitlabCommit]
+ total_commits_count: int
+
+ @property
+ def user(self) -> GitlabUser:
+ return GitlabUser(
+ id=self.user_id,
+ name=self.user_name,
+ email=self.user_email,
+ username=self.user_username,
+ avatar_url=self.user_avatar,
+ web_url=f"{self.project.gitlab_base_url}/{self.user_username}",
+ )
+
+ @property
+ def template_name(self) -> str:
+ return "tag" if self.ref_type == "tag" else "push"
+
+ @property
+ def event_properties(self) -> Iterable[str]:
+ return (
+ "user",
+ "is_new_ref",
+ "is_deleted_ref",
+ "ref_name",
+ "ref_type",
+ "ref_url",
+ "diff_url",
+ )
+
+ @property
+ def diff_url(self) -> str:
+ before = self.project.default_branch if self.is_new_ref else self.before
+ after = self.after
+ return f"{self.project.web_url}/-/compare/{before}...{after}"
+
+ @property
+ def is_new_ref(self) -> bool:
+ return self.before == "0" * len(self.before)
+
+ @property
+ def is_deleted_ref(self) -> bool:
+ return self.after == "0" * len(self.after)
+
+ @property
+ def ref_type(self) -> str:
+ if self.ref.startswith("refs/heads/"):
+ return "branch"
+ elif self.ref.startswith("refs/tags/"):
+ return "tag"
+ else:
+ return "ref"
+
+ @property
+ def ref_name(self) -> str:
+ return self.ref.split("/", 2)[2]
+
+ @property
+ def ref_url(self) -> Optional[str]:
+ if self.ref.startswith("refs/heads/"):
+ return f"{self.project.web_url}/-/tree/{self.ref_name}"
+ elif self.ref.startswith("refs/tags/"):
+ return f"{self.project.web_url}/-/tags/{self.ref_name}"
+ else:
+ return None
+
+ @property
+ def message_id(self) -> str:
+ return f"push-{self.project_id}-{self.checkout_sha}-{self.ref_name}"
+
+
+def split_updates(
+ evt: Union["GitlabIssueEvent", "GitlabMergeRequestEvent"]
+) -> List[GitlabEvent]:
+ if not evt.changes:
+ return [evt]
+ output = []
+ # We don't want to handle multiple issue change types in a single Matrix message,
+ # so split each change into a separate event.
+ for field in attr.fields(GitlabChanges):
+ value = getattr(evt.changes, field.name)
+ if value:
+ output.append(
+ attr.evolve(evt, changes=GitlabChanges(**{field.name: value}))
+ )
+ return output
+
+
+@dataclass
+class GitlabIssueEvent(SerializableAttrs, GitlabEvent):
+ object_kind: str
+ user: GitlabUser
+ project: GitlabProject
+ repository: GitlabRepository
+ object_attributes: GitlabIssueAttributes
+ assignees: Optional[List[GitlabUser]] = None
+ labels: Optional[List[GitlabLabel]] = None
+ changes: Optional[GitlabChanges] = None
+
+ def preprocess(self) -> List["GitlabIssueEvent"]:
+ users_to_mutate = [self.user]
+ if self.changes and self.changes.assignees:
+ users_to_mutate += self.changes.assignees.previous
+ users_to_mutate += self.changes.assignees.current
+ if self.assignees:
+ users_to_mutate += self.assignees
+ for user in users_to_mutate:
+ user.web_url = f"{self.project.gitlab_base_url}/{user.username}"
+
+ return split_updates(self) if self.action == Action.UPDATE else [self]
+
+ @property
+ def template_name(self) -> str:
+ return f"issue_{self.action.key.lower()}"
+
+ @property
+ def event_properties(self) -> Iterable[str]:
+ return ("action",)
+
+ @property
+ def action(self) -> Action:
+ return self.object_attributes.action
+
+
+@dataclass
+class GitlabCommentEvent(SerializableAttrs, GitlabEvent):
+ object_kind: str
+ user: GitlabUser
+ project_id: int
+ project: GitlabProject
+ repository: GitlabRepository
+ object_attributes: GitlabCommentAttributes
+ merge_request: Optional[GitlabMergeRequest] = None
+ commit: Optional[GitlabCommit] = None
+ issue: Optional[GitlabIssue] = None
+ snippet: Optional[GitlabSnippet] = None
+
+ def preprocess(self) -> List["GitlabCommentEvent"]:
+ self.user.web_url = f"{self.project.gitlab_base_url}/{self.user.username}"
+ return [self]
+
+ @property
+ def template_name(self) -> str:
+ return "comment"
+
+
+@dataclass
+class GitlabMergeRequestEvent(SerializableAttrs, GitlabEvent):
+ object_kind: str
+ user: GitlabUser
+ project: GitlabProject
+ repository: GitlabRepository
+ object_attributes: GitlabMergeRequestAttributes
+ labels: List[GitlabLabel]
+ changes: GitlabChanges
+
+ def preprocess(self) -> List["GitlabMergeRequestEvent"]:
+ users_to_mutate = [self.user]
+ if self.changes and self.changes.assignees:
+ users_to_mutate += self.changes.assignees.previous
+ users_to_mutate += self.changes.assignees.current
+ for user in users_to_mutate:
+ user.web_url = f"{self.project.gitlab_base_url}/{user.username}"
+
+ return split_updates(self) if self.action == Action.UPDATE else [self]
+
+ @property
+ def template_name(self) -> str:
+ return "issue_update" if self.action == Action.UPDATE else "merge_request"
+
+ @property
+ def event_properties(self) -> Iterable[str]:
+ return ("action",)
+
+ @property
+ def action(self) -> Action:
+ return self.object_attributes.action
+
+
+@dataclass
+class GitlabWikiPageEvent(SerializableAttrs, GitlabEvent):
+ object_kind: str
+ user: GitlabUser
+ project: GitlabProject
+ wiki: GitlabWiki
+ object_attributes: GitlabWikiPageAttributes
+
+ def preprocess(self) -> List["GitlabWikiPageEvent"]:
+ self.user.web_url = f"{self.project.gitlab_base_url}/{self.user.username}"
+ return [self]
+
+ @property
+ def template_name(self) -> str:
+ return "wiki"
+
+
+@dataclass
+class GitlabPipelineEvent(SerializableAttrs, GitlabEvent):
+ object_kind: str
+ object_attributes: GitlabPipelineAttributes
+ user: GitlabUser
+ project: GitlabProject
+ commit: GitlabCommit
+ builds: List[GitlabBuild]
+
+ @property
+ def message_id(self) -> str:
+ return f"pipeline-{self.object_attributes.id}"
+
+
+@dataclass
+class GitlabRunner(SerializableAttrs):
+ active: bool
+ description: str
+ id: int
+ tags: List[str]
+
+
+@dataclass
+class GitlabJobEvent(SerializableAttrs, GitlabEvent):
+ object_kind: str
+ ref: str
+ tag: str
+ before_sha: str
+ sha: str
+ pipeline_id: int
+ build_id: int
+ build_name: str
+ build_stage: str
+ build_status: BuildStatus
+ build_started_at: datetime
+ build_finished_at: datetime
+ build_duration: int
+ build_allow_failure: bool
+ build_failure_reason: FailureReason
+ project_id: int
+ project_name: str
+ user: GitlabUser
+ commit: GitlabJobCommit
+ repository: GitlabRepository
+ runner: Optional[GitlabRunner]
+
+ def preprocess(self) -> List["GitlabJobEvent"]:
+ base_url = str(URL(self.repository.homepage).with_path(""))
+ self.user.web_url = f"{base_url}/{self.user.username}"
+ return [self]
+
+ @property
+ def template_name(self) -> str:
+ return "job"
+
+ @property
+ def push_id(self) -> str:
+ return f"push-{self.project_id}-{self.sha}-{self.ref}"
+
+ @property
+ def reaction_id(self) -> str:
+ return f"job-{self.project_id}-{self.sha}-{self.ref}-{self.build_name}"
+
+ @property
+ def meta(self) -> JSON:
+ return {
+ "build": {
+ "pipeline_id": self.pipeline_id,
+ "id": self.build_id,
+ "name": self.build_name,
+ "stage": self.build_stage,
+ "status": self.build_status.value,
+ "url": self.build_url,
+ },
+ }
+
+ @property
+ def event_properties(self) -> Iterable[str]:
+ return ("build_url",)
+
+ @property
+ def build_url(self) -> str:
+ return f"{self.repository.homepage}/-/jobs/{self.build_id}"
+
+
+GitlabEventType = Union[
+ Type[GitlabPushEvent],
+ Type[GitlabIssueEvent],
+ Type[GitlabCommentEvent],
+ Type[GitlabMergeRequestEvent],
+ Type[GitlabWikiPageEvent],
+ Type[GitlabPipelineEvent],
+ Type[GitlabJobEvent],
+]
+
+EventParse: Dict[str, GitlabEventType] = {
+ "Push Hook": GitlabPushEvent,
+ "Tag Push Hook": GitlabPushEvent,
+ "Issue Hook": GitlabIssueEvent,
+ "Confidential Issue Hook": GitlabIssueEvent,
+ "Note Hook": GitlabCommentEvent,
+ "Confidential Note Hook": GitlabCommentEvent,
+ "Merge Request Hook": GitlabMergeRequestEvent,
+ "Wiki Page Hook": GitlabWikiPageEvent,
+ "Pipeline Hook": GitlabPipelineEvent,
+ "Job Hook": GitlabJobEvent,
+}
+
+OTHER_ENUMS = {
+ "NoteableType": NoteableType,
+ "CommentType": CommentType,
+ "BuildStatus": BuildStatus,
+ "FailureReason": FailureReason,
+}
diff --git a/ops_bot/main.py b/ops_bot/main.py
index c3999b0..148f9ee 100644
--- a/ops_bot/main.py
+++ b/ops_bot/main.py
@@ -1,30 +1,28 @@
import asyncio
-import json
import logging
-from typing import Any, Dict, Literal, Optional, Tuple, cast
+from typing import Any, Dict, List, Optional, Protocol, Tuple, cast
import uvicorn
+from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Request, status
-from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
-from pydantic import BaseSettings
+from fastapi.security import (
+ HTTPAuthorizationCredentials,
+ HTTPBasic,
+ HTTPBasicCredentials,
+ HTTPBearer,
+)
from ops_bot import aws, pagerduty
-from ops_bot.matrix import MatrixClient, MatrixClientSettings
+from ops_bot.config import BotSettings, RoutingKey, load_config
+from ops_bot.gitlab import hook as gitlab_hook
+from ops_bot.matrix import MatrixClient
-
-class BotSettings(BaseSettings):
- bearer_token: str
- routing_keys: Dict[str, str]
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
-
- class Config:
- env_prefix = "BOT_"
- secrets_dir = "/run/secrets"
- case_sensitive = False
+load_dotenv()
app = FastAPI()
-security = HTTPBearer()
+bearer_security = HTTPBearer(auto_error=False)
+basic_security = HTTPBasic(auto_error=False)
async def get_matrix_service(request: Request) -> MatrixClient:
@@ -40,11 +38,8 @@ async def matrix_main(matrix_client: MatrixClient) -> None:
@app.on_event("startup")
async def startup_event() -> None:
- bot_settings = BotSettings(_env_file=".env", _env_file_encoding="utf-8")
- 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 = load_config()
+ c = MatrixClient(settings=bot_settings.matrix, join_rooms=bot_settings.get_rooms())
app.state.matrix_client = c
app.state.bot_settings = bot_settings
asyncio.create_task(matrix_main(c))
@@ -60,65 +55,112 @@ async def root() -> Dict[str, str]:
return {"message": "Hello World"}
-def authorize(
- request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)
+async def bearer_token_authorizer(
+ route: RoutingKey,
+ request: Request,
+ basic_credentials: Optional[HTTPBasicCredentials],
+ bearer_credentials: Optional[HTTPAuthorizationCredentials],
+) -> bool:
+
+ bearer_token: Optional[str] = route.secret_token
+ return (
+ bearer_credentials is not None
+ and bearer_credentials.credentials == bearer_token
+ )
+
+
+async def nop_authorizer(
+ route: RoutingKey,
+ request: Request,
+ basic_credentials: Optional[HTTPBasicCredentials],
+ bearer_credentials: Optional[HTTPAuthorizationCredentials],
) -> bool:
- bearer_token = request.app.state.bot_settings.bearer_token
- if credentials.credentials != bearer_token:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Incorrect bearer token",
- headers={"WWW-Authenticate": "Bearer"},
- )
return True
-def get_destination(bot_settings: BotSettings, routing_key: str) -> Optional[str]:
- return bot_settings.routing_keys.get(routing_key, None)
+def get_route(bot_settings: BotSettings, path_key: str) -> Optional[RoutingKey]:
+ # find path_key in bot_settings.routing_keys
+ for route in bot_settings.routing_keys:
+ if route.path_key == path_key:
+ return route
+ return None
-async def receive_helper(request: Request) -> Tuple[str, Any]:
- payload: Any = await request.json()
- routing_key = request.path_params["routing_key"]
- room_id = get_destination(request.app.state.bot_settings, routing_key)
- if room_id is None:
+class Authorizer(Protocol):
+ async def __call__(
+ self,
+ route: RoutingKey,
+ request: Request,
+ basic_credentials: Optional[HTTPBasicCredentials],
+ bearer_credentials: Optional[HTTPAuthorizationCredentials],
+ ) -> bool:
+ ...
+
+
+class ParseHandler(Protocol):
+ async def __call__(
+ self,
+ route: RoutingKey,
+ payload: Any,
+ request: Request,
+ ) -> List[Tuple[str, str]]:
+ ...
+
+
+handlers: Dict[str, Tuple[Authorizer, ParseHandler]] = {
+ "gitlab": (gitlab_hook.authorize, gitlab_hook.parse_event),
+ "pagerduty": (bearer_token_authorizer, pagerduty.parse_pagerduty_event),
+ "aws-sns": (nop_authorizer, aws.parse_sns_event),
+}
+
+
+@app.post("/hook/{routing_key}")
+async def webhook_handler(
+ request: Request,
+ routing_key: str,
+ basic_credentials: Optional[HTTPBasicCredentials] = Depends(basic_security),
+ bearer_credentials: Optional[HTTPAuthorizationCredentials] = Depends(
+ bearer_security
+ ),
+ matrix_client: MatrixClient = Depends(get_matrix_service),
+) -> Dict[str, str]:
+ route = get_route(request.app.state.bot_settings, routing_key)
+
+ if not route:
logging.error(f"unknown routing key {routing_key}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Unknown routing key"
)
- payload_str = json.dumps(payload, sort_keys=True, indent=2)
- logging.info(f"received payload: \n {payload_str}")
- return room_id, payload
+ handler: Optional[Tuple[Authorizer, ParseHandler]] = handlers.get(route.hook_type)
+ if not handler:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Unknown hook type"
+ )
-@app.post("/hook/pagerduty/{routing_key}")
-async def pagerduty_hook(
- request: Request,
- matrix_client: MatrixClient = Depends(get_matrix_service),
- auth: bool = Depends(authorize),
-) -> Dict[str, str]:
- room_id, payload = await receive_helper(request)
- msg_plain, msg_formatted = pagerduty.parse_pagerduty_event(payload)
- await matrix_client.room_send(
- room_id,
- msg_plain,
- message_formatted=msg_formatted,
- )
- return {"message": msg_plain, "message_formatted": msg_formatted}
+ authorizer, parse_handler = handler
+ if not await authorizer(
+ route,
+ request=request,
+ bearer_credentials=bearer_credentials,
+ basic_credentials=basic_credentials,
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
+ )
-@app.post("/hook/aws-sns/{routing_key}")
-async def aws_sns_hook(
- request: Request, matrix_client: MatrixClient = Depends(get_matrix_service)
-) -> Dict[str, str]:
- room_id, payload = await receive_helper(request)
- msg_plain, msg_formatted = aws.parse_sns_event(payload)
- await matrix_client.room_send(
- room_id,
- msg_plain,
- message_formatted=msg_formatted,
- )
- return {"message": msg_plain, "message_formatted": msg_formatted}
+ payload: Any = await request.json()
+
+ messages = await parse_handler(route, payload, request=request)
+ for msg_plain, msg_formatted in messages:
+ await matrix_client.room_send(
+ route.room_id,
+ msg_plain,
+ message_formatted=msg_formatted,
+ )
+
+ return {"status": "ok"}
def start_dev() -> None:
diff --git a/ops_bot/matrix.py b/ops_bot/matrix.py
index 89fa3ae..fc8081e 100644
--- a/ops_bot/matrix.py
+++ b/ops_bot/matrix.py
@@ -46,19 +46,18 @@ class MatrixClientSettings(BaseSettings):
password: str
device_name: str
store_path: str
- join_rooms: Optional[List[str]]
verify_ssl: Optional[bool] = True
class Config:
env_prefix = "MATRIX_"
- secrets_dir = "/run/secrets"
case_sensitive = False
class MatrixClient:
- def __init__(self, settings: MatrixClientSettings):
+ def __init__(self, settings: MatrixClientSettings, join_rooms: List[str]):
self.settings = settings
self.store_path = pathlib.Path(settings.store_path)
+ self.join_rooms = join_rooms
self.credential_store = LocalCredentialStore(
self.store_path.joinpath("credentials.json")
)
@@ -79,9 +78,8 @@ class MatrixClient:
if self.client.should_upload_keys:
await self.client.keys_upload()
- if self.settings.join_rooms:
- for room in self.settings.join_rooms:
- await self.client.join(room)
+ for room in self.join_rooms:
+ await self.client.join(room)
await self.client.joined_rooms()
await self.client.sync_forever(timeout=300000, full_state=True)
diff --git a/ops_bot/pagerduty.py b/ops_bot/pagerduty.py
index 183a291..6a33baf 100644
--- a/ops_bot/pagerduty.py
+++ b/ops_bot/pagerduty.py
@@ -1,7 +1,10 @@
import json
-from typing import Any, Tuple
+from typing import Any, List, Tuple
+
+from fastapi import Request
from ops_bot.common import COLOR_ALARM, COLOR_UNKNOWN
+from ops_bot.config import RoutingKey
def urgency_color(urgency: str) -> str:
@@ -11,7 +14,11 @@ def urgency_color(urgency: str) -> str:
return COLOR_UNKNOWN
-def parse_pagerduty_event(payload: Any) -> Tuple[str, str]:
+async def parse_pagerduty_event(
+ route: RoutingKey,
+ payload: Any,
+ request: Request,
+) -> List[Tuple[str, str]]:
"""
Parses a pagerduty webhook v3 event into a human readable message.
Returns a tuple where the first item is plain text, and the second item is matrix html formatted text
@@ -37,12 +44,14 @@ def parse_pagerduty_event(payload: Any) -> Tuple[str, str]:
else:
color = urgency_color(urgency)
formatted = f"{header_str} on {service_name}: [{title}]({url})"
- return plain, formatted
+ return [(plain, formatted)]
payload_str = json.dumps(payload, sort_keys=True, indent=2)
- return (
- "unhandled",
- f"""**unhandled pager duty event** (this may or may not be a critical problem, please look carefully)
+ return [
+ (
+ "unhandled",
+ f"""**unhandled pager duty event** (this may or may not be a critical problem, please look carefully)
{payload_str}
""",
- )
+ )
+ ]
diff --git a/ops_bot/util/contrast.py b/ops_bot/util/contrast.py
new file mode 100644
index 0000000..183e11b
--- /dev/null
+++ b/ops_bot/util/contrast.py
@@ -0,0 +1,62 @@
+# Based on https://github.com/gsnedders/wcag-contrast-ratio
+# Copyright (c) 2015 Geoffrey Sneddon
+# Copyright (c) 2019 Tulir Asokan
+# MIT license
+from typing import Tuple
+
+RGB = Tuple[float, float, float]
+
+
+def hex_to_rgb(color: str) -> RGB:
+ color = color.lstrip("#")
+ if len(color) != 3 and len(color) != 6:
+ raise ValueError("Invalid hex length")
+ step = 1 if len(color) == 3 else 2
+ try:
+ r = int(color[0:step], 16)
+ g = int(color[step : 2 * step], 16)
+ b = int(color[2 * step : 3 * step], 16)
+ except ValueError as e:
+ raise ValueError("Invalid hex value") from e
+ return r / 255, g / 255, b / 255
+
+
+def rgb_to_hex(rgb: RGB) -> str:
+ r, g, b = rgb
+ r = int(r * 255)
+ g = int(g * 255)
+ b = int(b * 255)
+ return f"{r:02x}{g:02x}{b:02x}"
+
+
+def contrast(rgb1: RGB, rgb2: RGB) -> float:
+ for r, g, b in (rgb1, rgb2):
+ if not 0.0 <= r <= 1.0:
+ raise ValueError(f"r {r} is out of valid range (0.0 - 1.0)")
+ if not 0.0 <= g <= 1.0:
+ raise ValueError(f"g {g} is out of valid range (0.0 - 1.0)")
+ if not 0.0 <= b <= 1.0:
+ raise ValueError(f"b {b} is out of valid range (0.0 - 1.0)")
+
+ l1 = _relative_luminance(*rgb1)
+ l2 = _relative_luminance(*rgb2)
+
+ if l1 > l2:
+ return (l1 + 0.05) / (l2 + 0.05)
+ else:
+ return (l2 + 0.05) / (l1 + 0.05)
+
+
+def _relative_luminance(r: float, g: float, b: float) -> float:
+ r = _linearize(r)
+ g = _linearize(g)
+ b = _linearize(b)
+
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+
+
+def _linearize(v: float) -> float:
+ if v <= 0.03928:
+ return v / 12.92
+ else:
+ return float(((v + 0.055) / 1.055) ** 2.4)
diff --git a/ops_bot/util/markdown.py b/ops_bot/util/markdown.py
new file mode 100644
index 0000000..79d9ed6
--- /dev/null
+++ b/ops_bot/util/markdown.py
@@ -0,0 +1,38 @@
+# # 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/.
+from typing import Any
+
+import commonmark
+
+
+class HtmlEscapingRenderer(commonmark.HtmlRenderer):
+ def __init__(self, allow_html: bool = False):
+ super().__init__()
+ self.allow_html = allow_html
+
+ def lit(self, s: str) -> None:
+ if self.allow_html:
+ return super().lit(s)
+ return super().lit(s.replace("<", "<").replace(">", ">"))
+
+ def image(self, node: Any, entering: Any) -> None:
+ 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) # type: ignore
+ if allow_html:
+ return yes_html_renderer.render(parsed) # type: ignore
+ else:
+ return no_html_renderer.render(parsed) # type: ignore
diff --git a/ops_bot/util/template.py b/ops_bot/util/template.py
new file mode 100644
index 0000000..1a3e060
--- /dev/null
+++ b/ops_bot/util/template.py
@@ -0,0 +1,157 @@
+# 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 .
+import os.path
+from typing import Any, Callable, Dict, List, Tuple, Union
+
+from jinja2 import BaseLoader
+from jinja2 import Environment as JinjaEnvironment
+from jinja2 import Template, TemplateNotFound
+
+from ops_bot.util import markdown
+
+
+def sync_read_file(path: str) -> str:
+ with open(path) as file:
+ return file.read()
+
+
+def sync_list_files(directory: str) -> list[str]:
+ return os.listdir(directory)
+
+
+class TemplateUtil:
+ @staticmethod
+ def bold_scope(label: str) -> str:
+ try:
+ scope, label = label.rsplit("::", 1)
+ return f"{scope}::{label}"
+ except ValueError:
+ return label
+
+ @staticmethod
+ def pluralize(val: int, unit: str) -> str:
+ if val == 1:
+ return f"{val} {unit}"
+ return f"{val} {unit}s"
+
+ @classmethod
+ def format_time(cls, seconds: Union[int, float], enable_days: bool = False) -> str:
+ seconds = abs(seconds)
+ frac_seconds = round(seconds - int(seconds), 1)
+ minutes, seconds = divmod(int(seconds), 60)
+ hours, minutes = divmod(minutes, 60)
+ if enable_days:
+ days, hours = divmod(hours, 24)
+ else:
+ days = 0
+ parts = []
+ if days > 0:
+ parts.append(cls.pluralize(days, "day"))
+ if hours > 0:
+ parts.append(cls.pluralize(hours, "hour"))
+ if minutes > 0:
+ parts.append(cls.pluralize(minutes, "minute"))
+ if seconds > 0 or len(parts) == 0:
+ parts.append(cls.pluralize(int(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) -> List[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( # nosec B701
+ loader=self._loader,
+ lstrip_blocks=True,
+ trim_blocks=True,
+ autoescape=False,
+ extensions=["jinja2.ext.do"],
+ )
+ self._env.filters["markdown"] = lambda message: markdown.render(
+ message, allow_html=True
+ )
+
+ def __getitem__(self, item: str) -> Template:
+ return self._env.get_template(item)
+
+ def proxy(self, args: Dict[str, Any]) -> TemplateProxy:
+ return TemplateProxy(self._env, args)
diff --git a/poetry.lock b/poetry.lock
index f30d670..d6857c7 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -187,6 +187,17 @@ category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+[[package]]
+name = "commonmark"
+version = "0.9.1"
+description = "Python parser for the CommonMark Markdown spec"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
+
[[package]]
name = "exceptiongroup"
version = "1.0.4"
@@ -365,6 +376,20 @@ requirements_deprecated_finder = ["pipreqs", "pip-api"]
colors = ["colorama (>=0.4.3,<0.5.0)"]
plugins = ["setuptools"]
+[[package]]
+name = "jinja2"
+version = "3.1.2"
+description = "A very fast and expressive template engine."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
[[package]]
name = "jsonschema"
version = "3.2.0"
@@ -415,6 +440,14 @@ importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
[package.extras]
testing = ["coverage", "pyyaml"]
+[[package]]
+name = "markupsafe"
+version = "2.1.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
[[package]]
name = "matrix-nio"
version = "0.19.0"
@@ -442,6 +475,25 @@ unpaddedbase64 = ">=2.1.0,<3.0.0"
[package.extras]
e2e = ["python-olm (>=3.1.3,<4.0.0)", "peewee (>=3.14.4,<4.0.0)", "cachetools (>=4.2.1,<5.0.0)", "atomicwrites (>=1.4.0,<2.0.0)"]
+[[package]]
+name = "mautrix"
+version = "0.18.8"
+description = "A Python 3 asyncio Matrix framework."
+category = "main"
+optional = false
+python-versions = "~=3.8"
+
+[package.dependencies]
+aiohttp = ">=3,<4"
+attrs = ">=18.1.0"
+yarl = ">=1.5,<2"
+
+[package.extras]
+detect_mimetype = ["python-magic (>=0.4.15,<0.5)"]
+encryption = ["pycryptodome", "python-olm", "unpaddedbase64"]
+lint = ["black (==22.1.0)", "isort"]
+test = ["aiosqlite", "asyncpg", "pycryptodome", "pytest", "pytest-asyncio", "python-olm", "sqlalchemy", "unpaddedbase64"]
+
[[package]]
name = "mccabe"
version = "0.7.0"
@@ -631,6 +683,20 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+[[package]]
+name = "pytest-asyncio"
+version = "0.20.2"
+description = "Pytest support for asyncio"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+pytest = ">=6.1.0"
+
+[package.extras]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
+
[[package]]
name = "python-dotenv"
version = "0.21.0"
@@ -745,6 +811,14 @@ category = "dev"
optional = false
python-versions = ">=3.7"
+[[package]]
+name = "types-commonmark"
+version = "0.9.2"
+description = "Typing stubs for commonmark"
+category = "dev"
+optional = false
+python-versions = "*"
+
[[package]]
name = "types-markdown"
version = "3.4.2.1"
@@ -819,7 +893,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 = "45b4c6a8c5743ab30f7975df8fd7f47e15eb8cc719da9b5c16fd0e145d14a359"
[metadata.files]
aiofiles = [
@@ -1047,6 +1121,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 +1260,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 +1283,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 +1567,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"},
@@ -1519,6 +1651,10 @@ tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
+types-commonmark = [
+ {file = "types-commonmark-0.9.2.tar.gz", hash = "sha256:b894b67750c52fd5abc9a40a9ceb9da4652a391d75c1b480bba9cef90f19fc86"},
+ {file = "types_commonmark-0.9.2-py3-none-any.whl", hash = "sha256:56f20199a1f9a2924443211a0ef97f8b15a8a956a7f4e9186be6950bf38d6d02"},
+]
types-markdown = [
{file = "types-Markdown-3.4.2.1.tar.gz", hash = "sha256:03c0904cf5886a7d8193e2f50bcf842afc89e0ab80f060f389f6c2635c65628f"},
{file = "types_Markdown-3.4.2.1-py3-none-any.whl", hash = "sha256:b2333f6f4b8f69af83de359e10a097e4a3f14bbd6d2484e1829d9b0ec56fa0cb"},
diff --git a/pyproject.toml b/pyproject.toml
index 2051361..36e6f90 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,10 @@ 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"
+click = "^8.1.3"
[tool.poetry.dev-dependencies]
pytest = "^7.2.0"
@@ -23,6 +27,8 @@ flake8 = "^6.0.0"
flake8-black = "^0.3.5"
types-Markdown = "^3.4.0"
types-termcolor = "^1.1.5"
+pytest-asyncio = "^0.20.2"
+types-commonmark = "^0.9.2"
[build-system]
requires = ["poetry-core>=1.0.0"]
@@ -30,6 +36,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
start = "ops_bot.main:start_dev"
+config = "ops_bot.cli:cli"
[tool.black]
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..2f4c80e
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+asyncio_mode = auto
diff --git a/templates/gitlab/macros.html b/templates/gitlab/macros.html
new file mode 100644
index 0000000..234f7c0
--- /dev/null
+++ b/templates/gitlab/macros.html
@@ -0,0 +1,75 @@
+{%- macro repo_link(project) -%}
+ {{ project.path_with_namespace|e }}
+{%- endmacro -%}
+{%- macro user_link(user) -%}
+ {{ user.username|e }}
+{%- endmacro -%}
+
+{%- macro pluralize(value) -%}
+ {% if value != 1 %}s{% endif %}
+{%- endmacro -%}
+
+{%- macro issue_link(issue, title = true, important = true) -%}
+
+ {%- if issue.confidential and (not changes or not changes.confidential) -%}confidential {% endif -%}
+ issue #{{ issue.issue_id -}}
+
+ {%- if title -%}
+ : {{ issue.title|e -}}
+ {% endif %}
+{%- endmacro -%}
+
+{%- macro merge_request_link(merge_request, title = true, important = true) -%}
+
+ merge request !{{ merge_request.merge_request_id -}}
+
+ {%- if title -%}
+ : {{ merge_request.title|e -}}
+ {% endif %}
+{%- endmacro -%}
+
+{%- macro issue_or_merge_link(attrs, title = true, important = true) -%}
+ {%- if attrs.issue_id -%}
+ {{- issue_link(attrs, title, important) -}}
+ {%- elif attrs.merge_request_id -%}
+ {{- merge_request_link(attrs, title, important) -}}
+ {%- else -%}
+ unknown object {{ attrs.title|e }}
+ {%- endif -%}
+{%- endmacro -%}
+
+{%- macro fancy_label(label) -%}
+ {{ util.bold_scope(label.title) }}
+{%- endmacro -%}
+
+{%- macro fancy_labels(labels) -%}
+ {% for label in labels %}
+ {{ fancy_label(label) }}
+ {% endfor %}
+{%- endmacro -%}
+
+{%- macro list_changes(added, removed, add_word, remove_word, mutate) -%}
+ {%- if added -%}
+ {{ add_word }} {{ util.join_human_list(added, mutate=mutate) }}
+ {% if not removed %}
+ to
+ {% endif %}
+ {%- endif -%}
+ {%- if removed -%}
+ {%- if added %}
+ and
+ {% endif -%}
+ {{ remove_word }} {{ util.join_human_list(removed, mutate=mutate) }}
+ from
+ {%- endif -%}
+{%- endmacro -%}
+
+{%- macro label_changes(added, removed) -%}
+ {{ list_changes(added, removed, "added", "removed", fancy_label) }}
+{%- endmacro -%}
+{%- macro assignee_changes(added, removed) -%}
+ {{ list_changes(added, removed, "assigned", "unassigned", user_link) }}
+{%- endmacro -%}
diff --git a/templates/gitlab/messages/comment.html b/templates/gitlab/messages/comment.html
new file mode 100644
index 0000000..be88092
--- /dev/null
+++ b/templates/gitlab/messages/comment.html
@@ -0,0 +1,20 @@
+{{ templates.repo_sender_prefix }}
+
+ {%- if object_attributes.type == CommentType.DISCUSSION_NOTE -%}
+ replied to a thread
+ {%- else -%}
+ commented
+ {%- endif -%}
+ on
+{% if issue and object_attributes.noteable_type == NoteableType.ISSUE %}
+ {{ issue_link(issue, important=false) }}
+{% elif merge_request and object_attributes.noteable_type == NoteableType.MERGE_REQUEST %}
+ {{ merge_request_link(merge_request, important=false) }}
+{% else %}
+ {# unsupported comment target #}
+ {% do abort() %}
+{% endif %}
+
+{% if object_attributes.description %}
+ {{ object_attributes.description|markdown }}
+{% endif %}
diff --git a/templates/gitlab/messages/issue_close.html b/templates/gitlab/messages/issue_close.html
new file mode 100644
index 0000000..2a3f78f
--- /dev/null
+++ b/templates/gitlab/messages/issue_close.html
@@ -0,0 +1,2 @@
+{{ templates.repo_sender_prefix }}
+ closed {{ issue_link(object_attributes) }}
diff --git a/templates/gitlab/messages/issue_open.html b/templates/gitlab/messages/issue_open.html
new file mode 100644
index 0000000..fbebee3
--- /dev/null
+++ b/templates/gitlab/messages/issue_open.html
@@ -0,0 +1,6 @@
+{{ templates.repo_sender_prefix }}
+ opened {{ issue_link(object_attributes) }}
+{% if object_attributes.description %}
+ {{ object_attributes.description|markdown }}
+{% endif %}
+{{ fancy_labels(object_attributes.labels) }}
diff --git a/templates/gitlab/messages/issue_reopen.html b/templates/gitlab/messages/issue_reopen.html
new file mode 100644
index 0000000..8615afe
--- /dev/null
+++ b/templates/gitlab/messages/issue_reopen.html
@@ -0,0 +1,2 @@
+{{ templates.repo_sender_prefix }}
+ reopened {{ issue_link(object_attributes) }}
diff --git a/templates/gitlab/messages/issue_update.html b/templates/gitlab/messages/issue_update.html
new file mode 100644
index 0000000..5377fbe
--- /dev/null
+++ b/templates/gitlab/messages/issue_update.html
@@ -0,0 +1,77 @@
+{{ templates.repo_sender_prefix }}
+{% if changes.labels %}
+ {{ label_changes(changes.labels.added, changes.labels.removed) }}
+ {{ issue_or_merge_link(object_attributes) }}
+{# Milestone webhooks don't have the milestone displayname 3:< #}
+{#{% elif changes.milestone_id %}#}
+{# {% if not changes.milestone_id.current %}#}
+{# removed the milestone from {{ issue_or_merge_link(object_attributes) }}#}
+{# {% else %}#}
+{# added {{ issue_or_merge_link(object_attributes) }} to milestone#}
+{# {% endif %}#}
+{% elif changes.assignees %}
+ {{ assignee_changes(changes.assignees.added, changes.assignees.removed) }}
+ {{ issue_or_merge_link(object_attributes) }}
+{% elif changes.time_estimate %}
+ {% if not changes.time_estimate.current %}
+ removed the time estimate of {{ issue_or_merge_link(object_attributes) }}
+ {% elif not changes.time_estimate.previous %}
+ set the time estimate of {{ issue_or_merge_link(object_attributes) }} to
+ {{ util.format_time(changes.time_estimate.current) }}
+ {% else %}
+ {% if changes.time_estimate.current > changes.time_estimate.previous %}
+ increased
+ {% else %}
+ decreased
+ {% endif %}
+ the time estimate of {{ issue_or_merge_link(object_attributes) }} by
+ {{ util.format_time(changes.time_estimate.current - changes.time_estimate.previous) }}
+ {% endif %}
+{% elif changes.total_time_spent %}
+ {% if not changes.total_time_spent.current %}
+ removed the time spent
+ {% else %}
+ {% if changes.total_time_spent.current > (changes.total_time_spent.previous or 0) %}
+ spent {{ util.format_time(changes.total_time_spent.current - (changes.total_time_spent.previous or 0)) }}
+ {% else %}
+ subtracted {{ util.format_time(changes.total_time_spent.current - changes.total_time_spent.previous) }}
+ from the time spent
+ {% endif %}
+ {% endif %}
+ on {{ issue_or_merge_link(object_attributes) }}
+{% elif changes.weight %}
+ {% if not changes.weight.current %}
+ removed
+ {% else %}
+ changed
+ {% endif %}
+ the weight of {{ issue_or_merge_link(object_attributes) }}
+ {% if changes.weight.current %}
+ to {{ changes.weight.current }}
+ {% endif %}
+{% elif changes.due_date %}
+ {% if not changes.due_date.current %}
+ removed the due date of {{ issue_or_merge_link(object_attributes) }}
+ {% else %}
+ set the due date of {{ issue_or_merge_link(object_attributes) }}
+ to {{ changes.due_date.current.strftime("%B %d, %Y") }}
+ {% endif %}
+{% elif changes.confidential %}
+ made {{ issue_or_merge_link(object_attributes) }}
+ {% if changes.confidential.current %}
+ confidential
+ {% else %}
+ non-confidential
+ {% endif %}
+{% elif changes.discussion_locked %}
+ {% if changes.discussion_locked.current %}
+ locked discussion in
+ {% else %}
+ unlocked discussion in
+ {% endif %}
+ {{ issue_or_merge_link(object_attributes) }}
+{% elif changes.title %}
+ changed the title of {{ issue_or_merge_link(object_attributes, title=false) }} to {{ changes.title.current }}
+{% else %}
+ {% do abort() %}
+{% endif %}
diff --git a/templates/gitlab/messages/job.html b/templates/gitlab/messages/job.html
new file mode 100644
index 0000000..5a7abba
--- /dev/null
+++ b/templates/gitlab/messages/job.html
@@ -0,0 +1,9 @@
+{% if build_status == BuildStatus.FAILED and not build_allow_failure %}
+ [{{ repository.path|e }}]
+ Job {{ build_id }}: {{ build_name }}
+ failed
+ after {{ util.format_time(build_duration) }}
+ (build triggered by {{ user_link(user) }})
+{% else %}
+ {% do abort() %}
+{% endif %}
diff --git a/templates/gitlab/messages/merge_request.html b/templates/gitlab/messages/merge_request.html
new file mode 100644
index 0000000..0b3dffc
--- /dev/null
+++ b/templates/gitlab/messages/merge_request.html
@@ -0,0 +1,11 @@
+{{ templates.repo_sender_prefix }}
+{% if action == OPEN %}
+ opened {{ merge_request_link(object_attributes) }}
+ {% if object_attributes.description %}
+ {{ object_attributes.description|markdown }}
+ {% endif %}
+ {{ fancy_labels(labels) }}
+{% else %}
+ {{ object_attributes.action.past_tense }}
+ {{ merge_request_link(object_attributes) }}
+{% endif %}
diff --git a/templates/gitlab/messages/push.html b/templates/gitlab/messages/push.html
new file mode 100644
index 0000000..5285023
--- /dev/null
+++ b/templates/gitlab/messages/push.html
@@ -0,0 +1,27 @@
+{{ templates.repo_sender_prefix }}
+{% if is_deleted_ref %}
+ deleted branch
+{% else %}
+ pushed
+
+ {{- total_commits_count }} commit{{ pluralize(total_commits_count) -}}
+
+ to
+{% endif %}
+ {{ ref_name }}
+{%- if is_new_ref %} (new branch){% endif -%}
+{%- if commits|length > 0 %}:
+
+ {% for commit in commits[-5:] %}
+ -
+
+ {{- commit.id[:8] -}}
+
+ {{ commit.cut_message|e }}
+ {% if commit.author.name != user_name and commit.author.name != user_username %}
+ by {{ commit.author.name }}
+ {% endif %}
+
+ {% endfor %}
+
+{% endif -%}
diff --git a/templates/gitlab/messages/tag.html b/templates/gitlab/messages/tag.html
new file mode 100644
index 0000000..8b3a8bb
--- /dev/null
+++ b/templates/gitlab/messages/tag.html
@@ -0,0 +1,13 @@
+{{ templates.repo_sender_prefix }}
+{% if is_deleted_ref %}
+ deleted tag
+ {{ ref_name }}
+{% else %}
+ created tag
+ {{ ref_name }}
+{% endif %}
+{%- if message -%}:
+
+ {{ message | markdown }}
+
+{%- endif -%}
diff --git a/templates/gitlab/messages/test.html b/templates/gitlab/messages/test.html
new file mode 100644
index 0000000..a9c7426
--- /dev/null
+++ b/templates/gitlab/messages/test.html
@@ -0,0 +1 @@
+{{ templates.repo_sender_prefix }}
\ No newline at end of file
diff --git a/templates/gitlab/messages/wiki.html b/templates/gitlab/messages/wiki.html
new file mode 100644
index 0000000..2d54e71
--- /dev/null
+++ b/templates/gitlab/messages/wiki.html
@@ -0,0 +1,5 @@
+{{ templates.repo_sender_prefix }}
+{{ "edited" if object_attributes.action == UPDATE else object_attributes.action.past_tense }}
+{{ object_attributes.title }}
+on the wiki
+{%- if object_attributes.message -%}: {{ object_attributes.message }}{% endif %}
diff --git a/templates/gitlab/mixins/repo_sender_prefix.html b/templates/gitlab/mixins/repo_sender_prefix.html
new file mode 100644
index 0000000..144e0cf
--- /dev/null
+++ b/templates/gitlab/mixins/repo_sender_prefix.html
@@ -0,0 +1 @@
+[{{ repo_link(project) }}] {{ user_link(user) }}
diff --git a/tests/test_gitlab.py b/tests/test_gitlab.py
new file mode 100644
index 0000000..992cc82
--- /dev/null
+++ b/tests/test_gitlab.py
@@ -0,0 +1,163 @@
+import json
+from ops_bot.gitlab import hook
+from ops_bot.util.template import TemplateUtil
+
+issue_open_payload_raw = """
+{
+ "object_kind": "issue",
+ "event_type": "issue",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon",
+ "email": "admin@example.com"
+ },
+ "project": {
+ "id": 1,
+ "name":"Gitlab Test",
+ "description":"Aut reprehenderit ut est.",
+ "web_url":"http://example.com/gitlabhq/gitlab-test",
+ "avatar_url":null,
+ "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
+ "git_http_url":"http://example.com/gitlabhq/gitlab-test.git",
+ "namespace":"GitlabHQ",
+ "visibility_level":20,
+ "path_with_namespace":"gitlabhq/gitlab-test",
+ "default_branch":"master",
+ "ci_config_path": null,
+ "homepage":"http://example.com/gitlabhq/gitlab-test",
+ "url":"http://example.com/gitlabhq/gitlab-test.git",
+ "ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
+ "http_url":"http://example.com/gitlabhq/gitlab-test.git"
+ },
+ "object_attributes": {
+ "id": 301,
+ "title": "New API: create/update/delete file",
+ "assignee_ids": [51],
+ "assignee_id": 51,
+ "author_id": 51,
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "updated_by_id": 1,
+ "last_edited_at": null,
+ "last_edited_by_id": null,
+ "relative_position": 0,
+ "description": "Create new API for manipulations with repository",
+ "milestone_id": null,
+ "state_id": 1,
+ "confidential": false,
+ "discussion_locked": true,
+ "due_date": null,
+ "moved_to_id": null,
+ "duplicated_to_id": null,
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "time_change": 0,
+ "human_total_time_spent": null,
+ "human_time_estimate": null,
+ "human_time_change": null,
+ "weight": null,
+ "iid": 23,
+ "url": "http://example.com/diaspora/issues/23",
+ "state": "opened",
+ "action": "open",
+ "severity": "high",
+ "escalation_status": "triggered",
+ "escalation_policy": {
+ "id": 18,
+ "name": "Engineering On-call"
+ },
+ "labels": [{
+ "id": 206,
+ "title": "API",
+ "color": "#ffffff",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "API related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }]
+ },
+ "repository": {
+ "name": "Gitlab Test",
+ "url": "http://example.com/gitlabhq/gitlab-test.git",
+ "description": "Aut reprehenderit ut est.",
+ "homepage": "http://example.com/gitlabhq/gitlab-test"
+ },
+ "assignees": [{
+ "name": "User1",
+ "username": "user1",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+ }],
+ "assignee": {
+ "name": "User1",
+ "username": "user1",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+ },
+ "labels": [{
+ "id": 206,
+ "title": "API",
+ "color": "#ffffff",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "API related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }],
+ "changes": {
+ "updated_by_id": {
+ "previous": null,
+ "current": 1
+ },
+ "updated_at": {
+ "previous": "2017-09-15 16:50:55 UTC",
+ "current": "2017-09-15 16:52:00 UTC"
+ },
+ "labels": {
+ "previous": [{
+ "id": 206,
+ "title": "API",
+ "color": "#ffffff",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "API related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }],
+ "current": [{
+ "id": 205,
+ "title": "Platform",
+ "color": "#123123",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "Platform related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }]
+ }
+ }
+}"""
+issue_open_payload = json.loads(issue_open_payload_raw)
+def test_templates():
+ # print(gitlab.messages._loader.list_templates())
+ tpl = hook.messages["test"]
+ args = issue_open_payload | {"util": TemplateUtil}
+ args["templates"] = hook.templates.proxy(args)
+ assert tpl.render(**args) == "[gitlabhq/gitlab-test] root"
+
+
+async def test_hook():
+ r = await hook.handle_event("Issue Hook", issue_open_payload)
+ assert r[0]
+ assert r[0][0] == "[gitlabhq/gitlab-test] root opened [issue #23](http://example.com/diaspora/issues/23): New API: create/update/delete file\n \n> Create new API for manipulations with repository\nAPI"
+ assert r[0][1] == "[gitlabhq/gitlab-test] root\n opened issue #23: New API: create/update/delete file
\n Create new API for manipulations with repository
\n
\n API "
\ No newline at end of file
diff --git a/tests/test_ops_bot.py b/tests/test_ops_bot.py
index dd65087..4858ff7 100644
--- a/tests/test_ops_bot.py
+++ b/tests/test_ops_bot.py
@@ -44,19 +44,19 @@ sns_notification = """{
"UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96"
}"""
-def test_aws_sns_notification() -> None:
- r = aws.parse_sns_event(json.loads(sns_notification))
- assert r[0] == "My First Message\nHello world!"
- assert r[1] == "My First Message\nHello world!
"
+async def test_aws_sns_notification() -> None:
+ r = await aws.parse_sns_event(None, json.loads(sns_notification), None)
+ assert r[0][0] == "My First Message\nHello world!"
+ assert r[0][1] == "My First Message\nHello world!
"
-def test_aws_sns_subscribe() -> None:
- r = aws.parse_sns_event(json.loads(sns_subscribtion_confirm))
+async def test_aws_sns_subscribe() -> None:
+ r = await aws.parse_sns_event(None, json.loads(sns_subscribtion_confirm), None)
print(r)
expected = 'You have chosen to subscribe to the topic arn:aws:sns:us-west-2:123456789012:MyTopic.\nTo confirm the subscription, visit the SubscribeURL included in this message.\n\nhttps://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=2336412f37...'
- assert r == (expected, expected)
+ assert r[0] == (expected, expected)
-def test_aws_sns_unsubscribe() -> None:
- r = aws.parse_sns_event(json.loads(sns_subscribtion_unsubscribe))
+async def test_aws_sns_unsubscribe() -> None:
+ r = await aws.parse_sns_event(None, json.loads(sns_subscribtion_unsubscribe), None)
print(r)
expected = 'You have chosen to deactivate subscription arn:aws:sns:us-west-2:123456789012:MyTopic:2bcfbf39-05c3-41de-beaa-fcfcc21c8f55.\nTo cancel this operation and restore the subscription, visit the SubscribeURL included in this message.\n\nhttps://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=2336412f37fb6...'
- assert r == (expected, expected)
\ No newline at end of file
+ assert r[0] == (expected, expected)