Skip to content

Commit e1ec0fd

Browse files
committed
Add support for discussions to digests
close #67
1 parent 2521daa commit e1ec0fd

11 files changed

+499
-6
lines changed

index.py

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,138 @@ def getRepoData(repo, token):
145145
return githubListReq.json()
146146

147147

148+
def queryGithubDiscussions(repo, token, until, cursor=None):
149+
"""
150+
Query GitHub discussions using GraphQL API with pagination support.
151+
Returns discussions created or updated after the 'until' date.
152+
"""
153+
url = "https://api.github.com/graphql"
154+
headers = {
155+
"Authorization": f"Bearer {token}",
156+
"Content-Type": "application/json"
157+
}
158+
159+
owner, name = repo.split("/")
160+
after_clause = f', after: "{cursor}"' if cursor else ""
161+
162+
query = f"""
163+
query {{
164+
repository(owner: "{owner}", name: "{name}") {{
165+
hasDiscussionsEnabled
166+
discussions(first: 100{after_clause}, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
167+
pageInfo {{
168+
hasNextPage
169+
endCursor
170+
}}
171+
nodes {{
172+
id
173+
title
174+
url
175+
author {{
176+
login
177+
}}
178+
createdAt
179+
updatedAt
180+
comments(first: 10) {{
181+
totalCount
182+
nodes {{
183+
author {{
184+
login
185+
}}
186+
bodyText
187+
createdAt
188+
updatedAt
189+
}}
190+
}}
191+
answerChosenAt
192+
category {{
193+
name
194+
}}
195+
labels(first: 10) {{
196+
nodes {{
197+
name
198+
color
199+
}}
200+
}}
201+
}}
202+
}}
203+
}}
204+
}}
205+
"""
206+
207+
response = requests.post(url, headers=headers, json={"query": query})
208+
209+
if response.status_code != 200:
210+
return {"error": f"GraphQL request failed with status {response.status_code}"}
211+
212+
data = response.json()
213+
if "errors" in data:
214+
return {"error": f"GraphQL errors: {data['errors']}"}
215+
216+
return data
217+
218+
219+
def listGithubDiscussions(repo, token, until):
220+
"""
221+
Fetch GitHub discussions with pagination, filtering by date.
222+
"""
223+
discussions = []
224+
cursor = None
225+
until_iso = until.strftime("%Y-%m-%dT%H:%M:%SZ")
226+
227+
while True:
228+
result = queryGithubDiscussions(repo, token, until, cursor)
229+
230+
if "error" in result:
231+
return {"discussions": [], "error": result["error"]}
232+
233+
repo_data = result.get("data", {}).get("repository", {})
234+
235+
# Check if discussions are enabled for this repository
236+
if not repo_data.get("hasDiscussionsEnabled", False):
237+
return {"discussions": [], "hasDiscussionsEnabled": False}
238+
239+
discussions_data = repo_data.get("discussions", {})
240+
nodes = discussions_data.get("nodes", [])
241+
242+
# Filter discussions by date - include if created or updated after 'until'
243+
for discussion in nodes:
244+
created_at = discussion.get("createdAt", "")
245+
updated_at = discussion.get("updatedAt", "")
246+
247+
if created_at >= until_iso or updated_at >= until_iso:
248+
# Add text colors for labels (similar to existing label handling)
249+
for label in discussion.get("labels", {}).get("nodes", []):
250+
bg_color = label.get("color", "000000")
251+
bg_rgb = int(bg_color, 16)
252+
bg_r = (bg_rgb >> 16) & 0xFF
253+
bg_g = (bg_rgb >> 8) & 0xFF
254+
bg_b = (bg_rgb >> 0) & 0xFF
255+
luma = 0.2126 * bg_r + 0.7152 * bg_g + 0.0722 * bg_b # ITU-R BT.709
256+
if luma < 128:
257+
label["text_color"] = "ffffff"
258+
else:
259+
label["text_color"] = "000000"
260+
261+
discussions.append(discussion)
262+
elif updated_at < until_iso:
263+
# Since discussions are ordered by updated_at DESC, we can stop here
264+
return {"discussions": discussions, "hasDiscussionsEnabled": True}
265+
266+
# Check if we need to fetch more pages
267+
page_info = discussions_data.get("pageInfo", {})
268+
if not page_info.get("hasNextPage", False):
269+
break
270+
271+
cursor = page_info.get("endCursor")
272+
273+
# Safety check to prevent infinite loops
274+
if not cursor:
275+
break
276+
277+
return {"discussions": discussions, "hasDiscussionsEnabled": True}
278+
279+
148280
def navigateGithubList(url, token, until, cumul=[]):
149281
headers = {}
150282
headers["Authorization"] = "token %s" % token
@@ -186,7 +318,7 @@ def andify(l):
186318
return [{"name": x, "last": i == len(l) - 1} for i, x in enumerate(sorted(l))]
187319

188320

189-
def extractDigestInfo(events, eventFilter=None):
321+
def extractDigestInfo(events, eventFilter=None, discussions=None):
190322
def listify(l):
191323
return {"count": len(l), "list": l}
192324

@@ -235,6 +367,37 @@ def listify(l):
235367
commentedissues[number]["commentors"] = andify(
236368
commentedissues[number]["commentors"]
237369
)
370+
371+
# Process discussions data
372+
discussion_list = []
373+
discussion_comments = []
374+
if discussions and discussions.get("discussions"):
375+
for discussion in discussions["discussions"]:
376+
# Extract basic discussion info
377+
disc_data = {
378+
"id": discussion.get("id"),
379+
"title": discussion.get("title"),
380+
"url": discussion.get("url"),
381+
"author": discussion.get("author", {}).get("login", "unknown"),
382+
"createdAt": discussion.get("createdAt"),
383+
"updatedAt": discussion.get("updatedAt"),
384+
"category": discussion.get("category", {}).get("name", ""),
385+
"labels": discussion.get("labels", {}).get("nodes", []),
386+
"answerChosenAt": discussion.get("answerChosenAt"),
387+
"isAnswered": bool(discussion.get("answerChosenAt"))
388+
}
389+
discussion_list.append(disc_data)
390+
391+
# Process discussion comments
392+
comments = discussion.get("comments", {}).get("nodes", [])
393+
if comments:
394+
disc_comments = {
395+
"discussion": disc_data,
396+
"comments": comments,
397+
"commentscount": discussion.get("comments", {}).get("totalCount", len(comments)),
398+
"commentors": andify(list(set([c.get("author", {}).get("login", "unknown") for c in comments if c.get("author")])))
399+
}
400+
discussion_comments.append(disc_comments)
238401
data["errors"] = listify(errors)
239402
data["newissues"] = listify(newissues)
240403
data["closedissues"] = listify(closedissues)
@@ -264,6 +427,13 @@ def listify(l):
264427
data["activepr"] = (
265428
data["prcommentscount"] > 0 or len(newpr) > 0 or len(mergedpr) > 0
266429
)
430+
431+
# Add discussions data
432+
data["discussions"] = listify(discussion_list)
433+
data["discussioncomments"] = listify(discussion_comments)
434+
data["discussioncommentscount"] = sum(d["commentscount"] for d in discussion_comments)
435+
data["activediscussion"] = len(discussion_list) > 0 or data["discussioncommentscount"] > 0
436+
267437
return data
268438

269439
# this preserves order which list(set()) wouldn't
@@ -337,6 +507,7 @@ def sendDigest(config, period="daily"):
337507

338508
events["activeissuerepos"] = []
339509
events["activeprrepos"] = []
510+
events["activediscussionrepos"] = []
340511
events["repostatus"] = []
341512
events["period"] = duration.capitalize()
342513

@@ -370,8 +541,13 @@ def sendDigest(config, period="daily"):
370541
for r in s["repos"]:
371542
eventFilters[r] = s.get("eventFilter", None)
372543
for repo in repos:
544+
# Fetch discussions data if available
545+
discussions_data = None
546+
if token:
547+
discussions_data = listGithubDiscussions(repo, token, until)
548+
373549
data = extractDigestInfo(
374-
listGithubEvents(repo, token, until), eventFilters[repo]
550+
listGithubEvents(repo, token, until), eventFilters[repo], discussions_data
375551
)
376552
data["repo"] = getRepoData(repo, token)
377553
data["name"] = repo
@@ -384,6 +560,8 @@ def sendDigest(config, period="daily"):
384560
events["activeissuerepos"].append(data)
385561
if data["activepr"]:
386562
events["activeprrepos"].append(data)
563+
if data["activediscussion"]:
564+
events["activediscussionrepos"].append(data)
387565
events["filtered"] = d.get("eventFilter", None)
388566
events["filteredlabels"] = (
389567
len(d.get("eventFilter", {}).get("label", [])) > 0
@@ -398,10 +576,12 @@ def sendDigest(config, period="daily"):
398576
events["topic"] = d.get("topic", None)
399577
events["activeissues"] = len(events["activeissuerepos"])
400578
events["activeprs"] = len(events["activeprrepos"])
579+
events["activediscussions"] = len(events["activediscussionrepos"])
401580
events["summary"] = len(events["repostatus"])
402581
if (
403582
events["activeissues"] > 0
404583
or events["activeprs"] > 0
584+
or events["activediscussions"] > 0
405585
or events["summary"] > 0
406586
):
407587
templates, error = loadTemplates(

templates/generic/digest

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,27 @@
6767
{{/activeprrepos}}
6868
{{/activeprs}}
6969

70+
{{#activediscussions}}Discussions
71+
-----------
72+
{{#activediscussionrepos}}
73+
* {{name}} ({{discussions.count}} discussions, {{discussioncommentscount}} comments)
74+
{{#discussions.count}} {{.}} discussions active:
75+
{{#discussions.list}}
76+
- {{{title}}} (by {{author}}){{#isAnswered}} ✓ Answered{{/isAnswered}}
77+
{{{url}}} {{#category}}[{{{category}}}]{{/category}} {{#labels}}[{{name}}] {{/labels}}
78+
{{/discussions.list}}
79+
80+
{{/discussions.count}}
81+
{{#discussioncomments.count}} Discussions with comments:
82+
{{#discussioncomments.list}}
83+
- {{{discussion.title}}} ({{commentscount}} by {{#commentors}}{{name}}{{^last}}, {{/last}}{{/commentors}})
84+
{{{discussion.url}}}
85+
{{/discussioncomments.list}}
86+
87+
{{/discussioncomments.count}}
88+
{{/activediscussionrepos}}
89+
{{/activediscussions}}
90+
7091
Repositories tracked by this digest:
7192
-----------------------------------
7293
{{#repos}}

test_digest.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,23 @@ def setUp(self):
8282
body=self.read_file("tests/rec-repos.json"),
8383
content_type="application/json",
8484
)
85+
# Add GraphQL discussions endpoint mocks
86+
responses.add(
87+
responses.POST,
88+
"https://api.github.com/graphql",
89+
json={
90+
"data": {
91+
"repository": {
92+
"hasDiscussionsEnabled": True,
93+
"discussions": {
94+
"pageInfo": {"hasNextPage": False, "endCursor": None},
95+
"nodes": []
96+
}
97+
}
98+
}
99+
},
100+
content_type="application/json",
101+
)
85102

86103
def parseReferenceMessage():
87104
return headers, body
@@ -105,20 +122,47 @@ def test_weekly_digest(self, mock_smtp):
105122
],
106123
mock_smtp.return_value.__enter__.return_value.sendmail,
107124
)
108-
self.assertEqual(len(responses.calls), 6)
125+
self.assertEqual(len(responses.calls), 13)
109126

110127
@responses.activate
111128
@patch("smtplib.SMTP", autospec=True)
112129
def test_quarterly_summary(self, mock_smtp):
113130
self.do_digest(
114131
"quarterly", [{"dom@localhost": "tests/summary-quarterly.msg"}], mock_smtp.return_value.__enter__.return_value.sendmail
115132
)
116-
self.assertEqual(len(responses.calls), 2)
133+
self.assertEqual(len(responses.calls), 3)
134+
135+
@responses.activate
136+
@patch("smtplib.SMTP", autospec=True)
137+
def test_weekly_digest_with_discussions(self, mock_smtp):
138+
# Override GraphQL response with discussions data
139+
responses.replace(
140+
responses.POST,
141+
"https://api.github.com/graphql",
142+
body=self.read_file("tests/repo1-discussions-comprehensive.json"),
143+
content_type="application/json",
144+
)
145+
146+
# Use the discussions-specific MLS config
147+
config_with_discussions = dict(config)
148+
config_with_discussions["mls"] = "tests/mls-discussions.json"
149+
150+
self.do_digest(
151+
"Wednesday",
152+
[{"dom@localhost": "tests/digest-weekly-with-discussions.msg"}],
153+
mock_smtp.return_value.__enter__.return_value.sendmail,
154+
config_with_discussions
155+
)
156+
157+
# Check that GraphQL was called for discussions
158+
graphql_calls = [call for call in responses.calls if call.request.url == "https://api.github.com/graphql"]
159+
self.assertTrue(len(graphql_calls) > 0)
117160

118-
def do_digest(self, period, refs, mock_smtp):
161+
def do_digest(self, period, refs, mock_smtp, config_override=None):
119162
import email
120163

121-
sendDigest(config, period)
164+
digest_config = config_override if config_override else config
165+
sendDigest(digest_config, period)
122166
self.assertEqual(mock_smtp.call_count, len(refs))
123167
counter = 0
124168
import pprint

tests/digest-weekly-allrepos.msg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Issues
3131

3232

3333

34+
3435
Repositories tracked by this digest:
3536
-----------------------------------
3637
* https://github.com/w3c/webrtc-pc

tests/digest-weekly-filtered.msg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Issues
2929

3030

3131

32+
33+
3234
Repositories tracked by this digest:
3335
-----------------------------------
3436
* https://github.com/w3c/webrtc-pc

tests/digest-weekly-repofiltered.msg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ Pull requests
9595

9696

9797

98+
99+
98100
Repositories tracked by this digest:
99101
-----------------------------------
100102
* https://github.com/w3c/webcrypto

0 commit comments

Comments
 (0)