Skip to content

Commit 358fe18

Browse files
committed
add experimental github support
1 parent d902195 commit 358fe18

File tree

11 files changed

+273
-11
lines changed

11 files changed

+273
-11
lines changed

deploy/templates/app/intbot.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ DISCORD_BOT_TOKEN="Token Goes Here"
1414
DISCORD_TEST_CHANNEL_ID="123123123123123123123123"
1515
DISCORD_TEST_CHANNEL_NAME="#test-channel"
1616

17-
1817
# Webhooks
1918
WEBHOOK_INTERNAL_TOKEN="asdf"
19+
20+
# Github
21+
GITHUB_API_TOKEN="github-api-token-goes-here"
22+
GITHUB_WEBHOOK_SECRET_TOKEN="github-webhook-secret-token"

intbot/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Discord
12
DISCORD_BOT_TOKEN='asdf'
23
DISCORD_TEST_CHANNEL_ID="123123123123123123123123"
34
DISCORD_TEST_CHANNEL_NAME="#test-channel"
5+
6+
# Github
7+
GITHUB_API_TOKEN="github-api-token"
8+
GITHUB_WEBHOOK_SECRET_TOKEN="github-webhook-secret-token"

intbot/core/endpoints/__init__.py

Whitespace-only changes.

intbot/core/endpoints/webhooks.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import hashlib
12
import hmac
23
import json
34

45
from core.models import Webhook
56
from core.tasks import process_webhook
67
from django.conf import settings
8+
from django.http import HttpResponseForbidden
79
from django.http.response import HttpResponseNotAllowed, JsonResponse
810
from django.views.decorators.csrf import csrf_exempt
911

@@ -37,3 +39,49 @@ def verify_internal_webhook(request):
3739

3840
if not hmac.compare_digest(settings.WEBHOOK_INTERNAL_TOKEN, token):
3941
raise ValueError("Token doesn't match")
42+
43+
44+
@csrf_exempt
45+
def github_webhook_endpoint(request):
46+
if request.method == "POST":
47+
github_headers = {
48+
k: v for k, v in request.headers.items() if k.startswith("X-Github")
49+
}
50+
51+
try:
52+
signature = verify_github_signature(request)
53+
except ValueError as e:
54+
return HttpResponseForbidden(e)
55+
56+
wh = Webhook.objects.create(
57+
source="github",
58+
meta=github_headers,
59+
signature=signature,
60+
content=json.loads(request.body),
61+
)
62+
process_webhook.enqueue(str(wh.uuid))
63+
return JsonResponse({"status": "ok"})
64+
65+
return HttpResponseNotAllowed("Only POST")
66+
67+
68+
def verify_github_signature(request) -> str:
69+
"""Verify that the payload was sent by github"""
70+
71+
if "X-Hub-Signature-256" not in request.headers:
72+
raise ValueError("X-Hub-Signature-256 is missing")
73+
74+
signature = request.headers["X-Hub-Signature-256"]
75+
76+
hashed = hmac.new(
77+
settings.GITHUB_WEBHOOK_SECRET_TOKEN.encode("utf-8"),
78+
msg=request.body,
79+
digestmod=hashlib.sha256,
80+
)
81+
82+
expected = "sha256=" + hashed.hexdigest()
83+
84+
if not hmac.compare_digest(expected, signature):
85+
raise ValueError("Signature's don't match")
86+
87+
return signature

intbot/core/integrations/__init__.py

Whitespace-only changes.

intbot/core/integrations/github.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import dataclasses
2+
3+
import httpx
4+
from django.conf import settings
5+
6+
GITHUB_API_URL = "https://api.github.com/graphql"
7+
8+
# GraphQL query
9+
query = """
10+
query($itemId: ID!) {
11+
node(id: $itemId) {
12+
... on ProjectV2Item {
13+
id
14+
content {
15+
... on DraftIssue {
16+
id
17+
title
18+
}
19+
... on Issue {
20+
id
21+
title
22+
url
23+
}
24+
}
25+
}
26+
}
27+
}
28+
"""
29+
30+
31+
def parse_github_webhook(headers: dict, content: dict) -> tuple[str, str]:
32+
event = headers["X-Github-Event"]
33+
34+
if event == "projects_v2_item":
35+
parser = GithubProjectV2Item(content)
36+
formatted = parser.as_str()
37+
action = parser.action()
38+
event_action = f"{event}.{action}"
39+
return formatted, event_action
40+
41+
elif event == "...":
42+
return "", ""
43+
44+
else:
45+
raise ValueError(f"Event {event} not supported")
46+
47+
48+
@dataclasses.dataclass
49+
class GithubIssue:
50+
id: str
51+
title: str
52+
url: str
53+
54+
def as_discord_message(self):
55+
return f"[{self.title}]({self.url})"
56+
57+
58+
@dataclasses.dataclass
59+
class GithubDraftIssue:
60+
id: str
61+
title: str
62+
63+
def as_discord_message(self):
64+
return self.title
65+
66+
67+
def fetch_github_item_details(item_id):
68+
headers = {
69+
"Authorization": f"Bearer {settings.GITHUB_TOKEN}",
70+
"Content-Type": "application/json",
71+
}
72+
payload = {"query": query, "variables": {"itemId": item_id}}
73+
response = httpx.post(GITHUB_API_URL, json=payload, headers=headers)
74+
if response.status_code == 200:
75+
return response.json()["data"]["node"]["content"]
76+
else:
77+
raise Exception(f"GitHub API error: {response.status_code} - {response.text}")
78+
79+
80+
CONTENT_TYPE_MAP = {
81+
"Issue": GithubIssue,
82+
"DraftIssue": GithubDraftIssue,
83+
}
84+
85+
86+
class GithubProjectV2Item:
87+
def __init__(self, content: dict):
88+
self.content = content
89+
90+
def action(self):
91+
if self.content["action"] == "edited":
92+
action = "changed"
93+
elif self.content["action"] == "created":
94+
action = "created"
95+
else:
96+
raise ValueError(f"Action unsupported {self.content['action']}")
97+
98+
return action
99+
100+
def sender(self):
101+
login = self.content["sender"]["login"]
102+
url = self.content["sender"]["html_url"]
103+
104+
return f"[@{login}]({url})"
105+
106+
def content_type(self):
107+
return self.content["projects_v2_item"]["content_type"]
108+
109+
def node_id(self):
110+
# NOTE(artcz): This is more relevant, because of how the graphql query
111+
# above is constructed.
112+
# Using node_id, which is an id of a ProjectV2Item we can get both
113+
# DraftIssue and Issue from one query.
114+
# If we use the content_node_id we need to adjust the query as that ID
115+
# points us directly either an Issue or DraftIssue
116+
return self.content["projects_v2_item"]["node_id"]
117+
118+
def content_node_id(self):
119+
return self.content["projects_v2_item"]["content_node_id"]
120+
121+
def changes(self) -> dict:
122+
if "changes" in self.content:
123+
fv = self.content["changes"]["field_value"]
124+
field_name = fv["field_name"]
125+
field_type = fv["field_type"]
126+
if field_type == "date":
127+
changed_from = (
128+
fv["from"].split("T")[0] if fv["from"] is not None else "None"
129+
)
130+
changed_to = fv["to"].split("T")[0] if fv["to"] is not None else "None"
131+
elif field_type == "single_select":
132+
changed_from = fv["from"]["name"] if fv["from"] is not None else "None"
133+
changed_to = fv["to"]["name"] if fv["to"] is not None else "None"
134+
else:
135+
changed_from = "None"
136+
changed_to = "None"
137+
138+
return {
139+
"field": field_name,
140+
"from": changed_from,
141+
"to": changed_to,
142+
}
143+
144+
return {}
145+
146+
def as_discord_message(self, github_object: GithubDraftIssue | GithubIssue) -> str:
147+
message = "{sender} {action} {details}".format
148+
149+
action = self.action()
150+
sender = self.sender()
151+
152+
changes = self.changes()
153+
154+
if changes:
155+
details = "**{field}** of **{obj}** from **{from}** to **{to}**".format
156+
details = details(**{"obj": github_object.as_discord_message(), **changes})
157+
158+
else:
159+
details = github_object.as_discord_message()
160+
161+
return message(
162+
**{
163+
"sender": sender,
164+
"action": action,
165+
"details": details,
166+
}
167+
)
168+
169+
def fetch_quoted_github_object(self) -> GithubIssue | GithubDraftIssue:
170+
obj = fetch_github_item_details(self.node_id())
171+
172+
obj = CONTENT_TYPE_MAP[self.content_type()](**obj)
173+
174+
return obj
175+
176+
def as_str(self):
177+
github_obj = self.fetch_quoted_github_object()
178+
return self.as_discord_message(github_obj)

intbot/core/migrations/0002_test.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44

55

66
class Migration(migrations.Migration):
7-
87
dependencies = [
9-
('core', '0001_initial_migration'),
8+
("core", "0001_initial_migration"),
109
]
1110

1211
operations = [
1312
migrations.AlterField(
14-
model_name='webhook',
15-
name='meta',
13+
model_name="webhook",
14+
name="meta",
1615
field=models.JSONField(default=dict),
1716
),
1817
]

intbot/core/tasks.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
from django.conf import settings
2-
31
from core.models import DiscordMessage, Webhook
2+
from core.integrations.github import parse_github_webhook
3+
from django.conf import settings
44
from django.utils import timezone
55
from django_tasks import task
66

@@ -12,6 +12,9 @@ def process_webhook(wh_uuid: str):
1212
if wh.source == "internal":
1313
process_internal_webhook(wh)
1414

15+
elif wh.source == "github":
16+
process_github_webhook(wh)
17+
1518
else:
1619
raise ValueError(f"Unsupported source {wh.source}")
1720

@@ -29,3 +32,22 @@ def process_internal_webhook(wh: Webhook):
2932
)
3033
wh.processed_at = timezone.now()
3134
wh.save()
35+
36+
37+
def process_github_webhook(wh: Webhook):
38+
if wh.source != "github":
39+
raise ValueError("Incorrect wh.source = {wh.source}")
40+
41+
message, event_action = parse_github_webhook(headers=wh.meta, content=wh.content)
42+
43+
# NOTE WHERE SHOULD WE GET THE CHANNEL ID FROM?
44+
DiscordMessage.objects.create(
45+
channel_id=settings.DISCORD_TEST_CHANNEL_ID,
46+
channel_name=settings.DISCORD_TEST_CHANNEL_NAME,
47+
content=f"GitHub: {message}",
48+
# Mark as unsend - to be sent with the next batch
49+
sent_at=None,
50+
)
51+
wh.event = event_action
52+
wh.processed_at = timezone.now()
53+
wh.save()

intbot/intbot/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ def warn_if_missing(name, default=""):
152152
DISCORD_TEST_CHANNEL_ID = warn_if_missing("DISCORD_TEST_CHANNEL_ID", "")
153153
DISCORD_TEST_CHANNEL_NAME = warn_if_missing("DISCORD_TEST_CHANNEL_NAME", "")
154154
DISCORD_BOT_TOKEN = warn_if_missing("DISCORD_BOT_TOKEN", "")
155+
GITHUB_TOKEN = warn_if_missing("GITHUB_TOKEN", "")
156+
GITHUB_WEBHOOK_SECRET_TOKEN = warn_if_missing("GITHUB_WEBHOOK_SECRET_TOKEN", "")
155157

156158

157159
elif DJANGO_ENV == "test":
@@ -186,8 +188,11 @@ def warn_if_missing(name, default=""):
186188
}
187189
}
188190

191+
DISCORD_BOT_TOKEN = "test-token"
189192
DISCORD_TEST_CHANNEL_ID = "12345"
190193
DISCORD_TEST_CHANNEL_NAME = "#test-channel"
194+
GITHUB_TOKEN = "github-test-token"
195+
GITHUB_WEBHOOK_SECRET_TOKEN = "github-webhook-secret-token-token"
191196

192197

193198
elif DJANGO_ENV == "local_container":
@@ -293,6 +298,8 @@ def warn_if_missing(name, default=""):
293298
]
294299

295300
WEBHOOK_INTERNAL_TOKEN = os.environ["WEBHOOK_INTERNAL_TOKEN"]
301+
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
302+
GITHUB_WEBHOOK_SECRET_TOKEN = os.environ["GITHUB_WEBHOOK_SECRET_TOKEN"]
296303

297304

298305
elif DJANGO_ENV == "build":

intbot/intbot/urls.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1+
from core.endpoints.basic import index
2+
from core.endpoints.webhooks import github_webhook_endpoint, internal_webhook_endpoint
13
from django.contrib import admin
24
from django.urls import path
35

4-
from core.endpoints.basic import index
5-
from core.endpoints.webhooks import internal_webhook_endpoint
6-
76
urlpatterns = [
87
path("admin/", admin.site.urls),
98
path("", index),
109
# Internal Webhooks
1110
path("webhook/internal/", internal_webhook_endpoint),
11+
path("webhook/github/", github_webhook_endpoint),
1212
]

intbot/tests/test_tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,5 @@ def test_process_webhook_fails_if_unsupported_source(caplog):
3939
result = process_webhook.enqueue(str(wh.uuid))
4040

4141
assert result.status == ResultStatus.FAILED
42-
assert result._exception_class == ValueError
42+
assert result._exception_class == ValueError
4343
assert result.traceback.endswith("ValueError: Unsupported source asdf\n")

0 commit comments

Comments
 (0)