Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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[@]}"
41 changes: 41 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,44 @@ 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 72 hours ago, oldest first, with
its link, title, author, and age. Age measures creation time, not inactivity
or time since leaving draft. Large lists span multiple Slack messages.

To enable delivery:

1. Create a Slack app, enable Incoming Webhooks, and add a webhook for the
destination channel (workspace admin approval may be required).
2. Save its URL 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
```
149 changes: 149 additions & 0 deletions scripts/slack_review_digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# 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 escape(value):
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


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=72)),
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)
date_range = (
f"{start.isoformat(timespec='seconds')}..{now.isoformat(timespec='seconds')}"
)

def link(query, label):
url = "https://github.com/pulls?" + urlencode(
{"q": f"repo:{repository} is:pr {query}"}
)
return f"<{url}|{label}>"

lines = [
f"*AISimulate PR digest — {local:%Y-%m-%d, %I:%M %p %Z}*",
f"• Open and ready for review: *{len(ready)}*",
f"• Merged today: *{merged}*",
f"• Opened today: *{opened}*",
"Today: Pacific midnight through the time shown above.",
" · ".join(
[
link("is:open draft:false", "View review queue"),
link(f"is:merged merged:{date_range}", "View merged PRs"),
link(f"created:{date_range}", "View new PRs"),
]
),
"",
f"*Open non-draft PRs older than 72 hours — {len(stale)}*",
]
for pr in stale:
age = (now - timestamp(pr["created_at"])).total_seconds() / 86400
title = escape(" ".join(pr["title"].split())[:200])
author = escape((pr.get("user") or {}).get("login", "deleted-user"))
lines.append(
f"• <https://github.com/{repository}/pull/{pr['number']}|#{pr['number']}> "
f"{title} — {author} · {age:.1f} days"
)
if not stale:
lines.append("None.")
# Keep every stale PR, splitting large queues into comfortably sized messages.
chunk = ""
for line in lines:
if len(chunk) + len(line) + 1 > 3500:
yield chunk
chunk = "*AISimulate PR digest — continued*\n"
chunk += line + "\n"
yield chunk


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))
for message in messages(repository, open_prs, recent_prs, now):
if args.dry_run:
print(message)
continue
request = Request(
webhook,
data=json.dumps(
{"text": message, "unfurl_links": False, "unfurl_media": False}
).encode(),
headers={"Content-Type": "application/json"},
)
with urlopen(request, timeout=30) as response:
Comment thread
simone-chen marked this conversation as resolved.
Resolved
if response.read().strip() != b"ok":
raise ValueError("Slack did not acknowledge the digest")


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)")
101 changes: 101 additions & 0 deletions scripts/test_slack_review_digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# 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


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

def pr(self, number=1, age=4, **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=3),
self.pr(2, age=4),
self.pr(3, age=6),
self.pr(4, age=10, draft=True),
]
text = "".join(messages("ai-dynamo/aisimulate", opened, [], self.now))
self.assertIn("ready for review: *3*", text)
self.assertIn("older than 72 hours — 2", text)
self.assertLess(text.index("|#3>"), text.index("|#2>"))
self.assertNotIn("|#1>", text)
self.assertNotIn("|#4>", text)
self.assertIn("&lt;queue&gt; &amp; 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))
self.assertIn("Merged today: *1*", text)
self.assertIn("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))
self.assertIn("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)]
chunks = list(messages("ai-dynamo/aisimulate", prs, [], self.now))
self.assertGreater(len(chunks), 1)
self.assertTrue(all(len(chunk) <= 3500 for chunk in chunks))
for i in range(100):
self.assertEqual("".join(chunks).count(f"|#{i}>"), 1)

@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