Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: TicClick/librarian
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v2.1
Choose a base ref
...
head repository: TicClick/librarian
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref
Loading
Showing with 841 additions and 290 deletions.
  1. +0 −11 .github/dependabot.yml
  2. +1 −1 .github/workflows/run-tests.yml
  3. +8 −57 README.md
  4. +7 −5 alembic/env.py
  5. +30 −0 alembic/versions/424c7bd5c88c_remove_file_count_columns.py
  6. +2 −1 alembic/versions/c45dc1355fd1_add_assignees.py
  7. +3 −3 alembic/versions/fae673b8e597_create_all_tables.py
  8. BIN {media/repo → docs/images}/screenshot.png
  9. +52 −0 docs/selfhosting.md
  10. +3 −2 librarian/config.py
  11. +38 −6 librarian/discord/bot.py
  12. +51 −8 librarian/discord/cogs/background/base.py
  13. +94 −39 librarian/discord/cogs/background/github.py
  14. +38 −8 librarian/discord/cogs/helpers.py
  15. +17 −7 librarian/discord/cogs/pulls.py
  16. +31 −17 librarian/discord/cogs/server.py
  17. +31 −2 librarian/discord/cogs/system.py
  18. +8 −0 librarian/discord/errors.py
  19. +2 −2 librarian/discord/languages/__init__.py
  20. +1 −1 librarian/discord/languages/base.py
  21. +31 −0 librarian/discord/languages/special.py
  22. +2 −1 librarian/discord/settings/custom.py
  23. +2 −11 librarian/github.py
  24. +1 −2 librarian/main.py
  25. +13 −0 librarian/storage/models/discord.py
  26. +11 −13 librarian/storage/models/pull.py
  27. +13 −5 librarian/storage/utils.py
  28. +15 −0 librarian/types.py
  29. +1 −0 pytest.ini
  30. +12 −11 requirements.txt
  31. +52 −25 tests/conftest.py
  32. +45 −0 tests/discord/cogs/background/test_base.py
  33. +31 −10 tests/discord/cogs/background/test_github.py
  34. +4 −4 tests/discord/cogs/test_helpers.py
  35. +3 −3 tests/discord/cogs/test_server.py
  36. +45 −0 tests/discord/cogs/test_system.py
  37. +9 −3 tests/discord/languages/test_base.py
  38. +35 −0 tests/discord/languages/test_special.py
  39. +7 −0 tests/discord/settings/test_custom.py
  40. +24 −0 tests/storage/models/test_discord.py
  41. +8 −4 tests/storage/models/test_pull.py
  42. +3 −1 tests/storage/test_storage.py
  43. +37 −0 tests/storage/test_utils.py
  44. +19 −14 tests/test_github.py
  45. +1 −13 tests/utils.py
11 changes: 0 additions & 11 deletions .github/dependabot.yml

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
@@ -34,4 +34,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics
- name: Test with pytest
run: |
pytest
pytest --test-alembic
65 changes: 8 additions & 57 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Librarian

![test](media/repo/screenshot.png)
![test](docs/images/screenshot.png)

## overview

a Discord bot that tracks new pull requests of the [ppy/osu-wiki](https://github.com/ppy/osu-wiki) repository. it's a GitHub web hook, except not really:

- it's not a GitHub web hook, it's a chat bot you need to host or invite
- it's not a GitHub web hook, it's a chat bot you need to invite to your server
- to spin up your own installation, see [`selfhosting.md`](docs/selfhosting.md)
- it can be repurposed for another repository you don't own (as well as `ppy/osu-wiki`)
- it's stateful (has its own local database to work around API slowness)
- it has latency (up to 2 minutes in a worst case scenario)
@@ -20,69 +21,19 @@ a Discord bot that tracks new pull requests of the [ppy/osu-wiki](https://github

## usage

- [add the bot to your server](https://discord.com/api/oauth2/authorize?client_id=742750842737655830&permissions=11264&scope=bot)
- set up a channel for announcements using the `.set` command:
*(a cordial reminder is that the bot must be allowed to post text messages if you expect it to be of any use)*

- [![add the bot](https://img.shields.io/badge/-invite%20Librarian%20-718efc?style=flat)](https://discord.com/api/oauth2/authorize?client_id=742750842737655830&permissions=11264&scope=bot) to your server
- set up a channel for announcements using the `.set` command (available for server owner/admins/managers):
```
.set language ru
.set reviewer-role @role_mention # optional, if you want to receive pings
```
- use `.help` for general overview
- use `.help commandname` for details on `commandname`
## host your own installation
### credentials and setup
requirements:
- `python3` and `git`
- `tmux` if you want to run it unattended
1. [create a Discord application](https://discord.com/developers/applications) and add a bot account to it.
2. add the bot to your server using a modified version of an OAuth2 authorization link from [Bot Authorization Flow](https://discord.com/developers/docs/topics/oauth2#bot-authorization-flow).
3. clone the repository:
```bash
git clone https://github.com/TicClick/librarian
```
create a modified version of `config/config.example.yaml` and fill in whatever data you need. to benefit from GitHub's extended API limits, query it using an API token (get one at [Personal access tokens](https://github.com/settings/tokens))
4. setup and run the bot:
```bash
./bin.sh setup
tmux new -d -s librarian-bot "./bin.sh run --config /path/to/config"
```
### maintenance
stop the bot:
```bash
tmux kill-session -t librarian
```

update to the last stable version (make sure to stop the bot beforehand):

```bash
git fetch && git checkout main
git pull origin main
git checkout $( git tag --list --sort=v:refname | tail -n 1 )
```

for anything else, use `bin.sh` from the source directory:

```bash
./bin.sh setup # install all dependencies
./bin.sh run --config /path/to/config.yaml # start the bot
./bin.sh clean # remove virtual environment and Python bytecode cache
./bin.sh test # run unit tests with pytest
./bin.sh test -x -k TestDiscordCommands # stop on the first failure of a test suite
./bin.sh coverage # generate coverage data
./bin.sh cov # print coverage stats in terminal
./bin.sh hcov # render and open a nice HTML with coverage stats
./bin.sh db --config /path/to/config upgrade head # run available schema migrations
```

if anything goes wrong, make extensive use of a runtime log located at `{runtime}/librarian.log`

## credits
- bot avatar by [@drstrange777](https://twitter.com/drstrange777)
- PR status icons by [some GitHub teams](https://github.com/primer/octicons)
- see `requirements.txt` for a list of cool packages
12 changes: 7 additions & 5 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -65,11 +65,13 @@ def run_migrations_online():
and associate a connection with the context.
"""
connectable = engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
connectable = context.config.attributes.get("connection", None)
if connectable is None:
connectable = engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(
30 changes: 30 additions & 0 deletions alembic/versions/424c7bd5c88c_remove_file_count_columns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
remove_file_count_columns
Revision ID: 424c7bd5c88c
Revises: 5de1a9f1e6fa
Create Date: 2021-04-17 00:52:45.990998
"""

from alembic import op
import sqlalchemy as sql


# revision identifiers, used by Alembic.
revision = '424c7bd5c88c'
down_revision = '5de1a9f1e6fa'
branch_labels = None
depends_on = None


def upgrade():
# SQLite workarounds for ALTER TABLE: https://alembic.sqlalchemy.org/en/latest/batch.html
with op.batch_alter_table("pulls") as batch_op:
batch_op.drop_column("added_files")
batch_op.drop_column("deleted_files")


def downgrade():
with op.batch_alter_table("pulls") as batch_op:
batch_op.add_column(sql.Column("added_files", sql.Integer, default=0))
batch_op.add_column(sql.Column("deleted_files", sql.Integer, default=0))
3 changes: 2 additions & 1 deletion alembic/versions/c45dc1355fd1_add_assignees.py
Original file line number Diff line number Diff line change
@@ -22,4 +22,5 @@ def upgrade():


def downgrade():
op.drop_column("pulls", "assignees_logins")
with op.batch_alter_table("pulls") as batch_op:
batch_op.drop_column("assignees_logins")
6 changes: 3 additions & 3 deletions alembic/versions/fae673b8e597_create_all_tables.py
Original file line number Diff line number Diff line change
@@ -45,9 +45,9 @@ class Pull(Base):
user_login = sql.Column(sql.String(USER_LOGIN_LEN), nullable=False)
user_id = sql.Column(sql.Integer, nullable=False)

added_files = sql.Column(sql.Integer, nullable=False, default=0)
deleted_files = sql.Column(sql.Integer, nullable=False, default=0)
changed_files = sql.Column(sql.Integer, nullable=False, default=0)
added_files = sql.Column(sql.Integer, default=0)
deleted_files = sql.Column(sql.Integer, default=0)
changed_files = sql.Column(sql.Integer, default=0)

discord_messages = orm.relationship(
"DiscordMessage", order_by="DiscordMessage.id", back_populates="pull", lazy="joined"
File renamed without changes
52 changes: 52 additions & 0 deletions docs/selfhosting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## host your own installation

### credentials and setup

requirements:
- `python3` and `git`
- `tmux` if you want to run it unattended

1. [create a Discord application](https://discord.com/developers/applications) and add a bot account to it.
2. add the bot to your server using a modified version of an OAuth2 authorization link from [Bot Authorization Flow](https://discord.com/developers/docs/topics/oauth2#bot-authorization-flow).
3. clone the repository:
```bash
git clone https://github.com/TicClick/librarian
```
create a modified version of `config/config.example.yaml` and fill in whatever data you need. to benefit from GitHub's extended API limits, query it using an API token (get one at [Personal access tokens](https://github.com/settings/tokens))
4. setup and run the bot:
```bash
./bin.sh setup
tmux new -d -s librarian-bot "./bin.sh run --config /path/to/config"
```
### maintenance
stop the bot:
```bash
tmux kill-session -t librarian
```
update to the last stable version (make sure to stop the bot beforehand):
```bash
git fetch && git checkout main
git pull origin main
git checkout $( git tag --list --sort=v:refname | tail -n 1 )
```
for anything else, use `bin.sh` from the source directory:
```bash
./bin.sh setup # install all dependencies
./bin.sh run --config /path/to/config.yaml # start the bot
./bin.sh clean # remove virtual environment and Python bytecode cache
./bin.sh test # run unit tests with pytest
./bin.sh test -x -k TestDiscordCommands # stop on the first failure of a test suite
./bin.sh coverage # generate coverage data
./bin.sh cov # print coverage stats in terminal
./bin.sh hcov # render and open a nice HTML with coverage stats
./bin.sh db --config /path/to/config upgrade head # run available schema migrations
```
if anything goes wrong, make extensive use of a runtime log located at `{runtime}/librarian.log`
5 changes: 3 additions & 2 deletions librarian/config.py
Original file line number Diff line number Diff line change
@@ -3,13 +3,14 @@
import yaml


def load(config_path):
def load(config_path, verbose=False):
if not config_path:
raise RuntimeError("Config path is empty")
if config_path.endswith(".example.yaml"):
raise RuntimeError("Can't use example config, see the note on its first line")

print(f"Loading config from {config_path}")
if verbose:
print(f"Loading config from {config_path}")
with open(config_path, "r") as fd:
config = yaml.safe_load(fd)

44 changes: 38 additions & 6 deletions librarian/discord/bot.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import asyncio
import logging
import typing

import discord
import discord.errors
from discord.ext import commands
import discord.ext.commands.errors as commands_errors

from librarian import types
from librarian import github as gh
from librarian import storage as stg

from librarian.discord import errors
from librarian.discord.cogs import (
pulls,
server,
@@ -23,7 +31,8 @@ class Client(commands.Bot):
KILL_TIMEOUT = 10

def __init__(
self, *args, github=None, storage=None, assignee_login=None,
self, *args, github: gh.GitHub = None, storage: stg.Storage = None,
assignee_login: typing.Optional[str] = None,
**kwargs
):
self.github = github
@@ -33,6 +42,25 @@ def __init__(

super().__init__(*args, command_prefix=self.COMMAND_PREFIX, **kwargs)

async def _on_command_error_inner(self, ctx: types.Context, exception: Exception):
if isinstance(exception, commands_errors.CommandNotFound):
return await ctx.message.channel.send(
content="no such command `{}` -- try `{}help` instead".format(
ctx.invoked_with, self.COMMAND_PREFIX
)
)
else:
return await super().on_command_error(ctx, exception)

async def on_command_error(self, ctx, exception):
try:
return await self._on_command_error_inner(ctx, exception)
except discord.errors.DiscordException as new_exc:
logger.info(
"Failed to notify %r in #%d about the error %r: %s",
ctx.message.author, ctx.message.channel.id, exception, new_exc
)

def setup(self):
self.add_cog(pulls.Pulls())
self.add_cog(system.System())
@@ -53,22 +81,26 @@ async def on_ready(self):
await self.start_routines()

async def post_or_update(self, channel_id, message_id=None, content=None, embed=None):
channel = self.get_channel(channel_id)
if channel is None:
channel = await self.fetch_channel(channel_id)
try:
channel = self.get_channel(channel_id)
if channel is None:
channel = await self.fetch_channel(channel_id)
except discord.errors.NotFound:
raise errors.NoDiscordChannel(channel_id)

if message_id is None:
message = await channel.send(content=content, embed=embed) # type: discord.Message
logger.debug("New message #%s created in #%s", message.id, channel.id)
return message

else:
message = None
try:
logger.debug("Updating existing message #%s", message_id)
logger.debug("Reading/updating existing message #%s", message_id)
message = await channel.fetch_message(message_id) # type: discord.Message
except discord.NotFound:
logger.error("Message #%s wasn't found", message_id)
message = None
self.storage.discord.delete_message(message_id, channel_id)
else:
await message.edit(embed=embed)
finally:
Loading