Repository navigation
ci: add daily Slack PR review digest #349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
380e463
ci: add daily Slack PR review digest
simone-chen 2998e20
ci: adjust Slack digest age threshold and emoji
simone-chen 50c1035
ci: simplify Slack digest message
simone-chen 07fad64
ci: update Slack digest summary labels and spacing
simone-chen 5dc1187
ci: deliver PR digest through Slack Workflow Builder
simone-chen f61bc1e
ci: separate PR details for Slack thread replies
simone-chen 1e1b795
Merge branch 'main' into simonec/slack-review-digest
simone-chen 48cc03c
Merge branch 'main' into simonec/slack-review-digest
simone-chen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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[@]}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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("&", "&").replace("<", "<").replace(">", ">") | ||
|
|
||
|
|
||
| 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: | ||
| 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)") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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("<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)) | ||
| 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() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.