Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
183 changes: 183 additions & 0 deletions .github/scripts/stale_issue_pr_ping.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
moonbox3 marked this conversation as resolved.


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
Comment thread
moonbox3 marked this conversation as resolved.
Outdated
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
Comment thread
moonbox3 marked this conversation as resolved.

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}")
Comment thread
moonbox3 marked this conversation as resolved.
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)
Comment thread
moonbox3 marked this conversation as resolved.
Outdated

days_threshold = int(os.environ.get("DAYS_THRESHOLD", "4"))
dry_run = os.environ.get("DRY_RUN", "false").lower() == "true"
Comment thread
moonbox3 marked this conversation as resolved.
Outdated

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()
Comment thread
moonbox3 marked this conversation as resolved.
48 changes: 48 additions & 0 deletions .github/workflows/stale-issue-pr-ping.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
moonbox3 marked this conversation as resolved.

- uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Install dependencies
run: pip install PyGithub
Comment thread
moonbox3 marked this conversation as resolved.
Outdated

- 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' }}
Comment thread
moonbox3 marked this conversation as resolved.
Outdated
Loading