Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
148 changes: 148 additions & 0 deletions .github/scripts/dry_run_inactivity_unassign_phase2.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
set -euo pipefail

# DRY-RUN: Phase 2 Inactivity Unassign Bot
# - Does NOT change anything
# - Logs which PRs/issues WOULD be affected

REPO="${REPO:-}"
DAYS="${DAYS:-21}"

if [ -z "$REPO" ]; then
echo "ERROR: REPO environment variable not set."
echo "Example: export REPO=owner/repo"
exit 1
fi

echo "------------------------------------------------------------"
echo " DRY RUN: Phase 2 Inactivity Unassign (PR inactivity)"
echo " Repo: $REPO"
echo " Threshold: $DAYS days (no commit activity on PR)"
echo "------------------------------------------------------------"

NOW_TS=$(date +%s)

parse_ts() {
local ts="$1"
if date --version >/dev/null 2>&1; then
date -d "$ts" +%s
else
date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s"
fi
}

declare -a SUMMARY=()

ISSUES=$(gh api "repos/$REPO/issues" \
--paginate \
--jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number')

if [ -z "$ISSUES" ]; then
echo "No open issues with assignees found."
exit 0
fi

echo "[INFO] Found issues: $ISSUES"
echo

for ISSUE in $ISSUES; do
echo "============================================================"
echo " ISSUE #$ISSUE"
echo "============================================================"

ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE")
ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login')

if [ -z "$ASSIGNEES" ]; then
echo "[INFO] No assignees? Skipping."
echo
continue
fi

echo "[INFO] Assignees: $ASSIGNEES"
echo

for USER in $ASSIGNEES; do
echo " → Checking assignee: $USER"

PR_NUMBERS=$(gh api \
-H "Accept: application/vnd.github.mockingbird-preview+json" \
"repos/$REPO/issues/$ISSUE/timeline" \
--jq ".[]
| select(.event == \"cross-referenced\")
| select(.source.issue.pull_request != null)
| select(.source.issue.user.login == \"$USER\")
| .source.issue.number")

if [ -z "$PR_NUMBERS" ]; then
echo " [INFO] No linked PRs by $USER for this issue."
echo
continue
fi

echo " [INFO] Linked PRs by $USER: $PR_NUMBERS"

STALE_PR=""
STALE_AGE_DAYS=0

for PR_NUM in $PR_NUMBERS; do
PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state')

if [ "$PR_STATE" != "OPEN" ]; then
echo " [SKIP] PR #$PR_NUM is not open ($PR_STATE)."
continue
fi

# Use the same "last commit" logic as the real bot (with pagination)
COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate 2>/dev/null || echo "")

LAST_COMMIT_DATE=$(echo "$COMMITS_JSON" \
| jq -r 'last | (.commit.committer.date // .commit.author.date)' 2>/dev/null || echo "")

if [ -z "$LAST_COMMIT_DATE" ] || [ "$LAST_COMMIT_DATE" = "null" ]; then
echo " [WARN] Could not determine last commit date for PR #$PR_NUM, skipping."
continue
fi

LAST_COMMIT_TS=$(parse_ts "$LAST_COMMIT_DATE")
AGE_DAYS=$(( (NOW_TS - LAST_COMMIT_TS) / 86400 ))

echo " [INFO] PR #$PR_NUM last commit: $LAST_COMMIT_DATE (~${AGE_DAYS} days ago)"

if [ "$AGE_DAYS" -ge "$DAYS" ]; then
STALE_PR="$PR_NUM"
STALE_AGE_DAYS="$AGE_DAYS"
break
fi
done

if [ -z "$STALE_PR" ]; then
echo " [KEEP] No OPEN PR for $USER is stale (>= $DAYS days)."
echo
continue
fi

echo " [DRY RUN] Would CLOSE PR #$STALE_PR (no commits for $STALE_AGE_DAYS days)"
echo " [DRY RUN] Would UNASSIGN @$USER from issue #$ISSUE"
echo

SUMMARY+=("Issue #$ISSUE → user @$USER → stale PR #$STALE_PR (no commits for $STALE_AGE_DAYS days)")
done

echo
done

if [ ${#SUMMARY[@]} -gt 0 ]; then
echo "============================================================"
echo " SUMMARY: Actions that WOULD be taken (no changes made)"
echo "============================================================"
for ITEM in "${SUMMARY[@]}"; do
echo " - $ITEM"
done
else
echo "No stale PRs / unassignments detected in this dry-run."
fi

echo "------------------------------------------------------------"
echo " DRY RUN COMPLETE — No changes were made."
echo "------------------------------------------------------------"
161 changes: 161 additions & 0 deletions .github/scripts/inactivity_unassign_phase2.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail

# Inactivity Unassign Bot (Phase 2 - PR inactivity)
# Env:
# GH_TOKEN - provided by GitHub Actions
# REPO - owner/repo (fallback to GITHUB_REPOSITORY)
# DAYS - inactivity threshold in days (default 21)

REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
DAYS="${DAYS:-21}"

if [ -z "$REPO" ]; then
echo "ERROR: REPO environment variable not set."
exit 1
fi

echo "------------------------------------------------------------"
echo " Inactivity Unassign Bot (Phase 2 - PR inactivity)"
echo " Repo: $REPO"
echo " Threshold: $DAYS days (no commit activity on PR)"
echo "------------------------------------------------------------"

NOW_TS=$(date +%s)

# Cross-platform timestamp parsing (Linux + macOS/BSD)
parse_ts() {
local ts="$1"
if date --version >/dev/null 2>&1; then
# GNU date (Linux)
date -d "$ts" +%s
else
# macOS / BSD
date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s"
fi
}

# Fetch open ISSUES (not PRs) that have assignees
ISSUES=$(gh api "repos/$REPO/issues" \
--paginate \
--jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number')

if [ -z "$ISSUES" ]; then
echo "No open issues with assignees found."
exit 0
fi

for ISSUE in $ISSUES; do
echo "============================================================"
echo " ISSUE #$ISSUE"
echo "============================================================"

ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE")
ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login')

if [ -z "$ASSIGNEES" ]; then
echo "[INFO] No assignees? Skipping."
echo
continue
fi

echo "[INFO] Assignees: $ASSIGNEES"
echo

for USER in $ASSIGNEES; do
echo " → Checking assignee: $USER"

# Find OPEN PRs linked to THIS issue, authored by THIS user
PR_NUMBERS=$(gh api \
-H "Accept: application/vnd.github.mockingbird-preview+json" \
"repos/$REPO/issues/$ISSUE/timeline" \
--jq ".[]
| select(.event == \"cross-referenced\")
| select(.source.issue.pull_request != null)
| select(.source.issue.user.login == \"$USER\")
| .source.issue.number")

if [ -z "$PR_NUMBERS" ]; then
echo " [INFO] No linked PRs by $USER for this issue → Phase 1 covers the no-PR case."
echo
continue
fi

echo " [INFO] Linked PRs by $USER: $PR_NUMBERS"

STALE_PR=""
STALE_AGE_DAYS=0

# Look for a stale OPEN PR
for PR_NUM in $PR_NUMBERS; do
PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state')

if [ "$PR_STATE" != "OPEN" ]; then
echo " [SKIP] PR #$PR_NUM is not open ($PR_STATE)."
continue
fi

# Last commit date on the PR (use API order + paginate, take last)
COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate 2>/dev/null || echo "")

LAST_COMMIT_DATE=$(echo "$COMMITS_JSON" \
| jq -r 'last | (.commit.committer.date // .commit.author.date)' 2>/dev/null || echo "")

if [ -z "$LAST_COMMIT_DATE" ] || [ "$LAST_COMMIT_DATE" = "null" ]; then
echo " [WARN] Could not determine last commit date for PR #$PR_NUM, skipping."
continue
fi

LAST_COMMIT_TS=$(parse_ts "$LAST_COMMIT_DATE")
AGE_DAYS=$(( (NOW_TS - LAST_COMMIT_TS) / 86400 ))

echo " [INFO] PR #$PR_NUM last commit: $LAST_COMMIT_DATE (~${AGE_DAYS} days ago)"

if [ "$AGE_DAYS" -ge "$DAYS" ]; then
STALE_PR="$PR_NUM"
STALE_AGE_DAYS="$AGE_DAYS"
break
fi
done

if [ -z "$STALE_PR" ]; then
echo " [KEEP] No OPEN PR for $USER is stale (>= $DAYS days)."
echo
continue
fi

echo " [STALE] PR #$STALE_PR by $USER has had no commit activity for $STALE_AGE_DAYS days (>= $DAYS)."
echo " [ACTION] Closing PR #$STALE_PR and unassigning @$USER from issue #$ISSUE."

MESSAGE=$(
cat <<EOF
Hi @$USER, this is InactivityBot.

This pull request has become stale, with no development activity for **$STALE_AGE_DAYS days**. As a result, we have closed this pull request and unassigned you from the linked issue to keep the backlog available for active contributors.

You are welcome to get assigned to an issue once again once you have capacity.

In the future, please close old pull requests that will not have development activity and request to be unassigned if you are no longer working on the issue.
EOF
)

# Comment on the PR
gh pr comment "$STALE_PR" --repo "$REPO" --body "$MESSAGE"
echo " [DONE] Commented on PR #$STALE_PR."

# Close the PR
gh pr close "$STALE_PR" --repo "$REPO"
echo " [DONE] Closed PR #$STALE_PR."

# Unassign the user from the issue
gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER"
echo " [DONE] Unassigned @$USER from issue #$ISSUE."
echo
done

echo
done

echo "------------------------------------------------------------"
echo " Inactivity Unassign Bot (Phase 2) complete."
echo "------------------------------------------------------------"
12 changes: 10 additions & 2 deletions .github/workflows/bot-inactivity-unassign-phase1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
permissions:
contents: read
issues: write
pull-requests: read
pull-requests: write

jobs:
inactivity-unassign:
Expand All @@ -17,14 +17,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8

- name: Harden the runner
uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2
with:
egress-policy: audit

- name: Unassign inactive assignees with NO PRs (Phase 1)
- name: Phase 1 – unassign assignees with NO PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
DAYS: 21
run: bash .github/scripts/inactivity_unassign_phase1.sh

- name: Phase 2 – unassign assignees with stale PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
DAYS: 21
run: bash .github/scripts/inactivity_unassign_phase2.sh
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
## [Unreleased]

### Added

- Phase 2 of the inactivity-unassign bot:Automatically detects stale open pull requests (no commit activity for 21+ days), comments with a helpful InactivityBot message, closes the stale PR, and unassigns the contributor from the linked issue.
- Added **str**() to CustomFixedFee and updated examples and tests accordingly.
- Added a github template for good first issues
- Added `.github/workflows/bot-assignment-check.yml` to limit non-maintainers to 2 concurrent issue assignments.
Expand Down