From 480548058e0e4a31d4db922f0220e7110d9d4a1d Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 19 Mar 2026 13:36:52 +0900 Subject: [PATCH 1/3] Add script to ping on stale issues/PRs --- .../2026-03-19-stale-issue-pr-ping-design.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-19-stale-issue-pr-ping-design.md diff --git a/docs/superpowers/specs/2026-03-19-stale-issue-pr-ping-design.md b/docs/superpowers/specs/2026-03-19-stale-issue-pr-ping-design.md new file mode 100644 index 0000000000..43629bb481 --- /dev/null +++ b/docs/superpowers/specs/2026-03-19-stale-issue-pr-ping-design.md @@ -0,0 +1,95 @@ +# Stale Issue & PR Follow-Up Ping + +## Problem + +When a teammate responds to an external community issue or PR, the original author sometimes doesn't reply. These items go stale with no visibility. We need an automated daily check that pings the author after a configurable number of days of silence. + +## Scope + +- Open GitHub issues (not opened by team members) +- Open GitHub pull requests (not opened by team members) +- Same logic, same label, same threshold for both + +## Trigger + +- **Schedule:** Daily cron at midnight UTC (`0 0 * * *`) +- **Manual:** `workflow_dispatch` with configurable `days_threshold` input (default: `4`) + +## Behavior + +For each open issue and PR: + +1. **Skip** if it already has the `needs-info` label +2. **Skip** if the author is a team member +3. **Skip** if there are no comments +4. **Find the last comment from a team member** (walk backwards through comments, ignoring bot and non-team-member comments) +5. **Skip** if no team member has commented +6. **Skip** if the author has commented more recently than the last team member comment +7. **Check** if `DAYS_THRESHOLD` days have passed since that last team member comment +8. If all conditions met: + - Post a comment: `"@{author}, just checking in — do you have any updates on this?"` + - Add the `needs-info` label + +The `needs-info` label is left for teammates to manually remove when appropriate. + +## Architecture + +### Workflow YAML (`.github/workflows/stale-issue-pr-ping.yml`) + +Thin orchestration layer: + +- **Permissions:** `issues: write`, `pull-requests: write` +- **Concurrency:** `group: stale-issue-pr-ping`, `cancel-in-progress: true` +- **Token:** Must use `secrets.GH_ACTIONS_PR_WRITE` (not the default `GITHUB_TOKEN`) because the Teams API requires `read:org` scope. +- **Steps:** + 1. Checkout repo (`actions/checkout@v6`) + 2. Set up Python via `actions/setup-python@v5` + 3. `pip install PyGithub` + 4. Run `.github/scripts/stale_issue_pr_ping.py` with env vars: + - `GITHUB_TOKEN` — from `secrets.GH_ACTIONS_PR_WRITE` + - `GITHUB_REPOSITORY` — pre-set by Actions + - `TEAM_NAME` — from `secrets.DEVELOPER_TEAM` + - `DAYS_THRESHOLD` — from workflow input or default `4` + +Note: We use `actions/setup-python` + `pip install` rather than the repo's `python-setup` composite action (which sets up `uv` and the full workspace). This script is a standalone ops tool with a single dependency — the full workspace setup is unnecessary. + +### Python Script (`.github/scripts/stale_issue_pr_ping.py`) + +Core logic, structured for testability: + +**Functions:** + +- `main()` — reads env vars, orchestrates the scan +- `get_team_members(github_client, org, team_slug) -> set[str]` — fetches team member usernames once upfront (cached for the run). `org` is parsed from `GITHUB_REPOSITORY` (the portion before `/`). +- `find_last_team_comment(issue, team_members) -> Comment | None` — walks comments backwards, returns the last comment from a team member (skipping bots and non-team-members) +- `should_ping(issue, team_members, days_threshold) -> bool` — applies all skip conditions, returns whether this item should be pinged +- `ping(issue, author) -> None` — posts the follow-up comment and adds the `needs-info` label + +**Edge cases:** + +- **Pull requests:** GitHub's issues API returns PRs too. Instead of filtering them out, we process them with the same logic. Note: only top-level conversation comments are examined, not inline PR review comments — this is sufficient for detecting author silence. +- **Bot comments:** Ignored when searching for the last team member comment. Only comments from usernames in the team members set count. +- **Pagination:** PyGithub handles pagination automatically for large issue/comment lists. +- **Rate limiting:** PyGithub handles rate limiting with automatic retries. +- **Team API errors:** Fail loudly with a clear error rather than silently skipping. + +**Logging:** + +- Print summary: `"Scanned X items, pinged Y"` with list of pinged issue/PR numbers +- Log skip reasons for debugging + +## Configuration + +| Variable | Source | Default | Description | +|----------|--------|---------|-------------| +| `GITHUB_TOKEN` | `secrets.GH_ACTIONS_PR_WRITE` | — | API authentication (requires `read:org` for Teams API) | +| `GITHUB_REPOSITORY` | GitHub Actions env | — | `owner/repo` | +| `TEAM_NAME` | `secrets.DEVELOPER_TEAM` | — | Team slug for membership check | +| `DAYS_THRESHOLD` | Workflow input | `4` | Days of silence before pinging | + +## Not In Scope + +- Auto-removing `needs-info` label when the author responds (teammates handle manually) +- Closing stale issues/PRs after extended silence +- Different thresholds or labels for issues vs PRs +- Unit tests (standalone ops script, can add later) From fdf206ceae3d942a596dfafe14a665ee90b40294 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 19 Mar 2026 13:40:39 +0900 Subject: [PATCH 2/3] Add script to ping on stale issues/PRs --- .github/scripts/stale_issue_pr_ping.py | 183 ++++++++++++++++++ .github/workflows/stale-issue-pr-ping.yml | 48 +++++ .../2026-03-19-stale-issue-pr-ping-design.md | 95 --------- 3 files changed, 231 insertions(+), 95 deletions(-) create mode 100644 .github/scripts/stale_issue_pr_ping.py create mode 100644 .github/workflows/stale-issue-pr-ping.yml delete mode 100644 docs/superpowers/specs/2026-03-19-stale-issue-pr-ping-design.md diff --git a/.github/scripts/stale_issue_pr_ping.py b/.github/scripts/stale_issue_pr_ping.py new file mode 100644 index 0000000000..6fef28506f --- /dev/null +++ b/.github/scripts/stale_issue_pr_ping.py @@ -0,0 +1,183 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Scan open issues and PRs for stale follow-ups from external authors. + +If a team member commented and the external author hasn't replied within +DAYS_THRESHOLD days, post a reminder comment and add the 'needs-info' label. +""" + +from __future__ import annotations + +import os +import sys +import time +from datetime import datetime, timezone + +from github import Auth, Github +from github.Issue import Issue +from github.IssueComment import IssueComment + + +PING_COMMENT = ( + "@{author}, friendly reminder — this issue is waiting on your response. " + "Please share any updates when you get a chance. (This is an automated message.)" +) +LABEL = "needs-info" + + +def get_team_members(g: Github, org: str, team_slug: str) -> set[str]: + """Fetch active team member usernames.""" + try: + org_obj = g.get_organization(org) + team = org_obj.get_team_by_slug(team_slug) + return {m.login for m in team.get_members()} + except Exception as exc: + print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}") + sys.exit(1) + + +def find_last_team_comment( + issue: Issue, team_members: set[str] +) -> IssueComment | None: + """Return the most recent comment from a team member, or None.""" + comments = list(issue.get_comments()) + for comment in reversed(comments): + if comment.user and comment.user.login in team_members: + return comment + return None + + +def author_replied_after(issue: Issue, after: datetime) -> bool: + """Check if the issue author commented after the given timestamp.""" + author = issue.user.login + for comment in issue.get_comments(): + if ( + comment.user + and comment.user.login == author + and comment.created_at > after + ): + return True + return False + + +def should_ping( + issue: Issue, + team_members: set[str], + days_threshold: int, + now: datetime, +) -> bool: + """Determine whether this issue/PR should be pinged.""" + author = issue.user.login + + # Skip if author is a team member + if author in team_members: + return False + + # Skip if already labeled + if any(label.name == LABEL for label in issue.labels): + return False + + # Skip if no comments at all + if issue.comments == 0: + return False + + # Find last team member comment + last_team_comment = find_last_team_comment(issue, team_members) + if last_team_comment is None: + return False + + # Skip if author replied after the last team comment + if author_replied_after(issue, last_team_comment.created_at): + return False + + # Check if enough days have passed + days_since = (now - last_team_comment.created_at.replace(tzinfo=timezone.utc)).days + if days_since < days_threshold: + return False + + return True + + +def ping(issue: Issue, dry_run: bool) -> bool: + """Post a reminder comment and add the needs-info label. Returns True on success.""" + author = issue.user.login + kind = "PR" if issue.pull_request else "Issue" + + if dry_run: + print(f" [DRY RUN] Would ping {kind} #{issue.number} (@{author})") + return True + + max_retries = 3 + for attempt in range(1, max_retries + 1): + try: + issue.create_comment(PING_COMMENT.format(author=author)) + issue.add_to_labels(LABEL) + print(f" Pinged {kind} #{issue.number} (@{author})") + return True + except Exception as exc: + if attempt < max_retries: + wait = 2 ** attempt # 2s, 4s + print(f" WARN: Attempt {attempt}/{max_retries} failed for {kind} #{issue.number}: {exc}. Retrying in {wait}s...") + time.sleep(wait) + else: + print(f" ERROR: Failed to ping {kind} #{issue.number} after {max_retries} attempts: {exc}") + return False + + +def main() -> None: + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("ERROR: GITHUB_TOKEN environment variable is required") + sys.exit(1) + + repository = os.environ.get("GITHUB_REPOSITORY") + if not repository: + print("ERROR: GITHUB_REPOSITORY environment variable is required") + sys.exit(1) + + team_name = os.environ.get("TEAM_NAME") + if not team_name: + print("ERROR: TEAM_NAME environment variable is required") + sys.exit(1) + + days_threshold = int(os.environ.get("DAYS_THRESHOLD", "4")) + dry_run = os.environ.get("DRY_RUN", "false").lower() == "true" + + org = repository.split("/")[0] + + if dry_run: + print("Running in DRY RUN mode — no comments or labels will be applied.\n") + + g = Github(auth=Auth.Token(token)) + repo = g.get_repo(repository) + + print(f"Fetching team members for {org}/{team_name}...") + team_members = get_team_members(g, org, team_name) + print(f"Found {len(team_members)} team members.\n") + + now = datetime.now(timezone.utc) + pinged = [] + failed = [] + scanned = 0 + + print(f"Scanning open issues and PRs (threshold: {days_threshold} days)...\n") + + for issue in repo.get_issues(state="open"): + scanned += 1 + + if should_ping(issue, team_members, days_threshold, now): + if ping(issue, dry_run): + pinged.append(issue.number) + else: + failed.append(issue.number) + + print(f"\nDone. Scanned {scanned} items, pinged {len(pinged)}, failed {len(failed)}.") + if pinged: + print(f"Pinged: {', '.join(f'#{n}' for n in pinged)}") + if failed: + print(f"Failed: {', '.join(f'#{n}' for n in failed)}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/stale-issue-pr-ping.yml b/.github/workflows/stale-issue-pr-ping.yml new file mode 100644 index 0000000000..3e132949ac --- /dev/null +++ b/.github/workflows/stale-issue-pr-ping.yml @@ -0,0 +1,48 @@ +name: Stale issue and PR ping + +on: + schedule: + - cron: '0 0 * * *' # Midnight UTC daily + workflow_dispatch: + inputs: + days_threshold: + description: 'Days of silence before pinging the author' + required: false + default: '4' + dry_run: + description: 'Log what would be pinged without taking action' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + +concurrency: + group: stale-issue-pr-ping + cancel-in-progress: true + +jobs: + ping_stale: + name: "Ping stale issues and PRs" + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install dependencies + run: pip install PyGithub + + - name: Run stale issue/PR ping + run: python .github/scripts/stale_issue_pr_ping.py + env: + GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }} + TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} + DAYS_THRESHOLD: ${{ inputs.days_threshold || '4' }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} diff --git a/docs/superpowers/specs/2026-03-19-stale-issue-pr-ping-design.md b/docs/superpowers/specs/2026-03-19-stale-issue-pr-ping-design.md deleted file mode 100644 index 43629bb481..0000000000 --- a/docs/superpowers/specs/2026-03-19-stale-issue-pr-ping-design.md +++ /dev/null @@ -1,95 +0,0 @@ -# Stale Issue & PR Follow-Up Ping - -## Problem - -When a teammate responds to an external community issue or PR, the original author sometimes doesn't reply. These items go stale with no visibility. We need an automated daily check that pings the author after a configurable number of days of silence. - -## Scope - -- Open GitHub issues (not opened by team members) -- Open GitHub pull requests (not opened by team members) -- Same logic, same label, same threshold for both - -## Trigger - -- **Schedule:** Daily cron at midnight UTC (`0 0 * * *`) -- **Manual:** `workflow_dispatch` with configurable `days_threshold` input (default: `4`) - -## Behavior - -For each open issue and PR: - -1. **Skip** if it already has the `needs-info` label -2. **Skip** if the author is a team member -3. **Skip** if there are no comments -4. **Find the last comment from a team member** (walk backwards through comments, ignoring bot and non-team-member comments) -5. **Skip** if no team member has commented -6. **Skip** if the author has commented more recently than the last team member comment -7. **Check** if `DAYS_THRESHOLD` days have passed since that last team member comment -8. If all conditions met: - - Post a comment: `"@{author}, just checking in — do you have any updates on this?"` - - Add the `needs-info` label - -The `needs-info` label is left for teammates to manually remove when appropriate. - -## Architecture - -### Workflow YAML (`.github/workflows/stale-issue-pr-ping.yml`) - -Thin orchestration layer: - -- **Permissions:** `issues: write`, `pull-requests: write` -- **Concurrency:** `group: stale-issue-pr-ping`, `cancel-in-progress: true` -- **Token:** Must use `secrets.GH_ACTIONS_PR_WRITE` (not the default `GITHUB_TOKEN`) because the Teams API requires `read:org` scope. -- **Steps:** - 1. Checkout repo (`actions/checkout@v6`) - 2. Set up Python via `actions/setup-python@v5` - 3. `pip install PyGithub` - 4. Run `.github/scripts/stale_issue_pr_ping.py` with env vars: - - `GITHUB_TOKEN` — from `secrets.GH_ACTIONS_PR_WRITE` - - `GITHUB_REPOSITORY` — pre-set by Actions - - `TEAM_NAME` — from `secrets.DEVELOPER_TEAM` - - `DAYS_THRESHOLD` — from workflow input or default `4` - -Note: We use `actions/setup-python` + `pip install` rather than the repo's `python-setup` composite action (which sets up `uv` and the full workspace). This script is a standalone ops tool with a single dependency — the full workspace setup is unnecessary. - -### Python Script (`.github/scripts/stale_issue_pr_ping.py`) - -Core logic, structured for testability: - -**Functions:** - -- `main()` — reads env vars, orchestrates the scan -- `get_team_members(github_client, org, team_slug) -> set[str]` — fetches team member usernames once upfront (cached for the run). `org` is parsed from `GITHUB_REPOSITORY` (the portion before `/`). -- `find_last_team_comment(issue, team_members) -> Comment | None` — walks comments backwards, returns the last comment from a team member (skipping bots and non-team-members) -- `should_ping(issue, team_members, days_threshold) -> bool` — applies all skip conditions, returns whether this item should be pinged -- `ping(issue, author) -> None` — posts the follow-up comment and adds the `needs-info` label - -**Edge cases:** - -- **Pull requests:** GitHub's issues API returns PRs too. Instead of filtering them out, we process them with the same logic. Note: only top-level conversation comments are examined, not inline PR review comments — this is sufficient for detecting author silence. -- **Bot comments:** Ignored when searching for the last team member comment. Only comments from usernames in the team members set count. -- **Pagination:** PyGithub handles pagination automatically for large issue/comment lists. -- **Rate limiting:** PyGithub handles rate limiting with automatic retries. -- **Team API errors:** Fail loudly with a clear error rather than silently skipping. - -**Logging:** - -- Print summary: `"Scanned X items, pinged Y"` with list of pinged issue/PR numbers -- Log skip reasons for debugging - -## Configuration - -| Variable | Source | Default | Description | -|----------|--------|---------|-------------| -| `GITHUB_TOKEN` | `secrets.GH_ACTIONS_PR_WRITE` | — | API authentication (requires `read:org` for Teams API) | -| `GITHUB_REPOSITORY` | GitHub Actions env | — | `owner/repo` | -| `TEAM_NAME` | `secrets.DEVELOPER_TEAM` | — | Team slug for membership check | -| `DAYS_THRESHOLD` | Workflow input | `4` | Days of silence before pinging | - -## Not In Scope - -- Auto-removing `needs-info` label when the author responds (teammates handle manually) -- Closing stale issues/PRs after extended silence -- Different thresholds or labels for issues vs PRs -- Unit tests (standalone ops script, can add later) From 9771dbbc49be9d62da05d6a6e280342de7b87f45 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 19 Mar 2026 05:01:15 +0000 Subject: [PATCH 3/3] Fix stale issue/PR ping script review comments - Rename TEAM_NAME env var to TEAM_SLUG for clarity - Add actionable error messages for 403/404 team lookup failures - Add contents:read permission for actions/checkout - Use github.event.inputs context with fallback for scheduled runs - Pin PyGithub to 2.6.0 for reproducible builds - Fetch comments once in should_ping() to reduce API calls - Make ping() retry loop idempotent (track comment/label state) - Validate DAYS_THRESHOLD with helpful error for non-numeric input - Fix timezone bug: use astimezone() instead of replace(tzinfo=) - Add comprehensive unit tests (29 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/stale_issue_pr_ping.py | 58 +++-- .github/tests/test_stale_issue_pr_ping.py | 293 ++++++++++++++++++++++ .github/workflows/stale-issue-pr-ping.yml | 9 +- 3 files changed, 339 insertions(+), 21 deletions(-) create mode 100644 .github/tests/test_stale_issue_pr_ping.py diff --git a/.github/scripts/stale_issue_pr_ping.py b/.github/scripts/stale_issue_pr_ping.py index 6fef28506f..0effcd5d75 100644 --- a/.github/scripts/stale_issue_pr_ping.py +++ b/.github/scripts/stale_issue_pr_ping.py @@ -13,7 +13,7 @@ import time from datetime import datetime, timezone -from github import Auth, Github +from github import Auth, Github, GithubException from github.Issue import Issue from github.IssueComment import IssueComment @@ -31,26 +31,36 @@ def get_team_members(g: Github, org: str, team_slug: str) -> set[str]: org_obj = g.get_organization(org) team = org_obj.get_team_by_slug(team_slug) return {m.login for m in team.get_members()} + except GithubException as exc: + if exc.status in (403, 404): + print( + f"ERROR: Failed to fetch team members for {org}/{team_slug} " + f"(HTTP {exc.status}). Check that the token has the 'read:org' " + f"scope and that the team slug '{team_slug}' is correct." + ) + else: + print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}") + sys.exit(1) except Exception as exc: print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}") sys.exit(1) def find_last_team_comment( - issue: Issue, team_members: set[str] + comments: list[IssueComment], team_members: set[str] ) -> IssueComment | None: """Return the most recent comment from a team member, or None.""" - comments = list(issue.get_comments()) for comment in reversed(comments): if comment.user and comment.user.login in team_members: return comment return None -def author_replied_after(issue: Issue, after: datetime) -> bool: +def author_replied_after( + comments: list[IssueComment], author: str, after: datetime +) -> bool: """Check if the issue author commented after the given timestamp.""" - author = issue.user.login - for comment in issue.get_comments(): + for comment in comments: if ( comment.user and comment.user.login == author @@ -81,17 +91,20 @@ def should_ping( if issue.comments == 0: return False + # Fetch comments once for both lookups + comments = list(issue.get_comments()) + # Find last team member comment - last_team_comment = find_last_team_comment(issue, team_members) + last_team_comment = find_last_team_comment(comments, team_members) if last_team_comment is None: return False # Skip if author replied after the last team comment - if author_replied_after(issue, last_team_comment.created_at): + if author_replied_after(comments, author, last_team_comment.created_at): return False # Check if enough days have passed - days_since = (now - last_team_comment.created_at.replace(tzinfo=timezone.utc)).days + days_since = (now - last_team_comment.created_at.astimezone(timezone.utc)).days if days_since < days_threshold: return False @@ -108,10 +121,16 @@ def ping(issue: Issue, dry_run: bool) -> bool: return True max_retries = 3 + commented = False + labeled = False for attempt in range(1, max_retries + 1): try: - issue.create_comment(PING_COMMENT.format(author=author)) - issue.add_to_labels(LABEL) + if not commented: + issue.create_comment(PING_COMMENT.format(author=author)) + commented = True + if not labeled: + issue.add_to_labels(LABEL) + labeled = True print(f" Pinged {kind} #{issue.number} (@{author})") return True except Exception as exc: @@ -135,12 +154,17 @@ def main() -> None: print("ERROR: GITHUB_REPOSITORY environment variable is required") sys.exit(1) - team_name = os.environ.get("TEAM_NAME") - if not team_name: - print("ERROR: TEAM_NAME environment variable is required") + team_slug = os.environ.get("TEAM_SLUG") + if not team_slug: + print("ERROR: TEAM_SLUG environment variable is required") sys.exit(1) - days_threshold = int(os.environ.get("DAYS_THRESHOLD", "4")) + days_threshold_raw = os.environ.get("DAYS_THRESHOLD", "4") + try: + days_threshold = int(days_threshold_raw) + except ValueError: + print(f"ERROR: DAYS_THRESHOLD must be a numeric value, got '{days_threshold_raw}'") + sys.exit(1) dry_run = os.environ.get("DRY_RUN", "false").lower() == "true" org = repository.split("/")[0] @@ -151,8 +175,8 @@ def main() -> None: g = Github(auth=Auth.Token(token)) repo = g.get_repo(repository) - print(f"Fetching team members for {org}/{team_name}...") - team_members = get_team_members(g, org, team_name) + print(f"Fetching team members for {org}/{team_slug}...") + team_members = get_team_members(g, org, team_slug) print(f"Found {len(team_members)} team members.\n") now = datetime.now(timezone.utc) diff --git a/.github/tests/test_stale_issue_pr_ping.py b/.github/tests/test_stale_issue_pr_ping.py new file mode 100644 index 0000000000..f114a84ed8 --- /dev/null +++ b/.github/tests/test_stale_issue_pr_ping.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for stale_issue_pr_ping.py.""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure the script directory is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +from stale_issue_pr_ping import ( + LABEL, + PING_COMMENT, + author_replied_after, + find_last_team_comment, + get_team_members, + main, + ping, + should_ping, +) + +TEAM = {"alice", "bob"} +NOW = datetime(2026, 3, 15, 12, 0, 0, tzinfo=timezone.utc) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_comment(login: str | None, created_at: datetime) -> MagicMock: + """Create a mock IssueComment.""" + c = MagicMock() + if login is None: + c.user = None + else: + c.user = MagicMock() + c.user.login = login + c.created_at = created_at + return c + + +def _make_label(name: str) -> MagicMock: + lbl = MagicMock() + lbl.name = name + return lbl + + +def _make_issue( + author: str = "external", + labels: list[str] | None = None, + comment_count: int = 1, + comments: list[MagicMock] | None = None, + pull_request: bool = False, + number: int = 42, +) -> MagicMock: + issue = MagicMock() + issue.user = MagicMock() + issue.user.login = author + issue.number = number + issue.labels = [_make_label(n) for n in (labels or [])] + issue.comments = comment_count + issue.pull_request = MagicMock() if pull_request else None + if comments is not None: + issue.get_comments.return_value = comments + return issue + + +# --------------------------------------------------------------------------- +# find_last_team_comment +# --------------------------------------------------------------------------- + +class TestFindLastTeamComment: + def test_returns_last_team_comment(self): + c1 = _make_comment("alice", datetime(2026, 3, 1, tzinfo=timezone.utc)) + c2 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) + c3 = _make_comment("bob", datetime(2026, 3, 3, tzinfo=timezone.utc)) + assert find_last_team_comment([c1, c2, c3], TEAM) is c3 + + def test_returns_none_when_no_team_comments(self): + c1 = _make_comment("external", datetime(2026, 3, 1, tzinfo=timezone.utc)) + assert find_last_team_comment([c1], TEAM) is None + + def test_returns_none_for_empty_list(self): + assert find_last_team_comment([], TEAM) is None + + def test_skips_deleted_user(self): + c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc)) + c2 = _make_comment("alice", datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert find_last_team_comment([c1, c2], TEAM) is c2 + + def test_only_deleted_users(self): + c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc)) + assert find_last_team_comment([c1], TEAM) is None + + +# --------------------------------------------------------------------------- +# author_replied_after +# --------------------------------------------------------------------------- + +class TestAuthorRepliedAfter: + def test_author_replied(self): + after = datetime(2026, 3, 1, tzinfo=timezone.utc) + c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert author_replied_after([c1], "external", after) is True + + def test_author_not_replied(self): + after = datetime(2026, 3, 5, tzinfo=timezone.utc) + c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert author_replied_after([c1], "external", after) is False + + def test_different_user_replied(self): + after = datetime(2026, 3, 1, tzinfo=timezone.utc) + c1 = _make_comment("someone_else", datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert author_replied_after([c1], "external", after) is False + + def test_deleted_user_comment(self): + after = datetime(2026, 3, 1, tzinfo=timezone.utc) + c1 = _make_comment(None, datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert author_replied_after([c1], "external", after) is False + + +# --------------------------------------------------------------------------- +# should_ping +# --------------------------------------------------------------------------- + +class TestShouldPing: + def test_should_ping_stale_issue(self): + team_comment = _make_comment("alice", NOW - timedelta(days=5)) + issue = _make_issue(comments=[team_comment], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is True + + def test_skip_team_member_author(self): + issue = _make_issue(author="alice", comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_already_labeled(self): + issue = _make_issue(labels=[LABEL], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_no_comments(self): + issue = _make_issue(comment_count=0) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_no_team_comment(self): + c = _make_comment("external", NOW - timedelta(days=5)) + issue = _make_issue(comments=[c], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_author_replied(self): + team_c = _make_comment("alice", NOW - timedelta(days=5)) + author_c = _make_comment("external", NOW - timedelta(days=3)) + issue = _make_issue(comments=[team_c, author_c], comment_count=2) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_not_enough_days(self): + team_comment = _make_comment("alice", NOW - timedelta(days=2)) + issue = _make_issue(comments=[team_comment], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_aware_datetime_handled(self): + """Timezone-aware datetimes should not be mangled by astimezone.""" + aware_dt = (NOW - timedelta(days=5)).replace(tzinfo=timezone.utc) + team_comment = _make_comment("alice", aware_dt) + issue = _make_issue(comments=[team_comment], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is True + + def test_naive_datetime_handled(self): + """Naive datetimes (pre-PyGithub 2.x) should be handled by astimezone.""" + naive_dt = (NOW - timedelta(days=5)).replace(tzinfo=None) + team_comment = _make_comment("alice", naive_dt) + issue = _make_issue(comments=[team_comment], comment_count=1) + # astimezone on naive datetime treats it as local time; just verify no crash + should_ping(issue, TEAM, 4, NOW) + + +# --------------------------------------------------------------------------- +# ping +# --------------------------------------------------------------------------- + +class TestPing: + def test_dry_run(self, capsys): + issue = _make_issue() + assert ping(issue, dry_run=True) is True + issue.create_comment.assert_not_called() + assert "DRY RUN" in capsys.readouterr().out + + def test_success(self, capsys): + issue = _make_issue() + assert ping(issue, dry_run=False) is True + issue.create_comment.assert_called_once() + issue.add_to_labels.assert_called_once_with(LABEL) + + @patch("stale_issue_pr_ping.time.sleep") + def test_retry_on_failure(self, mock_sleep): + issue = _make_issue() + issue.create_comment.side_effect = [Exception("net error"), None] + assert ping(issue, dry_run=False) is True + assert issue.create_comment.call_count == 2 + mock_sleep.assert_called_once() + + @patch("stale_issue_pr_ping.time.sleep") + def test_idempotent_retry_skips_comment_on_label_failure(self, mock_sleep): + """If create_comment succeeds but add_to_labels fails, retry should not re-comment.""" + issue = _make_issue() + issue.add_to_labels.side_effect = [Exception("label error"), None] + assert ping(issue, dry_run=False) is True + # Comment should only be created once even though there were 2 attempts + assert issue.create_comment.call_count == 1 + assert issue.add_to_labels.call_count == 2 + + @patch("stale_issue_pr_ping.time.sleep") + def test_all_retries_fail(self, mock_sleep): + issue = _make_issue() + issue.create_comment.side_effect = Exception("permanent error") + assert ping(issue, dry_run=False) is False + assert issue.create_comment.call_count == 3 + + +# --------------------------------------------------------------------------- +# get_team_members +# --------------------------------------------------------------------------- + +class TestGetTeamMembers: + def test_success(self): + g = MagicMock() + member = MagicMock() + member.login = "alice" + g.get_organization.return_value.get_team_by_slug.return_value.get_members.return_value = [member] + assert get_team_members(g, "org", "my-team") == {"alice"} + + def test_403_error_message(self, capsys): + from github import GithubException + + g = MagicMock() + g.get_organization.return_value.get_team_by_slug.side_effect = GithubException( + 403, {"message": "Forbidden"}, None + ) + with pytest.raises(SystemExit): + get_team_members(g, "org", "my-team") + out = capsys.readouterr().out + assert "read:org" in out + assert "403" in out + + def test_404_error_message(self, capsys): + from github import GithubException + + g = MagicMock() + g.get_organization.return_value.get_team_by_slug.side_effect = GithubException( + 404, {"message": "Not Found"}, None + ) + with pytest.raises(SystemExit): + get_team_members(g, "org", "bad-slug") + out = capsys.readouterr().out + assert "read:org" in out + assert "bad-slug" in out + + def test_generic_error(self, capsys): + g = MagicMock() + g.get_organization.side_effect = RuntimeError("boom") + with pytest.raises(SystemExit): + get_team_members(g, "org", "team") + + +# --------------------------------------------------------------------------- +# main – env var validation +# --------------------------------------------------------------------------- + +class TestMain: + @patch.dict(os.environ, { + "GITHUB_TOKEN": "tok", + "GITHUB_REPOSITORY": "org/repo", + "TEAM_SLUG": "my-team", + "DAYS_THRESHOLD": "abc", + }, clear=True) + def test_invalid_days_threshold(self, capsys): + with pytest.raises(SystemExit): + main() + assert "numeric" in capsys.readouterr().out + + @patch.dict(os.environ, { + "GITHUB_TOKEN": "tok", + "GITHUB_REPOSITORY": "org/repo", + }, clear=True) + def test_missing_team_slug(self, capsys): + with pytest.raises(SystemExit): + main() + assert "TEAM_SLUG" in capsys.readouterr().out diff --git a/.github/workflows/stale-issue-pr-ping.yml b/.github/workflows/stale-issue-pr-ping.yml index 3e132949ac..483706fc76 100644 --- a/.github/workflows/stale-issue-pr-ping.yml +++ b/.github/workflows/stale-issue-pr-ping.yml @@ -27,6 +27,7 @@ jobs: name: "Ping stale issues and PRs" runs-on: ubuntu-latest permissions: + contents: read issues: write pull-requests: write steps: @@ -37,12 +38,12 @@ jobs: python-version: '3.13' - name: Install dependencies - run: pip install PyGithub + run: pip install PyGithub==2.6.0 - name: Run stale issue/PR ping run: python .github/scripts/stale_issue_pr_ping.py env: GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }} - TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} - DAYS_THRESHOLD: ${{ inputs.days_threshold || '4' }} - DRY_RUN: ${{ inputs.dry_run || 'false' }} + TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }} + DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}