Skip to content

Commit 6dbfb01

Browse files
authored
Release report changes (boostorg#1862)
1 parent fa0881e commit 6dbfb01

File tree

7 files changed

+338
-90
lines changed

7 files changed

+338
-90
lines changed

core/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
class SourceDocType(Enum):
55
ASCIIDOC = "asciidoc"
66
ANTORA = "antora"
7+
8+
9+
SLACK_URL = "https://cpplang.slack.com"

libraries/forms.py

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
)
2727
from libraries.constants import SUB_LIBRARIES
2828
from mailing_list.models import EmailData
29-
from .utils import batched
29+
from .utils import batched, conditional_batched
3030

3131

3232
class LibraryForm(ModelForm):
@@ -668,6 +668,31 @@ def _get_dependency_data(self, library_order, version):
668668
diffs.append(diffs_by_id.get(lib_id, {}))
669669
return diffs
670670

671+
def get_library_data(self, libraries, library_order, prior_version, version):
672+
library_data = [
673+
{
674+
"library": item[0],
675+
"full_count": item[1],
676+
"version_count": item[2],
677+
"top_contributors_release": item[3],
678+
"new_contributors_count": item[4],
679+
"issues": item[5],
680+
"library_version": item[6],
681+
"deps": item[7],
682+
}
683+
for item in zip(
684+
libraries,
685+
self._get_library_full_counts(libraries, library_order),
686+
self._get_library_version_counts(library_order, version),
687+
self._get_top_contributors_for_library_version(library_order, version),
688+
self._count_new_contributors(libraries, library_order, version),
689+
self._count_issues(libraries, library_order, version, prior_version),
690+
self._get_library_versions(library_order, version),
691+
self._get_dependency_data(library_order, version),
692+
)
693+
]
694+
return [x for x in library_data if x["version_count"]["commit_count"] > 0]
695+
671696
def get_stats(self):
672697
report_configuration = self.cleaned_data["report_configuration"]
673698
version = Version.objects.filter(name=report_configuration.version).first()
@@ -713,31 +738,16 @@ def get_stats(self):
713738
)
714739
)
715740

716-
library_data = [
717-
{
718-
"library": item[0],
719-
"full_count": item[1],
720-
"version_count": item[2],
721-
"top_contributors_release": item[3],
722-
"new_contributors_count": item[4],
723-
"issues": item[5],
724-
"library_version": item[6],
725-
"deps": item[7],
726-
}
727-
for item in zip(
728-
libraries,
729-
self._get_library_full_counts(libraries, library_order),
730-
self._get_library_version_counts(library_order, version),
731-
self._get_top_contributors_for_library_version(library_order, version),
732-
self._count_new_contributors(libraries, library_order, version),
733-
self._count_issues(libraries, library_order, version, prior_version),
734-
self._get_library_versions(library_order, version),
735-
self._get_dependency_data(library_order, version),
736-
)
737-
]
738-
library_data = [
739-
x for x in library_data if x["version_count"]["commit_count"] > 0
740-
]
741+
library_data = self.get_library_data(
742+
libraries, library_order, prior_version, version
743+
)
744+
AUTHORS_PER_PAGE_THRESHOLD = 6
745+
batched_library_data = conditional_batched(
746+
library_data,
747+
2,
748+
lambda x: x.get("top_contributors_release").count()
749+
<= AUTHORS_PER_PAGE_THRESHOLD,
750+
)
741751
top_contributors = self._get_top_contributors_for_version(version)
742752
# total messages sent during this release (version)
743753
total_mailinglist_count = EmailData.objects.filter(version=version).aggregate(
@@ -840,6 +850,7 @@ def get_stats(self):
840850
"version_commit_count": version_commit_count,
841851
"top_contributors_release_overall": top_contributors,
842852
"library_data": library_data,
853+
"batched_library_data": batched_library_data,
843854
"top_libraries_for_version": top_libraries_for_version,
844855
"library_count": library_count,
845856
"library_count_prior": library_count_prior,

libraries/tests/test_utils.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dateutil.relativedelta import relativedelta
55

66
from libraries.utils import (
7+
conditional_batched,
78
decode_content,
89
generate_fake_email,
910
get_first_last_day_last_month,
@@ -232,3 +233,52 @@ def test_write_content_to_tempfile():
232233
file_content = f.read()
233234
assert file_content == content
234235
os.remove(temp_file.name)
236+
237+
238+
def test_conditional_batched():
239+
# test basic functionality: batch consecutive items that pass condition
240+
items = [1, 2, 4, 3, 6, 8, 5, 10, 12, 7]
241+
# even numbers should be batched
242+
result = list(conditional_batched(items, 2, lambda x: x % 2 == 0))
243+
244+
# consecutive even numbers get batched, odd numbers are individual, order preserved
245+
assert result == [(1,), (2, 4), (3,), (6, 8), (5,), (10, 12), (7,)]
246+
247+
248+
def test_conditional_batched_all_pass():
249+
# test when all items pass the condition
250+
items = [2, 4, 6, 8, 10]
251+
result = list(conditional_batched(items, 2, lambda x: x % 2 == 0))
252+
253+
assert result == [(2, 4), (6, 8), (10,)]
254+
255+
256+
def test_conditional_batched_all_fail():
257+
# test when all items fail the condition
258+
items = [1, 3, 5, 7, 9]
259+
result = list(conditional_batched(items, 2, lambda x: x % 2 == 0))
260+
261+
assert result == [(1,), (3,), (5,), (7,), (9,)]
262+
263+
264+
def test_conditional_batched_strict_mode():
265+
# test strict mode with incomplete batch
266+
items = [2, 4, 6]
267+
with pytest.raises(ValueError, match="conditional_batched\\(\\): incomplete batch"):
268+
list(conditional_batched(items, 2, lambda x: x % 2 == 0, strict=True))
269+
270+
271+
def test_conditional_batched_strict_mode_complete():
272+
# test strict mode with complete batches
273+
items = [2, 4, 6, 8]
274+
result = list(conditional_batched(items, 2, lambda x: x % 2 == 0, strict=True))
275+
276+
assert result == [(2, 4), (6, 8)]
277+
278+
279+
def test_conditional_batched_invalid_n():
280+
# test invalid batch size
281+
items = [1, 2, 3]
282+
283+
with pytest.raises(ValueError, match="n must be at least one"):
284+
list(conditional_batched(items, 0, lambda x: True))

libraries/utils.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,46 @@ def batched(iterable, n, *, strict=False):
218218
yield batch
219219

220220

221+
def conditional_batched(iterable, n: int, condition: callable, *, strict=False):
222+
"""
223+
Batch items that pass a condition together, return items that fail individually.
224+
225+
Args:
226+
iterable: Items to process
227+
n: Batch size for items that pass the condition
228+
condition: Function that returns True if item should be batched
229+
strict: If True, raise error for incomplete final batch
230+
231+
Yields:
232+
Tuples of batched items or single-item tuples for items that fail condition
233+
"""
234+
if n < 1:
235+
raise ValueError("n must be at least one")
236+
237+
batch = []
238+
239+
for item in iterable:
240+
if condition(item):
241+
# item passes condition - add to batch
242+
batch.append(item)
243+
if len(batch) == n:
244+
# batch is full - yield it and start new batch
245+
yield tuple(batch)
246+
batch = []
247+
else:
248+
# item fails condition - yield any pending batch first, then item alone
249+
if batch:
250+
yield tuple(batch)
251+
batch = []
252+
yield (item,)
253+
254+
# handle any remaining items in batch
255+
if strict and batch and len(batch) != n:
256+
raise ValueError("conditional_batched(): incomplete batch")
257+
if batch:
258+
yield tuple(batch)
259+
260+
221261
def legacy_path_transform(content_path):
222262
if content_path and content_path.startswith(LEGACY_LATEST_RELEASE_URL_PATH_STR):
223263
content_path = re.sub(r"([a-zA-Z0-9\.]+)/(\S+)", r"latest/\2", content_path)

slack/management/commands/fetch_slack_activity.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import datetime
33
import functools
44
import time
5+
import re
56

67
from slack_sdk import WebClient
78
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
@@ -12,6 +13,7 @@
1213
from django.conf import settings
1314
from django.core.management import CommandError
1415

16+
from core.constants import SLACK_URL
1517
from slack.models import (
1618
SlackUser,
1719
SlackActivityBucket,
@@ -289,14 +291,97 @@ def command(channels, debug):
289291
# materialize this generator so we can iterate multiple times
290292
selected_channels.extend(get_my_channels())
291293

294+
def interpolate_text_usernames(text):
295+
user_mentions = re.findall(r"<@([A-Z0-9]+)>", text)
296+
for user_id in user_mentions:
297+
try:
298+
slack_user = SlackUser.objects.get(id=user_id)
299+
profile_url = f"{SLACK_URL}/team/{user_id}"
300+
text = text.replace(
301+
f"<@{user_id}>", f'<a href="{profile_url}">@{slack_user.name}</a>'
302+
)
303+
except SlackUser.DoesNotExist:
304+
logger.warning(f"SlackUser {user_id} not found in database")
305+
continue
306+
307+
return text
308+
309+
def interpolate_text_slack_channels(text):
310+
# match both <#CHANNELID> and <#CHANNELID|optional_text>
311+
channel_mentions = re.findall(r"<#([A-Z0-9]+)(?:\|[^>]*)?>", text)
312+
for channel_id in channel_mentions:
313+
try:
314+
channel = Channel.objects.get(id=channel_id)
315+
channel_name = channel.name
316+
except Channel.DoesNotExist:
317+
try:
318+
# fetch channel info from Slack API
319+
channel_data = client.conversations_info(channel=channel_id)
320+
channel_name = channel_data.data["channel"]["name"]
321+
logger.info(
322+
f"Fetched channel name {channel_name} for {channel_id} from API"
323+
)
324+
except Exception as e:
325+
logger.warning(f"Failed to get channel info for {channel_id}: {e}")
326+
continue
327+
328+
# replace the full match including any pipe content
329+
pattern = f"<#{channel_id}(?:\\|[^>]*)?>"
330+
text = re.sub(pattern, f"#{channel_name}", text)
331+
332+
return text
333+
334+
def interpolate_text_subteams(text):
335+
# returns early because we don't have usergroups:read permission added and
336+
# the only channel that really needs this data parsed at the moment is
337+
# #general which doesn't appear in the release report. If we need it in
338+
# the future, Sam says we'd need to create a new bot with that permission.
339+
return text
340+
341+
# match <!subteam^SUBTEAMID> patterns
342+
subteam_mentions = re.findall(r"<!subteam\^([A-Z0-9]+)>", text)
343+
for subteam_id in subteam_mentions:
344+
try:
345+
usergroups_data = client.usergroups_list()
346+
for usergroup in usergroups_data.data["usergroups"]:
347+
if usergroup["id"] == subteam_id:
348+
subteam_name = usergroup["handle"]
349+
text = text.replace(
350+
f"<!subteam^{subteam_id}>", f"@{subteam_name}"
351+
)
352+
break
353+
else:
354+
logger.warning(f"Subteam {subteam_id} not found in usergroups list")
355+
except Exception as e:
356+
logger.warning(f"Failed to get subteam info for {subteam_id}: {e}")
357+
continue
358+
359+
return text
360+
361+
def interpolate_text_urls_with_jinja_links(text):
362+
return re.sub(r"<(https?://[^>]+)>", r'<a href="\1">\1</a>', text)
363+
292364
for channel_data in selected_channels:
293365
with transaction.atomic():
366+
topic = channel_data["topic"]["value"]
367+
if topic:
368+
topic = interpolate_text_usernames(topic)
369+
topic = interpolate_text_slack_channels(topic)
370+
topic = interpolate_text_subteams(topic)
371+
topic = interpolate_text_urls_with_jinja_links(topic)
372+
purpose = channel_data["purpose"]["value"]
373+
if purpose:
374+
purpose = interpolate_text_usernames(purpose)
375+
purpose = interpolate_text_slack_channels(purpose)
376+
purpose = interpolate_text_subteams(purpose)
377+
purpose = interpolate_text_urls_with_jinja_links(purpose)
378+
294379
channel, created = Channel.objects.update_or_create(
295380
id=channel_data["id"],
296381
defaults={
297382
"name": channel_data["name"],
298-
"topic": channel_data["topic"]["value"],
299-
"purpose": channel_data["purpose"]["value"],
383+
"topic": topic,
384+
"purpose": purpose,
300385
},
301386
)
302387
if created:

0 commit comments

Comments
 (0)