Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/slack-review-digest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: Slack review digest

on:
schedule:
- cron: '7 17 * * *'
timezone: America/Los_Angeles
workflow_dispatch:
inputs:
dry_run:
description: Preview messages in the log without posting to Slack
type: boolean
default: true

permissions:
contents: read
pull-requests: read

concurrency:
group: slack-review-digest
cancel-in-progress: false

jobs:
digest:
if: github.repository == 'ai-dynamo/aisimulate'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Check digest calculations
run: python3 scripts/test_slack_review_digest.py
- name: Generate and send digest
env:
GH_TOKEN: ${{ github.token }}
SLACK_REVIEW_DIGEST_WEBHOOK_URL: ${{ secrets.SLACK_REVIEW_DIGEST_WEBHOOK_URL }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
run: |
args=()
if [[ "$DRY_RUN" == "true" ]]; then
args+=(--dry-run)
fi
python3 scripts/slack_review_digest.py "${args[@]}"
57 changes: 57 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,60 @@ Explore the automation helpers under `python/aisimulate/tools/automation/`.
## License

This project is licensed under Apache 2.0. All contributions must include SPDX license headers and DCO sign-off.

## Daily Slack review digest

The `Slack review digest` workflow runs every day at 17:07
`America/Los_Angeles` (including daylight saving changes). It reports:

- Open non-draft PRs, including approved PRs.
- PRs merged or created since Pacific midnight, through the run's start time.
Created PRs count even if subsequently closed or merged, including drafts.
- Every open non-draft PR created more than 5 days (120 hours) ago, oldest first, with
its link, title, author, and age. Age measures creation time, not inactivity
or time since leaving draft. PR details appear in a thread reply beneath the summary.
Lists over 35,000 characters fail before delivery to avoid losing entries.

The summary shows merged PRs (`:merged-2472:`), new PRs (`:pr-opened:`), then
open non-draft PRs labeled "PRs waiting for review" (`:reminder-alarm:`). The destination workspace must have the custom
`merged-2472`, `pr-opened`, and `reminder-alarm` emoji for those names to render as icons.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

The Workflow Builder Text variable does not parse Slack markup. Messages use
plain text, emoji, and full clickable PR URLs on separate lines; bold and
named hyperlinks are not supported by this template. An acknowledged trigger
means Slack accepted the request; check Slack workflow activity for delivery
failures in subsequent steps.

To enable delivery:

1. In Slack Workflow Builder, create a **From a webhook** workflow. Add a
Text variables named `message` and `pr_details`. Add **Send a message to a
channel**, select the destination, and insert `message` into its body.
Then add **Reply to a message in thread**. For the message to reply to,
select the message output from the preceding send step; insert `pr_details`
into the reply body. Leave any option to broadcast the reply to the channel
disabled. Publish the Slack workflow (republish after changing variables).
2. Save its Web request URL (`https://hooks.slack.com/triggers/...`) under repository **Settings → Secrets and variables → Actions**
as `SLACK_REVIEW_DIGEST_WEBHOOK_URL`. Never commit the URL.
3. Merge the workflow into the default branch. In **Actions → Slack review
digest → Run workflow**, leave `dry_run` enabled to preview; disable it
to send a test message.

No personal GitHub token or Python packages are needed in Actions. The job
uses its read-only repository token. Missing secrets and API errors fail the
job. Runs are not automatically retried; rerunning a sent or partially sent
job can duplicate messages. GitHub schedules may be delayed, so the digest
shows the actual reporting time. Activity after that time is outside the
same-day report.

Local preview (requires an authenticated GitHub CLI and Python 3.9+):

```bash
GH_TOKEN="$(gh auth token)" python3 scripts/slack_review_digest.py --dry-run
```

Run the focused offline checks with:

```bash
python3 scripts/test_slack_review_digest.py
```
137 changes: 137 additions & 0 deletions scripts/slack_review_digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Send the daily PR digest; --dry-run prints messages without contacting Slack."""

import argparse
import json
import os
import sys
from datetime import datetime, timedelta, timezone
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo

PACIFIC = ZoneInfo("America/Los_Angeles")


def timestamp(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))


def pull_requests(repository, token, state, since=None):
"""Page through PRs; updated order permits stopping at the day's boundary."""
page = 1
while True:
query = urlencode(
dict(state=state, sort="updated", direction="desc", per_page=100, page=page)
)
request = Request(
f"https://api.github.com/repos/{repository}/pulls?{query}",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
},
)
with urlopen(request, timeout=30) as response:
batch = json.load(response)
for pr in batch:
if since is not None and timestamp(pr["updated_at"]) < since:
return
yield pr
if len(batch) < 100:
return
page += 1


def messages(repository, open_prs, recent_prs, now):
local = now.astimezone(PACIFIC)
start = local.replace(hour=0, minute=0, second=0, microsecond=0)
ready = [pr for pr in open_prs if not pr["draft"]]
stale = sorted(
(
pr
for pr in ready
if timestamp(pr["created_at"]) < now - timedelta(hours=120)
),
key=lambda pr: pr["created_at"],
)

def today(value):
return value is not None and start <= timestamp(value) <= now

merged = sum(today(pr["merged_at"]) for pr in recent_prs)
opened = sum(today(pr["created_at"]) for pr in recent_prs)
lines = [
f"AISimulate PR digest — {local:%Y-%m-%d, %I:%M %p %Z}",
f":merged-2472: PRs merged today: {merged}",
f":pr-opened: PRs opened today: {opened}",
f":reminder-alarm: PRs waiting for review: {len(ready)}",
"",
f"Open non-draft PRs older than 5 days — {len(stale)}",
]
summary = "\n".join(lines)
lines = []
for pr in stale:
age = (now - timestamp(pr["created_at"])).total_seconds() / 86400
title = " ".join(pr["title"].split())[:200]
author = (pr.get("user") or {}).get("login", "deleted-user")
lines.append(
f"• #{pr['number']} {title} — {author} · {age:.1f} days\n"
f" https://github.com/{repository}/pull/{pr['number']}"
)
if not stale:
lines.append("None.")
details = "\n".join(lines)
# Fail before triggering instead of silently dropping PRs in a large queue.
if len(details) > 35000:
raise ValueError(
"PR details exceed the single thread reply budget (35000 characters)"
)
return {"message": summary, "pr_details": details}


def send_message(webhook, payload):
"""Trigger one workflow: channel summary followed by a thread reply."""
request = Request(
webhook,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urlopen(request, timeout=30) as response:
result = json.load(response)
if not isinstance(result, dict) or result.get("ok") is not True:
raise ValueError("Slack did not acknowledge the workflow trigger")


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
repository = os.environ.get("GITHUB_REPOSITORY", "ai-dynamo/aisimulate")
token = os.environ["GH_TOKEN"]
webhook = os.environ.get("SLACK_REVIEW_DIGEST_WEBHOOK_URL", "")
if not args.dry_run and not webhook:
raise ValueError(
"Set SLACK_REVIEW_DIGEST_WEBHOOK_URL before sending the digest"
)
now = datetime.now(timezone.utc)
start = now.astimezone(PACIFIC).replace(hour=0, minute=0, second=0, microsecond=0)
open_prs = list(pull_requests(repository, token, "open"))
recent_prs = list(pull_requests(repository, token, "all", since=start))
payload = messages(repository, open_prs, recent_prs, now)
if args.dry_run:
print("CHANNEL MESSAGE:\n" + payload["message"])
print("\nTHREAD REPLY:\n" + payload["pr_details"])
else:
send_message(webhook, payload)


if __name__ == "__main__":
try:
main()
except HTTPError as error:
# Do not print request URLs: Slack webhook URLs contain credentials.
sys.exit(f"Digest request failed (HTTP {error.code})")
except URLError:
sys.exit("Digest request failed (network error)")
128 changes: 128 additions & 0 deletions scripts/test_slack_review_digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Offline checks for digest boundaries, pagination, and Slack formatting."""

import io
import json
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch

from slack_review_digest import PACIFIC, messages, pull_requests, send_message


class DigestTests(unittest.TestCase):
now = datetime(2026, 9, 30, 0, 7, tzinfo=timezone.utc)

def pr(self, number=1, age=6, **changes):
pr = dict(
number=number,
title="Fix <queue> & counters",
user={"login": "author"},
draft=False,
created_at=(self.now - timedelta(days=age)).isoformat(),
updated_at=self.now.isoformat(),
merged_at=None,
)
pr.update(changes)
return pr

def test_stale_boundary_drafts_and_oldest_first(self):
opened = [
self.pr(1, age=5),
self.pr(2, age=6),
self.pr(3, age=8),
self.pr(4, age=10, draft=True),
]
text = "".join(messages("ai-dynamo/aisimulate", opened, [], self.now).values())
self.assertIn(":reminder-alarm: PRs waiting for review: 3", text)
self.assertLess(text.index(":merged-2472:"), text.index(":pr-opened:"))
self.assertLess(text.index(":pr-opened:"), text.index(":reminder-alarm:"))
self.assertIn("older than 5 days — 2", text)
self.assertLess(text.index("• #3 "), text.index("• #2 "))
self.assertNotIn("• #1 ", text)
self.assertNotIn("• #4 ", text)
self.assertIn("<queue> & counters", text)

def test_pacific_day_includes_closed_and_draft_new_prs(self):
recent = [
self.pr(
created_at="2026-09-29T07:00:00Z", merged_at="2026-09-30T00:00:00Z"
),
self.pr(
created_at="2026-09-29T06:59:59Z", merged_at="2026-09-29T06:59:59Z"
),
self.pr(created_at="2026-09-29T12:00:00Z", draft=True, state="closed"),
self.pr(created_at="2026-09-30T01:00:00Z"),
]
text = "".join(messages("ai-dynamo/aisimulate", [], recent, self.now).values())
self.assertIn(":merged-2472: PRs merged today: 1", text)
self.assertIn(":pr-opened: PRs opened today: 2", text)
self.assertIn("2026-09-29, 05:07 PM PDT", text)

def test_dst_day_uses_midnight_offset(self):
now = datetime(2026, 11, 2, 1, 7, tzinfo=timezone.utc)
self.assertEqual(
now.astimezone(PACIFIC).replace(hour=0, minute=0).utcoffset(),
timedelta(hours=-7),
)
recent = [self.pr(created_at="2026-11-01T07:30:00Z")]
text = "".join(messages("ai-dynamo/aisimulate", [], recent, now).values())
self.assertIn(":pr-opened: PRs opened today: 1", text)
self.assertIn("05:07 PM PST", text)

def test_large_queue_retains_all_prs(self):
prs = [self.pr(i, title="x" * 200) for i in range(100)]
payload = messages("ai-dynamo/aisimulate", prs, [], self.now)
self.assertNotIn("https://", payload["message"])
self.assertNotIn("• #", payload["message"])
self.assertIn("older than 5 days — 100", payload["message"])
for i in range(100):
self.assertEqual(payload["pr_details"].count(f"• #{i} "), 1)
with self.assertRaises(ValueError):
messages("ai-dynamo/aisimulate", prs * 2, [], self.now)

def test_empty_queue_thread_reply(self):
payload = messages("ai-dynamo/aisimulate", [], [], self.now)
self.assertEqual(payload["pr_details"], "None.")

@patch("slack_review_digest.urlopen")
def test_workflow_payload_and_acknowledgement(self, urlopen):
urlopen.return_value = io.BytesIO(b'{"ok":true}')
send_message(
"https://hooks.slack.com/triggers/test",
{"message": "Digest", "pr_details": "PRs"},
)
request = urlopen.call_args.args[0]
self.assertEqual(
json.loads(request.data), {"message": "Digest", "pr_details": "PRs"}
)
urlopen.return_value = io.BytesIO(b'{"ok":false}')
with self.assertRaises(ValueError):
send_message(
"https://hooks.slack.com/triggers/test",
{"message": "Digest", "pr_details": "PRs"},
)

@patch("slack_review_digest.urlopen")
def test_pagination_and_updated_cutoff(self, urlopen):
first = [self.pr(i) for i in range(100)]
second = [self.pr(100), self.pr(101, updated_at="2026-09-28T00:00:00Z")]
urlopen.side_effect = [
io.BytesIO(json.dumps(page).encode()) for page in [first, second]
]
result = list(
pull_requests(
"ai-dynamo/aisimulate",
"test",
"all",
since=self.now - timedelta(hours=1),
)
)
self.assertEqual(len(result), 101)
self.assertEqual(urlopen.call_count, 2)
self.assertIn("page=2", urlopen.call_args.args[0].full_url)


if __name__ == "__main__":
unittest.main()
Loading