Skip to content

Commit 995f875

Browse files
rnetsercursoragent
andcommitted
feat: full customizability — dynamic columns, sort, email templates
- Dynamic table columns from digest.columns config (BUNDLE/VERSION added) - sort_by honored within tier groups - Email body/celebration HTML templates configurable - Celebration max links + more template configurable - Empty-result template configurable - SMTP auth (username/password) + use_tls default true - Network timeouts configurable per integration - Week boundary configurable (sunday/monday) - Slack API base URL configurable - Email href scheme validation (http/https only) - Deploy templates use REPLACE placeholders (no org-specific values) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c32ed46 commit 995f875

14 files changed

Lines changed: 301 additions & 139 deletions

config/config.example.toml

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,23 @@
55
[schedule]
66
# Keep deploy/cronjob.yaml spec.schedule in sync with this value.
77
# Note: schedule.cron is a sync marker for deploy/cronjob.yaml; timezone is unused.
8-
# Week window is always the last complete Sun–Sat in UTC.
8+
# week_start: sunday → last complete Sun–Sat; monday → last complete Mon–Sun.
99
cron = "0 10 * * 0"
1010
timezone = "Asia/Jerusalem"
11+
week_start = "sunday"
1112

1213
[digest]
1314
# 0 = no limit (show all jobs). Positive N truncates the Slack message.
1415
max_rows = 0
1516
# sort_by applies within each tier group. Rows are first grouped by tier
1617
# (order from `tiers`), then sorted by version descending, bundle descending,
17-
# and finally by this field.
18+
# and finally by this field (job_name sorts ascending and does not reverse).
1819
sort_by = "not_reviewed" # not_reviewed | failures | job_name
1920
# API labels to include. "other" is a display catch-all (not sent to API).
2021
tiers = ["gating", "release-checklist", "other"]
21-
# Column order controls the Slack table (API fields only).
22-
# Note: columns apply to plain/mrkdwn format only. Blocks format uses a fixed
23-
# table layout (Job, Bundle, Reviewed, rootcoz).
24-
columns = ["job_name", "tier", "failures", "reviewed", "jenkins", "rootcoz"]
25-
# Optional columns: team, not_reviewed, build
22+
# Column order controls Slack tables (blocks + plain). Default blocks layout:
23+
columns = ["job_name", "bundle", "reviewed", "rootcoz"]
24+
# Optional columns: tier, team, failures, not_reviewed, build, jenkins, version
2625
# Passed as API ``exclude_label`` query params (metadata labels).
2726
# Example: exclude_labels = ["s390x"]
2827
exclude_labels = []
@@ -52,14 +51,19 @@ table_code_fence = true
5251
# {week_label}, {excluded_versions}, {mention_suffix}, {mention}, {team}, {total_jobs}, {lanes}
5352
# celebration_reviewed_template = "🎉 *rootcoz {lanes} weekly digest* — {week_label}{excluded_versions}{mention_suffix}\n\n✅ All *{total_jobs}* {lanes} failures for *{team}* have been reviewed! 👏"
5453
# celebration_no_failures_template = "🎉 *rootcoz {lanes} weekly digest* — {week_label}{excluded_versions}{mention_suffix}\n\n✅ Zero {lanes} failures for *{team}* this week! 🚀"
54+
# celebration_max_links = 20
55+
# celebration_more_template = "_+{remaining} more reviewed jobs_"
56+
# empty_template / empty_template_plain for zero-row digests
5557
# Email subject. Placeholders: {lanes}, {week_label}, {team}
5658
# email_subject_template = "rootcoz {lanes} weekly digest — {week_label} — {team}"
59+
# email_body_title / email_celebration_*_template for HTML email bodies
5760

5861
[rootcoz]
5962
# Prefer env: ROOTCOZ_URL, ROOTCOZ_API_KEY, ROOTCOZ_VERIFY_SSL
6063
url = ""
6164
api_key = ""
6265
verify_ssl = true
66+
timeout = 60
6367
# API endpoint and default query params
6468
endpoint = "/api/dashboard/filtered"
6569

@@ -103,11 +107,16 @@ release-checklist = "release-checklist"
103107
mode = "bot"
104108
bot_token = ""
105109
webhook_url = ""
110+
api_base_url = "https://slack.com/api"
111+
timeout = 30
106112

107113
[email]
108-
# SMTP relay (no auth). Enable to deliver digests via TARGETS[].email.
114+
# SMTP relay. Optional smtp_username/smtp_password for authenticated relays.
109115
enabled = false
110116
smtp_host = "smtp.example.com"
111117
smtp_port = 25
112118
from_address = "digest@example.com"
113-
use_tls = false
119+
use_tls = true
120+
# smtp_username = ""
121+
# smtp_password = ""
122+
timeout = 30

deploy/configmap.yaml

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,20 @@ data:
1010
[schedule]
1111
# Week window is always the last complete Sun–Sat in UTC.
1212
cron = "0 10 * * 0"
13-
timezone = "Asia/Jerusalem"
13+
timezone = "UTC"
14+
week_start = "sunday"
1415
1516
[digest]
1617
# 0 = no limit. exclude_labels are API query params; exclude_job_patterns is client-side name filter.
1718
max_rows = 0
1819
sort_by = "not_reviewed"
1920
# API labels to include. "other" is a display catch-all (not sent to API).
2021
tiers = ["gating", "release-checklist", "other"]
21-
columns = ["job_name", "tier", "failures", "reviewed", "jenkins", "rootcoz"]
22-
exclude_labels = ["s390x"]
23-
exclude_job_patterns = ["s390x"]
24-
exclude_versions = ["4.99", "5.99", "5.0", "4.23"]
25-
include_tags = ["rootcoz-jenkins-bot"]
22+
columns = ["job_name", "bundle", "reviewed", "rootcoz"]
23+
exclude_labels = ["REPLACE"]
24+
exclude_job_patterns = ["REPLACE"]
25+
exclude_versions = ["REPLACE"]
26+
include_tags = ["REPLACE"]
2627
2728
[message]
2829
format = "blocks"
@@ -68,19 +69,19 @@ data:
6869
smtp_host = "smtp.example.com"
6970
smtp_port = 25
7071
from_address = "digest@example.com"
71-
use_tls = false
72+
use_tls = true
7273
ROOTCOZ_URL: "REPLACE"
7374
ROOTCOZ_VERIFY_SSL: "true"
7475
TARGETS: |
7576
[
7677
{
77-
"team": "virt-node",
78-
"slack": {"channel": "REPLACE", "usergroup": "virt-node-qe"},
78+
"team": "REPLACE_TEAM",
79+
"slack": {"channel": "REPLACE", "usergroup": "REPLACE"},
7980
"email": {"recipients": ["REPLACE@example.com"]}
8081
},
8182
{
82-
"team": "network",
83-
"slack": {"channel": "REPLACE", "usergroup": "network-qe"},
83+
"team": "REPLACE_TEAM_2",
84+
"slack": {"channel": "REPLACE", "usergroup": "REPLACE"},
8485
"email": {"recipients": ["REPLACE@example.com"]}
8586
}
8687
]

deploy/staging/configmap.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,17 @@ data:
6868
smtp_host = "smtp.example.com"
6969
smtp_port = 25
7070
from_address = "digest@example.com"
71-
use_tls = false
71+
use_tls = true
7272
ROOTCOZ_URL: "REPLACE"
7373
ROOTCOZ_VERIFY_SSL: "true"
7474
TARGETS: |
7575
[
7676
{
7777
"team": "virt-node",
78-
"slack": {"channel": "C0AFWM3NFC5", "usergroup": ""}
78+
"slack": {"channel": "REPLACE_CHANNEL", "usergroup": ""}
7979
},
8080
{
8181
"team": "network",
82-
"slack": {"channel": "C0AFWM3NFC5", "usergroup": ""}
82+
"slack": {"channel": "REPLACE_CHANNEL", "usergroup": ""}
8383
}
8484
]

src/rootcoz_slack_digest/email_client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,15 @@ def send(
4040
msg.attach(MIMEText(html_body, "html"))
4141

4242
all_recipients = list(recipients) + (cc or [])
43-
with smtplib.SMTP(self._config.smtp_host, self._config.smtp_port, timeout=30) as server:
43+
with smtplib.SMTP(
44+
self._config.smtp_host,
45+
self._config.smtp_port,
46+
timeout=self._config.timeout,
47+
) as server:
4448
if self._config.use_tls:
4549
server.starttls()
50+
if self._config.smtp_username:
51+
server.login(self._config.smtp_username, self._config.smtp_password)
4652
server.sendmail(self._config.from_address, all_recipients, msg.as_string())
4753

4854
logger.info("Sent digest email to %s", ", ".join(recipients))

src/rootcoz_slack_digest/email_format.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,27 @@
44

55
import html
66

7-
from rootcoz_slack_digest.models import JobRow, WeekWindow
7+
from rootcoz_slack_digest.models import JobRow, MessageConfig, WeekWindow
88
from rootcoz_slack_digest.utils import version_sort_key
99

1010

11+
def _safe_href(url: str) -> str:
12+
"""Only allow http/https URLs in href attributes."""
13+
if url and url.startswith(("http://", "https://")):
14+
return html.escape(url, quote=True)
15+
return ""
16+
17+
1118
def format_digest_html(
1219
*,
1320
window: WeekWindow,
1421
rows: list[JobRow],
1522
team: str,
1623
tiers: list[str] | None = None,
24+
message: MessageConfig | None = None,
1725
) -> str:
1826
"""Render digest rows as styled HTML email body."""
27+
msg = message or MessageConfig()
1928
total_failures = sum(r.failure_count for r in rows)
2029
total_reviewed = sum(r.reviewed_count for r in rows)
2130
tier_display = ", ".join(html.escape(t) for t in tiers) if tiers else "all"
@@ -29,7 +38,7 @@ def format_digest_html(
2938

3039
html_parts = [
3140
"<html><body style='font-family: Arial, sans-serif; font-size: 14px;'>",
32-
f"<h2>rootcoz weekly digest{html.escape(window.label)}</h2>",
41+
f"<h2>{html.escape(msg.email_body_title)}{html.escape(window.label)}</h2>",
3342
f"<p><strong>Team:</strong> {team_html} · "
3443
f"<strong>Tiers:</strong> {tier_display} · "
3544
f"<strong>Jobs:</strong> {len(rows)} · "
@@ -53,15 +62,13 @@ def format_digest_html(
5362
html_parts.append("<ul>")
5463
for row in tier_rows:
5564
job_name = html.escape(row.job_name)
56-
if row.jenkins_url:
57-
name_html = f'<a href="{html.escape(row.jenkins_url, quote=True)}">{job_name}</a>'
65+
href = _safe_href(row.jenkins_url)
66+
if href:
67+
name_html = f'<a href="{href}">{job_name}</a>'
5868
else:
5969
name_html = f"<strong>{job_name}</strong>"
60-
rootcoz_html = (
61-
f' · <a href="{html.escape(row.rootcoz_url, quote=True)}">rootcoz</a>'
62-
if row.rootcoz_url
63-
else ""
64-
)
70+
rootcoz_href = _safe_href(row.rootcoz_url)
71+
rootcoz_html = f' · <a href="{rootcoz_href}">rootcoz</a>' if rootcoz_href else ""
6572
bundle_html = f" [{html.escape(row.bundle)}]" if row.bundle else ""
6673
html_parts.append(
6774
f"<li>{name_html}{bundle_html} "
@@ -81,36 +88,44 @@ def format_celebration_html(
8188
total_jobs: int,
8289
tiers: list[str] | None = None,
8390
jobs: list[JobRow] | None = None,
91+
message: MessageConfig | None = None,
8492
) -> str:
8593
"""Render celebration message as HTML."""
94+
msg = message or MessageConfig()
8695
tier_display = ", ".join(html.escape(t) for t in tiers) if tiers else "all"
8796
team_html = html.escape(team)
8897
if total_jobs > 0:
89-
body = (
90-
f"All <strong>{total_jobs}</strong> {tier_display} failures "
91-
f"for <strong>{team_html}</strong> have been reviewed! 👏"
98+
body = msg.email_celebration_reviewed_template.format(
99+
total_jobs=total_jobs,
100+
lanes=tier_display,
101+
team=team_html,
92102
)
93103
else:
94-
body = f"Zero {tier_display} failures for <strong>{team_html}</strong> this week! 🚀"
104+
body = msg.email_celebration_no_failures_template.format(
105+
lanes=tier_display,
106+
team=team_html,
107+
)
95108

96109
parts = [
97110
"<html><body style='font-family: Arial, sans-serif;'>",
98-
f"<h2>🎉 rootcoz weekly digest{html.escape(window.label)}</h2>",
111+
f"<h2>🎉 {html.escape(msg.email_body_title)}{html.escape(window.label)}</h2>",
99112
f"<p>✅ {body}</p>",
100113
]
114+
max_links = msg.celebration_max_links
101115
if total_jobs > 0 and jobs:
102116
items: list[str] = []
103-
for job in jobs[:20]:
117+
for job in jobs[:max_links]:
104118
bundle_html = f" [{html.escape(job.bundle)}]" if job.bundle else ""
105-
if job.rootcoz_url:
119+
href = _safe_href(job.rootcoz_url)
120+
if href:
106121
items.append(
107-
f'<li><a href="{html.escape(job.rootcoz_url, quote=True)}">'
108-
f"{html.escape(job.job_name)}</a>{bundle_html}</li>"
122+
f'<li><a href="{href}">{html.escape(job.job_name)}</a>{bundle_html}</li>'
109123
)
110124
else:
111125
items.append(f"<li>{html.escape(job.job_name)}{bundle_html}</li>")
112126
parts.append("<ul>" + "".join(items) + "</ul>")
113-
if len(jobs) > 20:
114-
parts.append(f"<p><em>+{len(jobs) - 20} more reviewed jobs</em></p>")
127+
if len(jobs) > max_links:
128+
remaining = len(jobs) - max_links
129+
parts.append(f"<p><em>+{remaining} more reviewed jobs</em></p>")
115130
parts.append("</body></html>")
116131
return "\n".join(parts)

src/rootcoz_slack_digest/mentions.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,17 @@ def mention_for_handle(resolver: UsergroupResolver, handle: str | None) -> str:
4444
class SlackUsergroupResolver:
4545
"""Resolve handles via ``usergroups.list`` (cached for one run)."""
4646

47-
def __init__(self, token: str, *, client: httpx.Client | None = None) -> None:
47+
def __init__(
48+
self,
49+
token: str,
50+
*,
51+
api_base_url: str = "https://slack.com/api",
52+
timeout: int = 30,
53+
client: httpx.Client | None = None,
54+
) -> None:
4855
self._token = token
56+
self._api_base_url = api_base_url.rstrip("/")
57+
self._timeout = timeout
4958
self._client = client
5059
self._owns_client = client is None
5160
self._cache: dict[str, str] | None = None
@@ -65,9 +74,9 @@ def __exit__(self, *args: object) -> None:
6574
def _http(self) -> httpx.Client:
6675
if self._client is None:
6776
self._client = httpx.Client(
68-
base_url="https://slack.com/api",
77+
base_url=self._api_base_url,
6978
headers={"Authorization": f"Bearer {self._token}"},
70-
timeout=30.0,
79+
timeout=float(self._timeout),
7180
)
7281
return self._client
7382

src/rootcoz_slack_digest/models.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class DigestColumn(StrEnum):
3131
JENKINS = "jenkins"
3232
ROOTCOZ = "rootcoz"
3333
BUILD = "build"
34+
BUNDLE = "bundle"
35+
VERSION = "version"
3436

3537

3638
class MessageFormat(StrEnum):
@@ -50,6 +52,14 @@ class MessageFormat(StrEnum):
5052
DigestColumn.ROOTCOZ,
5153
]
5254

55+
# Default Block Kit table layout (Job / Bundle / Reviewed / rootcoz).
56+
DEFAULT_BLOCK_COLUMNS: list[DigestColumn] = [
57+
DigestColumn.JOB_NAME,
58+
DigestColumn.BUNDLE,
59+
DigestColumn.REVIEWED,
60+
DigestColumn.ROOTCOZ,
61+
]
62+
5363

5464
class WeekWindow(BaseModel):
5565
"""Inclusive Sun–Sat UTC week window."""
@@ -96,6 +106,8 @@ class ScheduleConfig(BaseModel):
96106

97107
cron: str = "0 7 * * 0"
98108
timezone: str = "UTC"
109+
# sunday → last complete Sun–Sat; monday → last complete Mon–Sun.
110+
week_start: str = "sunday"
99111

100112

101113
class DigestConfig(BaseModel):
@@ -150,7 +162,19 @@ class MessageConfig(BaseModel):
150162
"🎉 *rootcoz {lanes} weekly digest* — {week_label}{excluded_versions}{mention_suffix}\n\n"
151163
"✅ Zero {lanes} failures for *{team}* this week! 🚀"
152164
)
165+
celebration_max_links: int = 20
166+
celebration_more_template: str = "_+{remaining} more reviewed jobs_"
167+
empty_template: str = "_No completed jobs with failures in this window._"
168+
empty_template_plain: str = "No completed jobs with failures in this window."
153169
email_subject_template: str = "rootcoz {lanes} weekly digest — {week_label} — {team}"
170+
email_body_title: str = "rootcoz weekly digest"
171+
email_celebration_reviewed_template: str = (
172+
"All <strong>{total_jobs}</strong> {lanes} failures for "
173+
"<strong>{team}</strong> have been reviewed! 👏"
174+
)
175+
email_celebration_no_failures_template: str = (
176+
"Zero {lanes} failures for <strong>{team}</strong> this week! 🚀"
177+
)
154178

155179

156180
class FieldMapConfig(BaseModel):
@@ -215,6 +239,7 @@ class RootcozConfig(BaseModel):
215239
url: str = ""
216240
api_key: str = ""
217241
verify_ssl: bool = True
242+
timeout: int = 60
218243
endpoint: str = "/api/dashboard/filtered"
219244
params: dict[str, str] = Field(
220245
default_factory=lambda: {
@@ -234,6 +259,8 @@ class SlackConfig(BaseModel):
234259
mode: Literal["webhook", "bot"] = "bot"
235260
webhook_url: str = ""
236261
bot_token: str = ""
262+
api_base_url: str = "https://slack.com/api"
263+
timeout: int = 30
237264

238265

239266
class SlackTargetConfig(BaseModel):
@@ -280,7 +307,10 @@ class EmailConfig(BaseModel):
280307
smtp_host: str = "smtp.example.com"
281308
smtp_port: int = 25
282309
from_address: str = "digest@example.com"
283-
use_tls: bool = False
310+
use_tls: bool = True
311+
smtp_username: str = ""
312+
smtp_password: str = ""
313+
timeout: int = 30
284314

285315

286316
class AppConfig(BaseModel):

src/rootcoz_slack_digest/rootcoz_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def __init__(
4242
base_url=config.url.rstrip("/"),
4343
headers={"Authorization": f"Bearer {config.api_key}"},
4444
verify=config.verify_ssl,
45-
timeout=60.0,
45+
timeout=float(config.timeout),
4646
)
4747

4848
def close(self) -> None:

0 commit comments

Comments
 (0)