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 all commits
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
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,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)") |
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,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() |
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.