Merge branch 'feat/gitlab' into 'main'

Add gitlab webhook support

See merge request guardianproject-ops/matrix-ops-bot!1
This commit is contained in:
Abel Luck 2022-12-01 16:38:26 +00:00
commit fef0818535
34 changed files with 2177 additions and 105 deletions

2
.gitignore vendored
View file

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

View file

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

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

View file

@ -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 .
@ -90,4 +110,8 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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
View 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
}
}

View file

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

View file

@ -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:

View file

@ -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)

View file

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

@ -0,0 +1,62 @@
# Based on https://github.com/gsnedders/wcag-contrast-ratio
# Copyright (c) 2015 Geoffrey Sneddon
# Copyright (c) 2019 Tulir Asokan
# MIT license
from typing import Tuple
RGB = Tuple[float, float, float]
def hex_to_rgb(color: str) -> RGB:
color = color.lstrip("#")
if len(color) != 3 and len(color) != 6:
raise ValueError("Invalid hex length")
step = 1 if len(color) == 3 else 2
try:
r = int(color[0:step], 16)
g = int(color[step : 2 * step], 16)
b = int(color[2 * step : 3 * step], 16)
except ValueError as e:
raise ValueError("Invalid hex value") from e
return r / 255, g / 255, b / 255
def rgb_to_hex(rgb: RGB) -> str:
r, g, b = rgb
r = int(r * 255)
g = int(g * 255)
b = int(b * 255)
return f"{r:02x}{g:02x}{b:02x}"
def contrast(rgb1: RGB, rgb2: RGB) -> float:
for r, g, b in (rgb1, rgb2):
if not 0.0 <= r <= 1.0:
raise ValueError(f"r {r} is out of valid range (0.0 - 1.0)")
if not 0.0 <= g <= 1.0:
raise ValueError(f"g {g} is out of valid range (0.0 - 1.0)")
if not 0.0 <= b <= 1.0:
raise ValueError(f"b {b} is out of valid range (0.0 - 1.0)")
l1 = _relative_luminance(*rgb1)
l2 = _relative_luminance(*rgb2)
if l1 > l2:
return (l1 + 0.05) / (l2 + 0.05)
else:
return (l2 + 0.05) / (l1 + 0.05)
def _relative_luminance(r: float, g: float, b: float) -> float:
r = _linearize(r)
g = _linearize(g)
b = _linearize(b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def _linearize(v: float) -> float:
if v <= 0.03928:
return v / 12.92
else:
return float(((v + 0.055) / 1.055) ** 2.4)

38
ops_bot/util/markdown.py Normal file
View 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("<", "&lt;").replace(">", "&gt;"))
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
View 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
View file

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

View file

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

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

View file

@ -0,0 +1,75 @@
{%- macro repo_link(project) -%}
<a data-mautrix-exclude-plaintext href="{{ project.web_url }}">{{ project.path_with_namespace|e }}</a>
{%- endmacro -%}
{%- macro user_link(user) -%}
<a data-mautrix-exclude-plaintext href="{{ user.web_url }}">{{ user.username|e }}</a>
{%- endmacro -%}
{%- macro pluralize(value) -%}
{% if value != 1 %}s{% endif %}
{%- endmacro -%}
{%- macro issue_link(issue, title = true, important = true) -%}
<a href="{{ issue.url }}" {% if not important %}data-mautrix-exclude-plaintext{% endif %}>
{%- if issue.confidential and (not changes or not changes.confidential) -%}confidential {% endif -%}
issue #{{ issue.issue_id -}}
</a>
{%- if title -%}
: {{ issue.title|e -}}
{% endif %}
{%- endmacro -%}
{%- macro merge_request_link(merge_request, title = true, important = true) -%}
<a href="{{ merge_request.url }}" {% if not important %}data-mautrix-exclude-plaintext{% endif %}>
merge request !{{ merge_request.merge_request_id -}}
</a>
{%- if title -%}
: {{ merge_request.title|e -}}
{% endif %}
{%- endmacro -%}
{%- macro issue_or_merge_link(attrs, title = true, important = true) -%}
{%- if attrs.issue_id -%}
{{- issue_link(attrs, title, important) -}}
{%- elif attrs.merge_request_id -%}
{{- merge_request_link(attrs, title, important) -}}
{%- else -%}
unknown object {{ attrs.title|e }}
{%- endif -%}
{%- endmacro -%}
{%- macro fancy_label(label) -%}
<span data-mx-color="{{ label.foreground_color }}"
data-mx-bg-color="{{ label.color }}"
title="{{ label.description }}"
>&nbsp;{{ util.bold_scope(label.title) }}&nbsp;</span>
{%- endmacro -%}
{%- macro fancy_labels(labels) -%}
{% for label in labels %}
{{ fancy_label(label) }}
{% endfor %}
{%- endmacro -%}
{%- macro list_changes(added, removed, add_word, remove_word, mutate) -%}
{%- if added -%}
{{ add_word }} {{ util.join_human_list(added, mutate=mutate) }}
{% if not removed %}
to
{% endif %}
{%- endif -%}
{%- if removed -%}
{%- if added %}
and
{% endif -%}
{{ remove_word }} {{ util.join_human_list(removed, mutate=mutate) }}
from
{%- endif -%}
{%- endmacro -%}
{%- macro label_changes(added, removed) -%}
{{ list_changes(added, removed, "added", "removed", fancy_label) }}
{%- endmacro -%}
{%- macro assignee_changes(added, removed) -%}
{{ list_changes(added, removed, "assigned", "unassigned", user_link) }}
{%- endmacro -%}

View file

@ -0,0 +1,20 @@
{{ templates.repo_sender_prefix }}
<a data-mautrix-exclude-plaintext href="{{ object_attributes.url }}">
{%- if object_attributes.type == CommentType.DISCUSSION_NOTE -%}
replied to a thread
{%- else -%}
commented
{%- endif -%}
</a> on
{% if issue and object_attributes.noteable_type == NoteableType.ISSUE %}
{{ issue_link(issue, important=false) }}
{% elif merge_request and object_attributes.noteable_type == NoteableType.MERGE_REQUEST %}
{{ merge_request_link(merge_request, important=false) }}
{% else %}
{# unsupported comment target #}
{% do abort() %}
{% endif %}
<br/>
{% if object_attributes.description %}
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
{% endif %}

View file

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

View file

@ -0,0 +1,6 @@
{{ templates.repo_sender_prefix }}
opened {{ issue_link(object_attributes) }}<br/>
{% if object_attributes.description %}
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
{% endif %}
{{ fancy_labels(object_attributes.labels) }}

View file

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

View file

@ -0,0 +1,77 @@
{{ templates.repo_sender_prefix }}
{% if changes.labels %}
{{ label_changes(changes.labels.added, changes.labels.removed) }}
{{ issue_or_merge_link(object_attributes) }}
{# Milestone webhooks don't have the milestone displayname 3:< #}
{#{% elif changes.milestone_id %}#}
{# {% if not changes.milestone_id.current %}#}
{# removed the milestone from {{ issue_or_merge_link(object_attributes) }}#}
{# {% else %}#}
{# added {{ issue_or_merge_link(object_attributes) }} to milestone#}
{# {% endif %}#}
{% elif changes.assignees %}
{{ assignee_changes(changes.assignees.added, changes.assignees.removed) }}
{{ issue_or_merge_link(object_attributes) }}
{% elif changes.time_estimate %}
{% if not changes.time_estimate.current %}
removed the time estimate of {{ issue_or_merge_link(object_attributes) }}
{% elif not changes.time_estimate.previous %}
set the time estimate of {{ issue_or_merge_link(object_attributes) }} to
<strong>{{ util.format_time(changes.time_estimate.current) }}</strong>
{% else %}
{% if changes.time_estimate.current > changes.time_estimate.previous %}
increased
{% else %}
decreased
{% endif %}
the time estimate of {{ issue_or_merge_link(object_attributes) }} by
<strong>{{ util.format_time(changes.time_estimate.current - changes.time_estimate.previous) }}</strong>
{% endif %}
{% elif changes.total_time_spent %}
{% if not changes.total_time_spent.current %}
removed the time spent
{% else %}
{% if changes.total_time_spent.current > (changes.total_time_spent.previous or 0) %}
spent <strong>{{ util.format_time(changes.total_time_spent.current - (changes.total_time_spent.previous or 0)) }}</strong>
{% else %}
subtracted <strong>{{ util.format_time(changes.total_time_spent.current - changes.total_time_spent.previous) }}</strong>
from the time spent
{% endif %}
{% endif %}
on {{ issue_or_merge_link(object_attributes) }}
{% elif changes.weight %}
{% if not changes.weight.current %}
removed
{% else %}
changed
{% endif %}
the weight of {{ issue_or_merge_link(object_attributes) }}
{% if changes.weight.current %}
to <strong>{{ changes.weight.current }}</strong>
{% endif %}
{% elif changes.due_date %}
{% if not changes.due_date.current %}
removed the due date of {{ issue_or_merge_link(object_attributes) }}
{% else %}
set the due date of {{ issue_or_merge_link(object_attributes) }}
to <strong>{{ changes.due_date.current.strftime("%B %d, %Y") }}</strong>
{% endif %}
{% elif changes.confidential %}
made {{ issue_or_merge_link(object_attributes) }}
{% if changes.confidential.current %}
confidential
{% else %}
non-confidential
{% endif %}
{% elif changes.discussion_locked %}
{% if changes.discussion_locked.current %}
locked discussion in
{% else %}
unlocked discussion in
{% endif %}
{{ issue_or_merge_link(object_attributes) }}
{% elif changes.title %}
changed the title of {{ issue_or_merge_link(object_attributes, title=false) }} to {{ changes.title.current }}
{% else %}
{% do abort() %}
{% endif %}

View file

@ -0,0 +1,9 @@
{% if build_status == BuildStatus.FAILED and not build_allow_failure %}
<strong>[<a data-mautrix-exclude-plaintext href="{{ repository.homepage }}">{{ repository.path|e }}</a>]</strong>
<a href="{{ build_url }}">Job {{ build_id }}: {{ build_name }}</a>
<strong><font color="#ff0000">failed</font></strong>
after {{ util.format_time(build_duration) }}
(build triggered by {{ user_link(user) }})
{% else %}
{% do abort() %}
{% endif %}

View file

@ -0,0 +1,11 @@
{{ templates.repo_sender_prefix }}
{% if action == OPEN %}
opened {{ merge_request_link(object_attributes) }}
{% if object_attributes.description %}
<blockquote>{{ object_attributes.description|markdown }}</blockquote>
{% endif %}
{{ fancy_labels(labels) }}
{% else %}
{{ object_attributes.action.past_tense }}
{{ merge_request_link(object_attributes) }}
{% endif %}

View file

@ -0,0 +1,27 @@
{{ templates.repo_sender_prefix }}
{% if is_deleted_ref %}
deleted branch
{% else %}
pushed
<a href="{{ diff_url }}" data-mautrix-exclude-plaintext>
{{- total_commits_count }} commit{{ pluralize(total_commits_count) -}}
</a>
to
{% endif %}
<a data-mautrix-exclude-plaintext href="{{ ref_url }}">{{ ref_name }}</a>
{%- if is_new_ref %} (new branch){% endif -%}
{%- if commits|length > 0 %}:
<ul>
{% for commit in commits[-5:] %}
<li>
<code><a data-mautrix-exclude-plaintext href="{{ commit.url }}">
{{- commit.id[:8] -}}
</a></code>
{{ commit.cut_message|e }}
{% if commit.author.name != user_name and commit.author.name != user_username %}
by {{ commit.author.name }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif -%}

View file

@ -0,0 +1,13 @@
{{ templates.repo_sender_prefix }}
{% if is_deleted_ref %}
deleted tag
{{ ref_name }}
{% else %}
created tag
<a data-mautrix-exclude-plaintext href="{{ ref_url }}">{{ ref_name }}</a>
{% endif %}
{%- if message -%}:
<blockquote>
{{ message | markdown }}
</blockquote>
{%- endif -%}

View file

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

View file

@ -0,0 +1,5 @@
{{ templates.repo_sender_prefix }}
{{ "edited" if object_attributes.action == UPDATE else object_attributes.action.past_tense }}
<a href="{{ object_attributes.url }}">{{ object_attributes.title }}</a>
on the wiki
{%- if object_attributes.message -%}: {{ object_attributes.message }}{% endif %}

View file

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

163
tests/test_gitlab.py Normal file
View file

@ -0,0 +1,163 @@
import json
from ops_bot.gitlab import hook
from ops_bot.util.template import TemplateUtil
issue_open_payload_raw = """
{
"object_kind": "issue",
"event_type": "issue",
"user": {
"id": 1,
"name": "Administrator",
"username": "root",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon",
"email": "admin@example.com"
},
"project": {
"id": 1,
"name":"Gitlab Test",
"description":"Aut reprehenderit ut est.",
"web_url":"http://example.com/gitlabhq/gitlab-test",
"avatar_url":null,
"git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
"git_http_url":"http://example.com/gitlabhq/gitlab-test.git",
"namespace":"GitlabHQ",
"visibility_level":20,
"path_with_namespace":"gitlabhq/gitlab-test",
"default_branch":"master",
"ci_config_path": null,
"homepage":"http://example.com/gitlabhq/gitlab-test",
"url":"http://example.com/gitlabhq/gitlab-test.git",
"ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
"http_url":"http://example.com/gitlabhq/gitlab-test.git"
},
"object_attributes": {
"id": 301,
"title": "New API: create/update/delete file",
"assignee_ids": [51],
"assignee_id": 51,
"author_id": 51,
"project_id": 14,
"created_at": "2013-12-03T17:15:43Z",
"updated_at": "2013-12-03T17:15:43Z",
"updated_by_id": 1,
"last_edited_at": null,
"last_edited_by_id": null,
"relative_position": 0,
"description": "Create new API for manipulations with repository",
"milestone_id": null,
"state_id": 1,
"confidential": false,
"discussion_locked": true,
"due_date": null,
"moved_to_id": null,
"duplicated_to_id": null,
"time_estimate": 0,
"total_time_spent": 0,
"time_change": 0,
"human_total_time_spent": null,
"human_time_estimate": null,
"human_time_change": null,
"weight": null,
"iid": 23,
"url": "http://example.com/diaspora/issues/23",
"state": "opened",
"action": "open",
"severity": "high",
"escalation_status": "triggered",
"escalation_policy": {
"id": 18,
"name": "Engineering On-call"
},
"labels": [{
"id": 206,
"title": "API",
"color": "#ffffff",
"project_id": 14,
"created_at": "2013-12-03T17:15:43Z",
"updated_at": "2013-12-03T17:15:43Z",
"template": false,
"description": "API related issues",
"type": "ProjectLabel",
"group_id": 41
}]
},
"repository": {
"name": "Gitlab Test",
"url": "http://example.com/gitlabhq/gitlab-test.git",
"description": "Aut reprehenderit ut est.",
"homepage": "http://example.com/gitlabhq/gitlab-test"
},
"assignees": [{
"name": "User1",
"username": "user1",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
}],
"assignee": {
"name": "User1",
"username": "user1",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
},
"labels": [{
"id": 206,
"title": "API",
"color": "#ffffff",
"project_id": 14,
"created_at": "2013-12-03T17:15:43Z",
"updated_at": "2013-12-03T17:15:43Z",
"template": false,
"description": "API related issues",
"type": "ProjectLabel",
"group_id": 41
}],
"changes": {
"updated_by_id": {
"previous": null,
"current": 1
},
"updated_at": {
"previous": "2017-09-15 16:50:55 UTC",
"current": "2017-09-15 16:52:00 UTC"
},
"labels": {
"previous": [{
"id": 206,
"title": "API",
"color": "#ffffff",
"project_id": 14,
"created_at": "2013-12-03T17:15:43Z",
"updated_at": "2013-12-03T17:15:43Z",
"template": false,
"description": "API related issues",
"type": "ProjectLabel",
"group_id": 41
}],
"current": [{
"id": 205,
"title": "Platform",
"color": "#123123",
"project_id": 14,
"created_at": "2013-12-03T17:15:43Z",
"updated_at": "2013-12-03T17:15:43Z",
"template": false,
"description": "Platform related issues",
"type": "ProjectLabel",
"group_id": 41
}]
}
}
}"""
issue_open_payload = json.loads(issue_open_payload_raw)
def test_templates():
# print(gitlab.messages._loader.list_templates())
tpl = hook.messages["test"]
args = issue_open_payload | {"util": TemplateUtil}
args["templates"] = hook.templates.proxy(args)
assert tpl.render(**args) == "<strong data-mautrix-exclude-plaintext>[<a data-mautrix-exclude-plaintext href=\"http://example.com/gitlabhq/gitlab-test\">gitlabhq/gitlab-test</a>]</strong> <a data-mautrix-exclude-plaintext href=\"\">root</a>"
async def test_hook():
r = await hook.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 >&nbsp;API&nbsp;</span>"

View file

@ -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)