Merge branch 'feat/gitlab' into 'main'
Add gitlab webhook support See merge request guardianproject-ops/matrix-ops-bot!1
This commit is contained in:
commit
fef0818535
34 changed files with 2177 additions and 105 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,3 +4,5 @@ __pycache__
|
||||||
.venv
|
.venv
|
||||||
devstate
|
devstate
|
||||||
.env*
|
.env*
|
||||||
|
config.json
|
||||||
|
dev.data
|
||||||
|
|
@ -30,7 +30,6 @@ build-test:
|
||||||
- docker pull registry.gitlab.com/$CI_PROJECT_NAMESPACE/${CI_PROJECT_NAME}:main
|
- docker pull registry.gitlab.com/$CI_PROJECT_NAMESPACE/${CI_PROJECT_NAME}:main
|
||||||
- docker build -t $UNIQUE_IMAGE .
|
- docker build -t $UNIQUE_IMAGE .
|
||||||
- docker push $UNIQUE_IMAGE
|
- docker push $UNIQUE_IMAGE
|
||||||
- docker run --entrypoint /test $UNIQUE_IMAGE
|
|
||||||
except:
|
except:
|
||||||
- main
|
- main
|
||||||
- tags
|
- tags
|
||||||
|
|
|
||||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true
|
||||||
|
}
|
||||||
32
README.md
32
README.md
|
|
@ -1,14 +1,34 @@
|
||||||
# matrix-ops-bot
|
# 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
|
## 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 .
|
docker build -t registry.gitlab.com/guardianproject-ops/matrix-ops-bot .
|
||||||
|
|
@ -91,3 +111,7 @@ GNU Affero General Public License for more details.
|
||||||
You should have received a copy of the GNU Affero General Public License
|
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/>.
|
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.
|
||||||
|
|
|
||||||
20
config.json.sample
Normal file
20
config.json.sample
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
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.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")
|
message = payload.get("Message")
|
||||||
url = payload.get("SubscribeURL")
|
url = payload.get("SubscribeURL")
|
||||||
plain = f"{message}\n\n{url}"
|
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")
|
message = payload.get("Message")
|
||||||
subject = payload.get("Subject")
|
subject = payload.get("Subject")
|
||||||
|
|
||||||
|
|
@ -20,15 +23,15 @@ def handle_notification(payload: Any) -> Tuple[str, str]:
|
||||||
formatted = (
|
formatted = (
|
||||||
f"<strong><font color={COLOR_ALARM}>{subject}</font></strong>\n<p>{message}</p>"
|
f"<strong><font color={COLOR_ALARM}>{subject}</font></strong>\n<p>{message}</p>"
|
||||||
)
|
)
|
||||||
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:
|
if "AlarmName" not in body:
|
||||||
msg = "Received unknown json payload type over AWS SNS"
|
msg = "Received unknown json payload type over AWS SNS"
|
||||||
logging.info(msg)
|
logging.info(msg)
|
||||||
logging.info(payload.get("Message"))
|
logging.info(payload.get("Message"))
|
||||||
return msg, msg
|
return [(msg, msg)]
|
||||||
|
|
||||||
description = body.get("AlarmDescription")
|
description = body.get("AlarmDescription")
|
||||||
subject = payload.get("Subject")
|
subject = payload.get("Subject")
|
||||||
|
|
@ -50,10 +53,14 @@ def handle_json_notification(payload: Any, body: Any) -> Tuple[str, str]:
|
||||||
else:
|
else:
|
||||||
plain += "\n{description}"
|
plain += "\n{description}"
|
||||||
formatted += f"\n<p>{description}</p>"
|
formatted += f"\n<p>{description}</p>"
|
||||||
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":
|
if payload.get("Type") == "SubscriptionConfirmation":
|
||||||
return handle_subscribe_confirm(payload)
|
return handle_subscribe_confirm(payload)
|
||||||
elif payload.get("Type") == "UnsubscribeConfirmation":
|
elif payload.get("Type") == "UnsubscribeConfirmation":
|
||||||
|
|
|
||||||
56
ops_bot/cli.py
Normal file
56
ops_bot/cli.py
Normal file
|
|
@ -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}")
|
||||||
47
ops_bot/config.py
Normal file
47
ops_bot/config.py
Normal file
|
|
@ -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))
|
||||||
90
ops_bot/gitlab/hook.py
Normal file
90
ops_bot/gitlab/hook.py
Normal file
|
|
@ -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)
|
||||||
957
ops_bot/gitlab/types.py
Normal file
957
ops_bot/gitlab/types.py
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
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,
|
||||||
|
}
|
||||||
174
ops_bot/main.py
174
ops_bot/main.py
|
|
@ -1,30 +1,28 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Literal, Optional, Tuple, cast
|
from typing import Any, Dict, List, Optional, Protocol, Tuple, cast
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from dotenv import load_dotenv
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import (
|
||||||
from pydantic import BaseSettings
|
HTTPAuthorizationCredentials,
|
||||||
|
HTTPBasic,
|
||||||
|
HTTPBasicCredentials,
|
||||||
|
HTTPBearer,
|
||||||
|
)
|
||||||
|
|
||||||
from ops_bot import aws, pagerduty
|
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
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
security = HTTPBearer()
|
bearer_security = HTTPBearer(auto_error=False)
|
||||||
|
basic_security = HTTPBasic(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
async def get_matrix_service(request: Request) -> MatrixClient:
|
async def get_matrix_service(request: Request) -> MatrixClient:
|
||||||
|
|
@ -40,11 +38,8 @@ async def matrix_main(matrix_client: MatrixClient) -> None:
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event() -> None:
|
async def startup_event() -> None:
|
||||||
bot_settings = BotSettings(_env_file=".env", _env_file_encoding="utf-8")
|
bot_settings = load_config()
|
||||||
logging.getLogger().setLevel(bot_settings.log_level)
|
c = MatrixClient(settings=bot_settings.matrix, join_rooms=bot_settings.get_rooms())
|
||||||
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)
|
|
||||||
app.state.matrix_client = c
|
app.state.matrix_client = c
|
||||||
app.state.bot_settings = bot_settings
|
app.state.bot_settings = bot_settings
|
||||||
asyncio.create_task(matrix_main(c))
|
asyncio.create_task(matrix_main(c))
|
||||||
|
|
@ -60,65 +55,112 @@ async def root() -> Dict[str, str]:
|
||||||
return {"message": "Hello World"}
|
return {"message": "Hello World"}
|
||||||
|
|
||||||
|
|
||||||
def authorize(
|
async def bearer_token_authorizer(
|
||||||
request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)
|
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:
|
) -> 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
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_destination(bot_settings: BotSettings, routing_key: str) -> Optional[str]:
|
def get_route(bot_settings: BotSettings, path_key: str) -> Optional[RoutingKey]:
|
||||||
return bot_settings.routing_keys.get(routing_key, None)
|
# 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]:
|
class Authorizer(Protocol):
|
||||||
payload: Any = await request.json()
|
async def __call__(
|
||||||
routing_key = request.path_params["routing_key"]
|
self,
|
||||||
room_id = get_destination(request.app.state.bot_settings, routing_key)
|
route: RoutingKey,
|
||||||
if room_id is None:
|
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}")
|
logging.error(f"unknown routing key {routing_key}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Unknown routing key"
|
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}")
|
authorizer, parse_handler = handler
|
||||||
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}
|
|
||||||
|
|
||||||
|
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}")
|
payload: Any = await request.json()
|
||||||
async def aws_sns_hook(
|
|
||||||
request: Request, matrix_client: MatrixClient = Depends(get_matrix_service)
|
messages = await parse_handler(route, payload, request=request)
|
||||||
) -> Dict[str, str]:
|
for msg_plain, msg_formatted in messages:
|
||||||
room_id, payload = await receive_helper(request)
|
await matrix_client.room_send(
|
||||||
msg_plain, msg_formatted = aws.parse_sns_event(payload)
|
route.room_id,
|
||||||
await matrix_client.room_send(
|
msg_plain,
|
||||||
room_id,
|
message_formatted=msg_formatted,
|
||||||
msg_plain,
|
)
|
||||||
message_formatted=msg_formatted,
|
|
||||||
)
|
return {"status": "ok"}
|
||||||
return {"message": msg_plain, "message_formatted": msg_formatted}
|
|
||||||
|
|
||||||
|
|
||||||
def start_dev() -> None:
|
def start_dev() -> None:
|
||||||
|
|
|
||||||
|
|
@ -46,19 +46,18 @@ class MatrixClientSettings(BaseSettings):
|
||||||
password: str
|
password: str
|
||||||
device_name: str
|
device_name: str
|
||||||
store_path: str
|
store_path: str
|
||||||
join_rooms: Optional[List[str]]
|
|
||||||
verify_ssl: Optional[bool] = True
|
verify_ssl: Optional[bool] = True
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_prefix = "MATRIX_"
|
env_prefix = "MATRIX_"
|
||||||
secrets_dir = "/run/secrets"
|
|
||||||
case_sensitive = False
|
case_sensitive = False
|
||||||
|
|
||||||
|
|
||||||
class MatrixClient:
|
class MatrixClient:
|
||||||
def __init__(self, settings: MatrixClientSettings):
|
def __init__(self, settings: MatrixClientSettings, join_rooms: List[str]):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.store_path = pathlib.Path(settings.store_path)
|
self.store_path = pathlib.Path(settings.store_path)
|
||||||
|
self.join_rooms = join_rooms
|
||||||
self.credential_store = LocalCredentialStore(
|
self.credential_store = LocalCredentialStore(
|
||||||
self.store_path.joinpath("credentials.json")
|
self.store_path.joinpath("credentials.json")
|
||||||
)
|
)
|
||||||
|
|
@ -79,9 +78,8 @@ class MatrixClient:
|
||||||
if self.client.should_upload_keys:
|
if self.client.should_upload_keys:
|
||||||
await self.client.keys_upload()
|
await self.client.keys_upload()
|
||||||
|
|
||||||
if self.settings.join_rooms:
|
for room in self.join_rooms:
|
||||||
for room in self.settings.join_rooms:
|
await self.client.join(room)
|
||||||
await self.client.join(room)
|
|
||||||
|
|
||||||
await self.client.joined_rooms()
|
await self.client.joined_rooms()
|
||||||
await self.client.sync_forever(timeout=300000, full_state=True)
|
await self.client.sync_forever(timeout=300000, full_state=True)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import json
|
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.common import COLOR_ALARM, COLOR_UNKNOWN
|
||||||
|
from ops_bot.config import RoutingKey
|
||||||
|
|
||||||
|
|
||||||
def urgency_color(urgency: str) -> str:
|
def urgency_color(urgency: str) -> str:
|
||||||
|
|
@ -11,7 +14,11 @@ def urgency_color(urgency: str) -> str:
|
||||||
return COLOR_UNKNOWN
|
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.
|
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
|
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:
|
else:
|
||||||
color = urgency_color(urgency)
|
color = urgency_color(urgency)
|
||||||
formatted = f"<strong><font color={color}>{header_str}</font></strong> on {service_name}: [{title}]({url})"
|
formatted = f"<strong><font color={color}>{header_str}</font></strong> on {service_name}: [{title}]({url})"
|
||||||
return plain, formatted
|
return [(plain, formatted)]
|
||||||
|
|
||||||
payload_str = json.dumps(payload, sort_keys=True, indent=2)
|
payload_str = json.dumps(payload, sort_keys=True, indent=2)
|
||||||
return (
|
return [
|
||||||
"unhandled",
|
(
|
||||||
f"""**unhandled pager duty event** (this may or may not be a critical problem, please look carefully)
|
"unhandled",
|
||||||
|
f"""**unhandled pager duty event** (this may or may not be a critical problem, please look carefully)
|
||||||
<pre><code class="language-json">{payload_str}</code></pre>
|
<pre><code class="language-json">{payload_str}</code></pre>
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
|
|
||||||
62
ops_bot/util/contrast.py
Normal file
62
ops_bot/util/contrast.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Based on https://github.com/gsnedders/wcag-contrast-ratio
|
||||||
|
# Copyright (c) 2015 Geoffrey Sneddon
|
||||||
|
# Copyright (c) 2019 Tulir Asokan
|
||||||
|
# MIT license
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
RGB = Tuple[float, float, float]
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_rgb(color: str) -> RGB:
|
||||||
|
color = color.lstrip("#")
|
||||||
|
if len(color) != 3 and len(color) != 6:
|
||||||
|
raise ValueError("Invalid hex length")
|
||||||
|
step = 1 if len(color) == 3 else 2
|
||||||
|
try:
|
||||||
|
r = int(color[0:step], 16)
|
||||||
|
g = int(color[step : 2 * step], 16)
|
||||||
|
b = int(color[2 * step : 3 * step], 16)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError("Invalid hex value") from e
|
||||||
|
return r / 255, g / 255, b / 255
|
||||||
|
|
||||||
|
|
||||||
|
def rgb_to_hex(rgb: RGB) -> str:
|
||||||
|
r, g, b = rgb
|
||||||
|
r = int(r * 255)
|
||||||
|
g = int(g * 255)
|
||||||
|
b = int(b * 255)
|
||||||
|
return f"{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def contrast(rgb1: RGB, rgb2: RGB) -> float:
|
||||||
|
for r, g, b in (rgb1, rgb2):
|
||||||
|
if not 0.0 <= r <= 1.0:
|
||||||
|
raise ValueError(f"r {r} is out of valid range (0.0 - 1.0)")
|
||||||
|
if not 0.0 <= g <= 1.0:
|
||||||
|
raise ValueError(f"g {g} is out of valid range (0.0 - 1.0)")
|
||||||
|
if not 0.0 <= b <= 1.0:
|
||||||
|
raise ValueError(f"b {b} is out of valid range (0.0 - 1.0)")
|
||||||
|
|
||||||
|
l1 = _relative_luminance(*rgb1)
|
||||||
|
l2 = _relative_luminance(*rgb2)
|
||||||
|
|
||||||
|
if l1 > l2:
|
||||||
|
return (l1 + 0.05) / (l2 + 0.05)
|
||||||
|
else:
|
||||||
|
return (l2 + 0.05) / (l1 + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_luminance(r: float, g: float, b: float) -> float:
|
||||||
|
r = _linearize(r)
|
||||||
|
g = _linearize(g)
|
||||||
|
b = _linearize(b)
|
||||||
|
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||||
|
|
||||||
|
|
||||||
|
def _linearize(v: float) -> float:
|
||||||
|
if v <= 0.03928:
|
||||||
|
return v / 12.92
|
||||||
|
else:
|
||||||
|
return float(((v + 0.055) / 1.055) ** 2.4)
|
||||||
38
ops_bot/util/markdown.py
Normal file
38
ops_bot/util/markdown.py
Normal file
|
|
@ -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
|
||||||
157
ops_bot/util/template.py
Normal file
157
ops_bot/util/template.py
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
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}::<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(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)
|
||||||
138
poetry.lock
generated
138
poetry.lock
generated
|
|
@ -187,6 +187,17 @@ category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
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]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -365,6 +376,20 @@ requirements_deprecated_finder = ["pipreqs", "pip-api"]
|
||||||
colors = ["colorama (>=0.4.3,<0.5.0)"]
|
colors = ["colorama (>=0.4.3,<0.5.0)"]
|
||||||
plugins = ["setuptools"]
|
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]]
|
[[package]]
|
||||||
name = "jsonschema"
|
name = "jsonschema"
|
||||||
version = "3.2.0"
|
version = "3.2.0"
|
||||||
|
|
@ -415,6 +440,14 @@ importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
|
||||||
[package.extras]
|
[package.extras]
|
||||||
testing = ["coverage", "pyyaml"]
|
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]]
|
[[package]]
|
||||||
name = "matrix-nio"
|
name = "matrix-nio"
|
||||||
version = "0.19.0"
|
version = "0.19.0"
|
||||||
|
|
@ -442,6 +475,25 @@ unpaddedbase64 = ">=2.1.0,<3.0.0"
|
||||||
[package.extras]
|
[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)"]
|
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]]
|
[[package]]
|
||||||
name = "mccabe"
|
name = "mccabe"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -631,6 +683,20 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
|
||||||
[package.extras]
|
[package.extras]
|
||||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
|
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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "0.21.0"
|
version = "0.21.0"
|
||||||
|
|
@ -745,6 +811,14 @@ category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-commonmark"
|
||||||
|
version = "0.9.2"
|
||||||
|
description = "Typing stubs for commonmark"
|
||||||
|
category = "dev"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-markdown"
|
name = "types-markdown"
|
||||||
version = "3.4.2.1"
|
version = "3.4.2.1"
|
||||||
|
|
@ -819,7 +893,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-co
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.9"
|
python-versions = "^3.9"
|
||||||
content-hash = "8c9dc7f1cfae257da386d35620ae44f55488c1a977c8d7dad2f2cf085d04cd65"
|
content-hash = "45b4c6a8c5743ab30f7975df8fd7f47e15eb8cc719da9b5c16fd0e145d14a359"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiofiles = [
|
aiofiles = [
|
||||||
|
|
@ -1047,6 +1121,10 @@ colorama = [
|
||||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
{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 = [
|
exceptiongroup = [
|
||||||
{file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"},
|
{file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"},
|
||||||
{file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"},
|
{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-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
|
||||||
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
|
{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 = [
|
jsonschema = [
|
||||||
{file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"},
|
{file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"},
|
||||||
{file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"},
|
{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-py3-none-any.whl", hash = "sha256:08fb8465cffd03d10b9dd34a5c3fea908e20391a2a90b88d66362cb05beed186"},
|
||||||
{file = "Markdown-3.4.1.tar.gz", hash = "sha256:3b809086bb6efad416156e00a0da66fe47618a5d6918dd688f53f40c8e4cfeff"},
|
{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 = [
|
matrix-nio = [
|
||||||
{file = "matrix-nio-0.19.0.tar.gz", hash = "sha256:fb413e2db457380f6798c90f3caac4730a5e0b0d4ab1b8f4fc4af2e35ac9356c"},
|
{file = "matrix-nio-0.19.0.tar.gz", hash = "sha256:fb413e2db457380f6798c90f3caac4730a5e0b0d4ab1b8f4fc4af2e35ac9356c"},
|
||||||
{file = "matrix_nio-0.19.0-py3-none-any.whl", hash = "sha256:e5f43bc1a343f87982b597a5f6aa712468b619363f543a201e5a8c847e518c01"},
|
{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 = [
|
mccabe = [
|
||||||
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
|
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
|
||||||
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
{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-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
|
||||||
{file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
|
{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 = [
|
python-dotenv = [
|
||||||
{file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"},
|
{file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"},
|
||||||
{file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"},
|
{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-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
|
||||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
{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 = [
|
types-markdown = [
|
||||||
{file = "types-Markdown-3.4.2.1.tar.gz", hash = "sha256:03c0904cf5886a7d8193e2f50bcf842afc89e0ab80f060f389f6c2635c65628f"},
|
{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"},
|
{file = "types_Markdown-3.4.2.1-py3-none-any.whl", hash = "sha256:b2333f6f4b8f69af83de359e10a097e4a3f14bbd6d2484e1829d9b0ec56fa0cb"},
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ uvicorn = "^0.18.2"
|
||||||
termcolor = "^1.1.0"
|
termcolor = "^1.1.0"
|
||||||
Markdown = "^3.4.1"
|
Markdown = "^3.4.1"
|
||||||
pydantic = {extras = ["dotenv"], version = "^1.9.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]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^7.2.0"
|
pytest = "^7.2.0"
|
||||||
|
|
@ -23,6 +27,8 @@ flake8 = "^6.0.0"
|
||||||
flake8-black = "^0.3.5"
|
flake8-black = "^0.3.5"
|
||||||
types-Markdown = "^3.4.0"
|
types-Markdown = "^3.4.0"
|
||||||
types-termcolor = "^1.1.5"
|
types-termcolor = "^1.1.5"
|
||||||
|
pytest-asyncio = "^0.20.2"
|
||||||
|
types-commonmark = "^0.9.2"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|
@ -30,6 +36,7 @@ build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
start = "ops_bot.main:start_dev"
|
start = "ops_bot.main:start_dev"
|
||||||
|
config = "ops_bot.cli:cli"
|
||||||
|
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
|
|
|
||||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
75
templates/gitlab/macros.html
Normal file
75
templates/gitlab/macros.html
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
{%- macro repo_link(project) -%}
|
||||||
|
<a data-mautrix-exclude-plaintext href="{{ project.web_url }}">{{ project.path_with_namespace|e }}</a>
|
||||||
|
{%- endmacro -%}
|
||||||
|
{%- macro user_link(user) -%}
|
||||||
|
<a data-mautrix-exclude-plaintext href="{{ user.web_url }}">{{ user.username|e }}</a>
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{%- macro pluralize(value) -%}
|
||||||
|
{% if value != 1 %}s{% endif %}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{%- macro issue_link(issue, title = true, important = true) -%}
|
||||||
|
<a href="{{ issue.url }}" {% if not important %}data-mautrix-exclude-plaintext{% endif %}>
|
||||||
|
{%- if issue.confidential and (not changes or not changes.confidential) -%}confidential {% endif -%}
|
||||||
|
issue #{{ issue.issue_id -}}
|
||||||
|
</a>
|
||||||
|
{%- if title -%}
|
||||||
|
: {{ issue.title|e -}}
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{%- macro merge_request_link(merge_request, title = true, important = true) -%}
|
||||||
|
<a href="{{ merge_request.url }}" {% if not important %}data-mautrix-exclude-plaintext{% endif %}>
|
||||||
|
merge request !{{ merge_request.merge_request_id -}}
|
||||||
|
</a>
|
||||||
|
{%- if title -%}
|
||||||
|
: {{ merge_request.title|e -}}
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{%- macro issue_or_merge_link(attrs, title = true, important = true) -%}
|
||||||
|
{%- if attrs.issue_id -%}
|
||||||
|
{{- issue_link(attrs, title, important) -}}
|
||||||
|
{%- elif attrs.merge_request_id -%}
|
||||||
|
{{- merge_request_link(attrs, title, important) -}}
|
||||||
|
{%- else -%}
|
||||||
|
unknown object {{ attrs.title|e }}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{%- macro fancy_label(label) -%}
|
||||||
|
<span data-mx-color="{{ label.foreground_color }}"
|
||||||
|
data-mx-bg-color="{{ label.color }}"
|
||||||
|
title="{{ label.description }}"
|
||||||
|
> {{ util.bold_scope(label.title) }} </span>
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{%- macro fancy_labels(labels) -%}
|
||||||
|
{% for label in labels %}
|
||||||
|
{{ fancy_label(label) }}
|
||||||
|
{% endfor %}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{%- macro list_changes(added, removed, add_word, remove_word, mutate) -%}
|
||||||
|
{%- if added -%}
|
||||||
|
{{ add_word }} {{ util.join_human_list(added, mutate=mutate) }}
|
||||||
|
{% if not removed %}
|
||||||
|
to
|
||||||
|
{% endif %}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if removed -%}
|
||||||
|
{%- if added %}
|
||||||
|
and
|
||||||
|
{% endif -%}
|
||||||
|
{{ remove_word }} {{ util.join_human_list(removed, mutate=mutate) }}
|
||||||
|
from
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{%- macro label_changes(added, removed) -%}
|
||||||
|
{{ list_changes(added, removed, "added", "removed", fancy_label) }}
|
||||||
|
{%- endmacro -%}
|
||||||
|
{%- macro assignee_changes(added, removed) -%}
|
||||||
|
{{ list_changes(added, removed, "assigned", "unassigned", user_link) }}
|
||||||
|
{%- endmacro -%}
|
||||||
20
templates/gitlab/messages/comment.html
Normal file
20
templates/gitlab/messages/comment.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
<a data-mautrix-exclude-plaintext href="{{ object_attributes.url }}">
|
||||||
|
{%- if object_attributes.type == CommentType.DISCUSSION_NOTE -%}
|
||||||
|
replied to a thread
|
||||||
|
{%- else -%}
|
||||||
|
commented
|
||||||
|
{%- endif -%}
|
||||||
|
</a> on
|
||||||
|
{% if issue and object_attributes.noteable_type == NoteableType.ISSUE %}
|
||||||
|
{{ issue_link(issue, important=false) }}
|
||||||
|
{% elif merge_request and object_attributes.noteable_type == NoteableType.MERGE_REQUEST %}
|
||||||
|
{{ merge_request_link(merge_request, important=false) }}
|
||||||
|
{% else %}
|
||||||
|
{# unsupported comment target #}
|
||||||
|
{% do abort() %}
|
||||||
|
{% endif %}
|
||||||
|
<br/>
|
||||||
|
{% if object_attributes.description %}
|
||||||
|
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
|
||||||
|
{% endif %}
|
||||||
2
templates/gitlab/messages/issue_close.html
Normal file
2
templates/gitlab/messages/issue_close.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
closed {{ issue_link(object_attributes) }}
|
||||||
6
templates/gitlab/messages/issue_open.html
Normal file
6
templates/gitlab/messages/issue_open.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
opened {{ issue_link(object_attributes) }}<br/>
|
||||||
|
{% if object_attributes.description %}
|
||||||
|
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
|
||||||
|
{% endif %}
|
||||||
|
{{ fancy_labels(object_attributes.labels) }}
|
||||||
2
templates/gitlab/messages/issue_reopen.html
Normal file
2
templates/gitlab/messages/issue_reopen.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
reopened {{ issue_link(object_attributes) }}
|
||||||
77
templates/gitlab/messages/issue_update.html
Normal file
77
templates/gitlab/messages/issue_update.html
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
{% if changes.labels %}
|
||||||
|
{{ label_changes(changes.labels.added, changes.labels.removed) }}
|
||||||
|
{{ issue_or_merge_link(object_attributes) }}
|
||||||
|
{# Milestone webhooks don't have the milestone displayname 3:< #}
|
||||||
|
{#{% elif changes.milestone_id %}#}
|
||||||
|
{# {% if not changes.milestone_id.current %}#}
|
||||||
|
{# removed the milestone from {{ issue_or_merge_link(object_attributes) }}#}
|
||||||
|
{# {% else %}#}
|
||||||
|
{# added {{ issue_or_merge_link(object_attributes) }} to milestone#}
|
||||||
|
{# {% endif %}#}
|
||||||
|
{% elif changes.assignees %}
|
||||||
|
{{ assignee_changes(changes.assignees.added, changes.assignees.removed) }}
|
||||||
|
{{ issue_or_merge_link(object_attributes) }}
|
||||||
|
{% elif changes.time_estimate %}
|
||||||
|
{% if not changes.time_estimate.current %}
|
||||||
|
removed the time estimate of {{ issue_or_merge_link(object_attributes) }}
|
||||||
|
{% elif not changes.time_estimate.previous %}
|
||||||
|
set the time estimate of {{ issue_or_merge_link(object_attributes) }} to
|
||||||
|
<strong>{{ util.format_time(changes.time_estimate.current) }}</strong>
|
||||||
|
{% else %}
|
||||||
|
{% if changes.time_estimate.current > changes.time_estimate.previous %}
|
||||||
|
increased
|
||||||
|
{% else %}
|
||||||
|
decreased
|
||||||
|
{% endif %}
|
||||||
|
the time estimate of {{ issue_or_merge_link(object_attributes) }} by
|
||||||
|
<strong>{{ util.format_time(changes.time_estimate.current - changes.time_estimate.previous) }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
{% elif changes.total_time_spent %}
|
||||||
|
{% if not changes.total_time_spent.current %}
|
||||||
|
removed the time spent
|
||||||
|
{% else %}
|
||||||
|
{% if changes.total_time_spent.current > (changes.total_time_spent.previous or 0) %}
|
||||||
|
spent <strong>{{ util.format_time(changes.total_time_spent.current - (changes.total_time_spent.previous or 0)) }}</strong>
|
||||||
|
{% else %}
|
||||||
|
subtracted <strong>{{ util.format_time(changes.total_time_spent.current - changes.total_time_spent.previous) }}</strong>
|
||||||
|
from the time spent
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
on {{ issue_or_merge_link(object_attributes) }}
|
||||||
|
{% elif changes.weight %}
|
||||||
|
{% if not changes.weight.current %}
|
||||||
|
removed
|
||||||
|
{% else %}
|
||||||
|
changed
|
||||||
|
{% endif %}
|
||||||
|
the weight of {{ issue_or_merge_link(object_attributes) }}
|
||||||
|
{% if changes.weight.current %}
|
||||||
|
to <strong>{{ changes.weight.current }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
{% elif changes.due_date %}
|
||||||
|
{% if not changes.due_date.current %}
|
||||||
|
removed the due date of {{ issue_or_merge_link(object_attributes) }}
|
||||||
|
{% else %}
|
||||||
|
set the due date of {{ issue_or_merge_link(object_attributes) }}
|
||||||
|
to <strong>{{ changes.due_date.current.strftime("%B %d, %Y") }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
{% elif changes.confidential %}
|
||||||
|
made {{ issue_or_merge_link(object_attributes) }}
|
||||||
|
{% if changes.confidential.current %}
|
||||||
|
confidential
|
||||||
|
{% else %}
|
||||||
|
non-confidential
|
||||||
|
{% endif %}
|
||||||
|
{% elif changes.discussion_locked %}
|
||||||
|
{% if changes.discussion_locked.current %}
|
||||||
|
locked discussion in
|
||||||
|
{% else %}
|
||||||
|
unlocked discussion in
|
||||||
|
{% endif %}
|
||||||
|
{{ issue_or_merge_link(object_attributes) }}
|
||||||
|
{% elif changes.title %}
|
||||||
|
changed the title of {{ issue_or_merge_link(object_attributes, title=false) }} to {{ changes.title.current }}
|
||||||
|
{% else %}
|
||||||
|
{% do abort() %}
|
||||||
|
{% endif %}
|
||||||
9
templates/gitlab/messages/job.html
Normal file
9
templates/gitlab/messages/job.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% if build_status == BuildStatus.FAILED and not build_allow_failure %}
|
||||||
|
<strong>[<a data-mautrix-exclude-plaintext href="{{ repository.homepage }}">{{ repository.path|e }}</a>]</strong>
|
||||||
|
<a href="{{ build_url }}">Job {{ build_id }}: {{ build_name }}</a>
|
||||||
|
<strong><font color="#ff0000">failed</font></strong>
|
||||||
|
after {{ util.format_time(build_duration) }}
|
||||||
|
(build triggered by {{ user_link(user) }})
|
||||||
|
{% else %}
|
||||||
|
{% do abort() %}
|
||||||
|
{% endif %}
|
||||||
11
templates/gitlab/messages/merge_request.html
Normal file
11
templates/gitlab/messages/merge_request.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
{% if action == OPEN %}
|
||||||
|
opened {{ merge_request_link(object_attributes) }}
|
||||||
|
{% if object_attributes.description %}
|
||||||
|
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
|
||||||
|
{% endif %}
|
||||||
|
{{ fancy_labels(labels) }}
|
||||||
|
{% else %}
|
||||||
|
{{ object_attributes.action.past_tense }}
|
||||||
|
{{ merge_request_link(object_attributes) }}
|
||||||
|
{% endif %}
|
||||||
27
templates/gitlab/messages/push.html
Normal file
27
templates/gitlab/messages/push.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
{% if is_deleted_ref %}
|
||||||
|
deleted branch
|
||||||
|
{% else %}
|
||||||
|
pushed
|
||||||
|
<a href="{{ diff_url }}" data-mautrix-exclude-plaintext>
|
||||||
|
{{- total_commits_count }} commit{{ pluralize(total_commits_count) -}}
|
||||||
|
</a>
|
||||||
|
to
|
||||||
|
{% endif %}
|
||||||
|
<a data-mautrix-exclude-plaintext href="{{ ref_url }}">{{ ref_name }}</a>
|
||||||
|
{%- if is_new_ref %} (new branch){% endif -%}
|
||||||
|
{%- if commits|length > 0 %}:
|
||||||
|
<ul>
|
||||||
|
{% for commit in commits[-5:] %}
|
||||||
|
<li>
|
||||||
|
<code><a data-mautrix-exclude-plaintext href="{{ commit.url }}">
|
||||||
|
{{- commit.id[:8] -}}
|
||||||
|
</a></code>
|
||||||
|
{{ commit.cut_message|e }}
|
||||||
|
{% if commit.author.name != user_name and commit.author.name != user_username %}
|
||||||
|
by {{ commit.author.name }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif -%}
|
||||||
13
templates/gitlab/messages/tag.html
Normal file
13
templates/gitlab/messages/tag.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
{% if is_deleted_ref %}
|
||||||
|
deleted tag
|
||||||
|
{{ ref_name }}
|
||||||
|
{% else %}
|
||||||
|
created tag
|
||||||
|
<a data-mautrix-exclude-plaintext href="{{ ref_url }}">{{ ref_name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{%- if message -%}:
|
||||||
|
<blockquote>
|
||||||
|
{{ message | markdown }}
|
||||||
|
</blockquote>
|
||||||
|
{%- endif -%}
|
||||||
1
templates/gitlab/messages/test.html
Normal file
1
templates/gitlab/messages/test.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
5
templates/gitlab/messages/wiki.html
Normal file
5
templates/gitlab/messages/wiki.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{{ templates.repo_sender_prefix }}
|
||||||
|
{{ "edited" if object_attributes.action == UPDATE else object_attributes.action.past_tense }}
|
||||||
|
<a href="{{ object_attributes.url }}">{{ object_attributes.title }}</a>
|
||||||
|
on the wiki
|
||||||
|
{%- if object_attributes.message -%}: {{ object_attributes.message }}{% endif %}
|
||||||
1
templates/gitlab/mixins/repo_sender_prefix.html
Normal file
1
templates/gitlab/mixins/repo_sender_prefix.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<strong data-mautrix-exclude-plaintext>[{{ repo_link(project) }}]</strong> {{ user_link(user) }}
|
||||||
163
tests/test_gitlab.py
Normal file
163
tests/test_gitlab.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import json
|
||||||
|
from ops_bot.gitlab import hook
|
||||||
|
from ops_bot.util.template import TemplateUtil
|
||||||
|
|
||||||
|
issue_open_payload_raw = """
|
||||||
|
{
|
||||||
|
"object_kind": "issue",
|
||||||
|
"event_type": "issue",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Administrator",
|
||||||
|
"username": "root",
|
||||||
|
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"id": 1,
|
||||||
|
"name":"Gitlab Test",
|
||||||
|
"description":"Aut reprehenderit ut est.",
|
||||||
|
"web_url":"http://example.com/gitlabhq/gitlab-test",
|
||||||
|
"avatar_url":null,
|
||||||
|
"git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
|
||||||
|
"git_http_url":"http://example.com/gitlabhq/gitlab-test.git",
|
||||||
|
"namespace":"GitlabHQ",
|
||||||
|
"visibility_level":20,
|
||||||
|
"path_with_namespace":"gitlabhq/gitlab-test",
|
||||||
|
"default_branch":"master",
|
||||||
|
"ci_config_path": null,
|
||||||
|
"homepage":"http://example.com/gitlabhq/gitlab-test",
|
||||||
|
"url":"http://example.com/gitlabhq/gitlab-test.git",
|
||||||
|
"ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
|
||||||
|
"http_url":"http://example.com/gitlabhq/gitlab-test.git"
|
||||||
|
},
|
||||||
|
"object_attributes": {
|
||||||
|
"id": 301,
|
||||||
|
"title": "New API: create/update/delete file",
|
||||||
|
"assignee_ids": [51],
|
||||||
|
"assignee_id": 51,
|
||||||
|
"author_id": 51,
|
||||||
|
"project_id": 14,
|
||||||
|
"created_at": "2013-12-03T17:15:43Z",
|
||||||
|
"updated_at": "2013-12-03T17:15:43Z",
|
||||||
|
"updated_by_id": 1,
|
||||||
|
"last_edited_at": null,
|
||||||
|
"last_edited_by_id": null,
|
||||||
|
"relative_position": 0,
|
||||||
|
"description": "Create new API for manipulations with repository",
|
||||||
|
"milestone_id": null,
|
||||||
|
"state_id": 1,
|
||||||
|
"confidential": false,
|
||||||
|
"discussion_locked": true,
|
||||||
|
"due_date": null,
|
||||||
|
"moved_to_id": null,
|
||||||
|
"duplicated_to_id": null,
|
||||||
|
"time_estimate": 0,
|
||||||
|
"total_time_spent": 0,
|
||||||
|
"time_change": 0,
|
||||||
|
"human_total_time_spent": null,
|
||||||
|
"human_time_estimate": null,
|
||||||
|
"human_time_change": null,
|
||||||
|
"weight": null,
|
||||||
|
"iid": 23,
|
||||||
|
"url": "http://example.com/diaspora/issues/23",
|
||||||
|
"state": "opened",
|
||||||
|
"action": "open",
|
||||||
|
"severity": "high",
|
||||||
|
"escalation_status": "triggered",
|
||||||
|
"escalation_policy": {
|
||||||
|
"id": 18,
|
||||||
|
"name": "Engineering On-call"
|
||||||
|
},
|
||||||
|
"labels": [{
|
||||||
|
"id": 206,
|
||||||
|
"title": "API",
|
||||||
|
"color": "#ffffff",
|
||||||
|
"project_id": 14,
|
||||||
|
"created_at": "2013-12-03T17:15:43Z",
|
||||||
|
"updated_at": "2013-12-03T17:15:43Z",
|
||||||
|
"template": false,
|
||||||
|
"description": "API related issues",
|
||||||
|
"type": "ProjectLabel",
|
||||||
|
"group_id": 41
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"name": "Gitlab Test",
|
||||||
|
"url": "http://example.com/gitlabhq/gitlab-test.git",
|
||||||
|
"description": "Aut reprehenderit ut est.",
|
||||||
|
"homepage": "http://example.com/gitlabhq/gitlab-test"
|
||||||
|
},
|
||||||
|
"assignees": [{
|
||||||
|
"name": "User1",
|
||||||
|
"username": "user1",
|
||||||
|
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
|
||||||
|
}],
|
||||||
|
"assignee": {
|
||||||
|
"name": "User1",
|
||||||
|
"username": "user1",
|
||||||
|
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
|
||||||
|
},
|
||||||
|
"labels": [{
|
||||||
|
"id": 206,
|
||||||
|
"title": "API",
|
||||||
|
"color": "#ffffff",
|
||||||
|
"project_id": 14,
|
||||||
|
"created_at": "2013-12-03T17:15:43Z",
|
||||||
|
"updated_at": "2013-12-03T17:15:43Z",
|
||||||
|
"template": false,
|
||||||
|
"description": "API related issues",
|
||||||
|
"type": "ProjectLabel",
|
||||||
|
"group_id": 41
|
||||||
|
}],
|
||||||
|
"changes": {
|
||||||
|
"updated_by_id": {
|
||||||
|
"previous": null,
|
||||||
|
"current": 1
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"previous": "2017-09-15 16:50:55 UTC",
|
||||||
|
"current": "2017-09-15 16:52:00 UTC"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"previous": [{
|
||||||
|
"id": 206,
|
||||||
|
"title": "API",
|
||||||
|
"color": "#ffffff",
|
||||||
|
"project_id": 14,
|
||||||
|
"created_at": "2013-12-03T17:15:43Z",
|
||||||
|
"updated_at": "2013-12-03T17:15:43Z",
|
||||||
|
"template": false,
|
||||||
|
"description": "API related issues",
|
||||||
|
"type": "ProjectLabel",
|
||||||
|
"group_id": 41
|
||||||
|
}],
|
||||||
|
"current": [{
|
||||||
|
"id": 205,
|
||||||
|
"title": "Platform",
|
||||||
|
"color": "#123123",
|
||||||
|
"project_id": 14,
|
||||||
|
"created_at": "2013-12-03T17:15:43Z",
|
||||||
|
"updated_at": "2013-12-03T17:15:43Z",
|
||||||
|
"template": false,
|
||||||
|
"description": "Platform related issues",
|
||||||
|
"type": "ProjectLabel",
|
||||||
|
"group_id": 41
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
issue_open_payload = json.loads(issue_open_payload_raw)
|
||||||
|
def test_templates():
|
||||||
|
# print(gitlab.messages._loader.list_templates())
|
||||||
|
tpl = hook.messages["test"]
|
||||||
|
args = issue_open_payload | {"util": TemplateUtil}
|
||||||
|
args["templates"] = hook.templates.proxy(args)
|
||||||
|
assert tpl.render(**args) == "<strong data-mautrix-exclude-plaintext>[<a data-mautrix-exclude-plaintext href=\"http://example.com/gitlabhq/gitlab-test\">gitlabhq/gitlab-test</a>]</strong> <a data-mautrix-exclude-plaintext href=\"\">root</a>"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hook():
|
||||||
|
r = await hook.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] == "<strong data-mautrix-exclude-plaintext>[<a data-mautrix-exclude-plaintext href=\"http://example.com/gitlabhq/gitlab-test\">gitlabhq/gitlab-test</a>]</strong> <a data-mautrix-exclude-plaintext href=\"http://example.com/root\">root</a>\n opened <a href=\"http://example.com/diaspora/issues/23\" >issue #23</a>: New API: create/update/delete file<br/>\n <blockquote><p>Create new API for manipulations with repository</p>\n</blockquote>\n <span data-mx-color=\"#000000\"\n data-mx-bg-color=\"#ffffff\"\n title=\"API related issues\"\n > API </span>"
|
||||||
|
|
@ -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"
|
"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:
|
async def test_aws_sns_notification() -> None:
|
||||||
r = aws.parse_sns_event(json.loads(sns_notification))
|
r = await aws.parse_sns_event(None, json.loads(sns_notification), None)
|
||||||
assert r[0] == "My First Message\nHello world!"
|
assert r[0][0] == "My First Message\nHello world!"
|
||||||
assert r[1] == "<strong><font color=#dc3545>My First Message</font></strong>\n<p>Hello world!</p>"
|
assert r[0][1] == "<strong><font color=#dc3545>My First Message</font></strong>\n<p>Hello world!</p>"
|
||||||
|
|
||||||
def test_aws_sns_subscribe() -> None:
|
async def test_aws_sns_subscribe() -> None:
|
||||||
r = aws.parse_sns_event(json.loads(sns_subscribtion_confirm))
|
r = await aws.parse_sns_event(None, json.loads(sns_subscribtion_confirm), None)
|
||||||
print(r)
|
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...'
|
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:
|
async def test_aws_sns_unsubscribe() -> None:
|
||||||
r = aws.parse_sns_event(json.loads(sns_subscribtion_unsubscribe))
|
r = await aws.parse_sns_event(None, json.loads(sns_subscribtion_unsubscribe), None)
|
||||||
print(r)
|
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...'
|
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)
|
assert r[0] == (expected, expected)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue