Skip to content

Commit 3faa47d

Browse files
committed
feat: Create server and bot
1 parent f001bff commit 3faa47d

17 files changed

Lines changed: 1804 additions & 0 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
DISCORD_BOT_TOKEN=your-discord-bot-token
2+
FORUM_CHANNEL_ID=your-forum-channel-id
3+
GITHUB_TOKEN=your-github-token
4+
GITHUB_PROJECT_NODE_ID=PVT_kwDOC3sox84A9Lxx
5+
GITHUB_USERNAME_TO_DISCORD_ID_MAPPING_PATH=path-to-github-username-to-discord-id-mapping.json
6+
IP_ADDRESS=0.0.0.0:8000

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,9 @@ cython_debug/
205205
marimo/_static/
206206
marimo/_lsp/
207207
__marimo__/
208+
209+
.idea/
210+
211+
# Shelves databases
212+
item_name_to_node_id.db
213+
post_id.db

.pre-commit-config.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
repos:
2+
# Run lint
3+
- repo: https://github.com/astral-sh/ruff-pre-commit
4+
# Ruff version.
5+
rev: v0.9.9
6+
hooks:
7+
# Run the linter.
8+
- id: ruff
9+
# Run the formatter.
10+
- id: ruff-format
11+
# Run the tests.
12+
- repo: local
13+
hooks:
14+
- id: pytest
15+
name: pytest
16+
entry: ./.venv/bin/pytest
17+
language: system
18+
types: [python]
19+
pass_filenames: false
20+
always_run: true

Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.12-slim-trixie
2+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3+
4+
ADD src /app
5+
6+
WORKDIR /app
7+
RUN uv sync --locked
8+
9+
CMD ["uv", "run", "src"]
10+
EXPOSE 8000

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# <picture> <source srcset="https://raw.githubusercontent.com/Hack4Krak/Hack4KrakSite/refs/heads/master/.github/assets/banner-light.png" media="(prefers-color-scheme: dark)"/> <img src="https://raw.githubusercontent.com/Hack4Krak/Hack4KrakSite/refs/heads/master/.github/assets/banner-dark.png" /> </picture>
2+
3+
## GitHub Projects Discord Bot
4+
5+
This repository provides and integration between [Discord](https://discord.com/) and [GitHub Projects](https://https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects),
6+
allowing users to discuss GitHub Projects directly from Discord. Whenever a new issue, pull request or draft issue is added
7+
to the project, bot creates a new thread in the specified Discord channel. Every update to the project item is then communicated
8+
in the thread, allowing users to stay up-to-date with the latest changes.
9+
10+
## 🚜 Development
11+
12+
This repository contains two main components:
13+
- [`server.py`](/src/server.py): listens for GitHub webhook events and processes them
14+
- [`bot.py`](/src/bot.py): a Discord bot that creates threads and posts updates
15+
16+
For local development, you can copy the `.env.example` file to `.env` and fill in the first three environment variables.
17+
Then run the following command to install dependencies:
18+
19+
```bash
20+
uv sync
21+
```
22+
23+
To run the server and bot locally, you can use the following command:
24+
25+
```bashbash
26+
uv run start-app
27+
```
28+
29+
## 🚀 Deployment
30+
31+
For deployment follow these steps:
32+
- set all environment variables accordingly,
33+
- update your `github_usernames_to_discord_id_mapping.json`,
34+
- set up a webhook in your GitHub repository to point to your server's `/webhook_endpoint` endpoint,
35+
- use Dockerfile to build the image.
36+
37+
## ⚒️ How it works
38+
39+
1. GitHub sends a webhook event to the server when an issue, pull request or draft issue is added or updated in the project.
40+
2. The server processes the event and extracts relevant information, such as the issue title, description
41+
3. The server updates shared state with the new information which is then used by the bot to post updates.
42+
4. The bot creates a new thread in the specified Discord channel for new issues, pull requests or draft issues.
43+
5. The bot posts updates in the thread whenever the issue, pull request or draft issue is updated.
44+
6. The bot uses the `github_usernames_to_discord_id_mapping.json` file to map GitHub usernames to Discord user IDs,
45+
allowing it to mention users in the thread.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"Kubaryt": "786956683871387698",
3+
"Norbiros": "770620808644919307"
4+
}

pyproject.toml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
[project]
2+
name = "githubprojectdiscordbot"
3+
version = "1.0.0"
4+
description = "Create post on forum channel for github project cards"
5+
requires-python = ">=3.13"
6+
dependencies = [
7+
"discord-py>=2.6.3",
8+
"dotenv>=0.9.9",
9+
"fastapi[standard]>=0.117.1",
10+
"pre-commit>=4.3.0",
11+
"pytest>=8.4.2",
12+
"requests>=2.32.5",
13+
"ruff>=0.13.1",
14+
"setuptools>=80.9.0",
15+
]
16+
17+
[build-system]
18+
requires = ["setuptools"]
19+
build-backend = "setuptools.build_meta"
20+
21+
[tool.setuptools]
22+
packages = ["src"]
23+
24+
[project.optional-dependencies]
25+
test = ["pytest"]
26+
27+
[tool.pytest.ini_options]
28+
testpaths = ["src/tests"]
29+
30+
[project.scripts]
31+
start-app = "src.main:main"
32+
33+
[tool.ruff]
34+
line-length = 120
35+
preview = true
36+
37+
[tool.ruff.lint]
38+
select = [
39+
"E", # pycodestyle errors
40+
"W", # pycodestyle warnings
41+
"F", # pyflakes
42+
"I", # isort
43+
"B", # flake8-bugbear
44+
"C4", # flake8-comprehensions
45+
"UP", # pyupgrade (modern Python syntax)
46+
"N", # PEP8 Naming
47+
]

src/__init__.py

Whitespace-only changes.

src/bot.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import asyncio
2+
import os
3+
from copy import deepcopy
4+
from threading import Lock
5+
6+
import discord
7+
8+
from src.utils.data_types import (
9+
ProjectItemEditedAssignees,
10+
ProjectItemEditedBody,
11+
ProjectItemEditedSingleSelect,
12+
ProjectItemEditedTitle,
13+
ProjectItemEvent,
14+
SimpleProjectItemEvent,
15+
)
16+
from src.utils.error import ForumChannelNotFound
17+
from src.utils.utils import add_tag_to_thread, get_post_id, retrieve_discord_id
18+
19+
20+
class DiscordClient(discord.Client):
21+
bg_task: asyncio.Task
22+
23+
def __init__(self, *, state, lock, **kwargs):
24+
super().__init__(**kwargs)
25+
self.state: dict[str, bool | list[ProjectItemEvent]] = state
26+
self.lock: Lock = lock
27+
28+
async def on_ready(self):
29+
print(f"Logged on as {self.user}!")
30+
self.bg_task = self.loop.create_task(self.process_updates())
31+
32+
async def process_updates(self):
33+
forum_channel_id = int(os.getenv("FORUM_CHANNEL_ID"))
34+
forum_channel: discord.ForumChannel = self.get_channel(forum_channel_id)
35+
if forum_channel is None:
36+
raise ForumChannelNotFound(f"Forum channel with ID {forum_channel_id} not found.")
37+
local_queue_copy: list[ProjectItemEvent] = []
38+
39+
while True:
40+
with self.lock:
41+
if self.state["update-received"]:
42+
local_queue_copy = deepcopy(self.state["update-queue"])
43+
self.state["update-queue"].clear()
44+
self.state["update-received"] = False
45+
46+
for event in local_queue_copy:
47+
post_id = await get_post_id(event.name, forum_channel)
48+
author_discord_id = retrieve_discord_id(event.sender)
49+
if post_id is None:
50+
message = f"Nowy task stworzony {event.name} przez <@{author_discord_id}>"
51+
await forum_channel.create_thread(name=event.name, content=message, auto_archive_duration=10080)
52+
post_id = await get_post_id(event.name, forum_channel)
53+
thread: discord.Thread = forum_channel.get_thread(int(post_id)) or await self.fetch_channel(
54+
int(post_id)
55+
)
56+
if thread is None:
57+
continue
58+
59+
if isinstance(event, SimpleProjectItemEvent):
60+
match event.event_type.value:
61+
case "archived":
62+
message = f"Task zarchiwizowany przez <@{author_discord_id}>."
63+
await thread.send(message)
64+
await thread.edit(archived=True)
65+
case "restored":
66+
message = f"Task przywrócony przez <@{author_discord_id}>."
67+
await thread.send(message)
68+
await thread.edit(archived=False)
69+
case "deleted":
70+
await thread.delete()
71+
elif isinstance(event, ProjectItemEditedAssignees):
72+
assignee_mentions: list[str] = []
73+
if event.new_assignees:
74+
for assignee in event.new_assignees:
75+
discord_id = retrieve_discord_id(assignee)
76+
if discord_id:
77+
assignee_mentions.append(f"<@{discord_id}>")
78+
else:
79+
assignee_mentions.append("Brak przypisanych osób")
80+
81+
message = (
82+
f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}"
83+
)
84+
await thread.send(message)
85+
elif isinstance(event, ProjectItemEditedBody):
86+
message = f"Opis taska zaktualizowany przez <@{author_discord_id}>. Nowy opis: {event.new_body}"
87+
await thread.send(message)
88+
elif isinstance(event, ProjectItemEditedTitle):
89+
await thread.edit(name=event.new_title)
90+
elif isinstance(event, ProjectItemEditedSingleSelect):
91+
thread_tags = list(thread.applied_tags)
92+
for tag in thread_tags:
93+
if tag.name.startswith(f"{event.value_type.value}: "):
94+
await thread.remove_tags(tag)
95+
96+
await add_tag_to_thread(
97+
thread, forum_channel, f"{event.value_type.value}: {event.new_value}", event.value_type.value
98+
)
99+
100+
local_queue_copy.clear()
101+
102+
await asyncio.sleep(1)
103+
104+
105+
def run(state, lock):
106+
intents = discord.Intents.default()
107+
108+
client = DiscordClient(intents=intents, state=state, lock=lock)
109+
client.run(os.getenv("DISCORD_BOT_TOKEN"))

src/main.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import os
2+
3+
import dotenv
4+
import uvicorn
5+
6+
7+
def main():
8+
dotenv.load_dotenv()
9+
host, port = os.getenv("IP_ADDRESS", "0.0.0.0:8000").split(":")
10+
uvicorn.run("src.server:src", host=host, port=int(port), reload=True)
11+
12+
13+
if __name__ == "__main__":
14+
main()

0 commit comments

Comments
 (0)