Skip to content

Commit 545ee25

Browse files
Akshat8510adityashirsatrao007
authored andcommitted
feat(github-actions): add inactivity bot phase 2 (stale PR detection) (hiero-ledger#989)
Signed-off-by: Akshat Kumar <akshat230405@gmail.com> Signed-off-by: Akshat8510 <akshat230405@gmail.com>
1 parent e2b7510 commit 545ee25

File tree

4 files changed

+320
-3
lines changed

4 files changed

+320
-3
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# DRY-RUN: Phase 2 Inactivity Unassign Bot
5+
# - Does NOT change anything
6+
# - Logs which PRs/issues WOULD be affected
7+
8+
REPO="${REPO:-}"
9+
DAYS="${DAYS:-21}"
10+
11+
if [ -z "$REPO" ]; then
12+
echo "ERROR: REPO environment variable not set."
13+
echo "Example: export REPO=owner/repo"
14+
exit 1
15+
fi
16+
17+
echo "------------------------------------------------------------"
18+
echo " DRY RUN: Phase 2 Inactivity Unassign (PR inactivity)"
19+
echo " Repo: $REPO"
20+
echo " Threshold: $DAYS days (no commit activity on PR)"
21+
echo "------------------------------------------------------------"
22+
23+
NOW_TS=$(date +%s)
24+
25+
parse_ts() {
26+
local ts="$1"
27+
if date --version >/dev/null 2>&1; then
28+
date -d "$ts" +%s
29+
else
30+
date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s"
31+
fi
32+
}
33+
34+
declare -a SUMMARY=()
35+
36+
ISSUES=$(gh api "repos/$REPO/issues" \
37+
--paginate \
38+
--jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number')
39+
40+
if [ -z "$ISSUES" ]; then
41+
echo "No open issues with assignees found."
42+
exit 0
43+
fi
44+
45+
echo "[INFO] Found issues: $ISSUES"
46+
echo
47+
48+
for ISSUE in $ISSUES; do
49+
echo "============================================================"
50+
echo " ISSUE #$ISSUE"
51+
echo "============================================================"
52+
53+
ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE")
54+
ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login')
55+
56+
if [ -z "$ASSIGNEES" ]; then
57+
echo "[INFO] No assignees? Skipping."
58+
echo
59+
continue
60+
fi
61+
62+
echo "[INFO] Assignees: $ASSIGNEES"
63+
echo
64+
65+
for USER in $ASSIGNEES; do
66+
echo " → Checking assignee: $USER"
67+
68+
PR_NUMBERS=$(gh api \
69+
-H "Accept: application/vnd.github.mockingbird-preview+json" \
70+
"repos/$REPO/issues/$ISSUE/timeline" \
71+
--jq ".[]
72+
| select(.event == \"cross-referenced\")
73+
| select(.source.issue.pull_request != null)
74+
| select(.source.issue.user.login == \"$USER\")
75+
| .source.issue.number")
76+
77+
if [ -z "$PR_NUMBERS" ]; then
78+
echo " [INFO] No linked PRs by $USER for this issue."
79+
echo
80+
continue
81+
fi
82+
83+
echo " [INFO] Linked PRs by $USER: $PR_NUMBERS"
84+
85+
STALE_PR=""
86+
STALE_AGE_DAYS=0
87+
88+
for PR_NUM in $PR_NUMBERS; do
89+
PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state')
90+
91+
if [ "$PR_STATE" != "OPEN" ]; then
92+
echo " [SKIP] PR #$PR_NUM is not open ($PR_STATE)."
93+
continue
94+
fi
95+
96+
# Use the same "last commit" logic as the real bot (with pagination)
97+
COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate 2>/dev/null || echo "")
98+
99+
LAST_COMMIT_DATE=$(echo "$COMMITS_JSON" \
100+
| jq -r 'last | (.commit.committer.date // .commit.author.date)' 2>/dev/null || echo "")
101+
102+
if [ -z "$LAST_COMMIT_DATE" ] || [ "$LAST_COMMIT_DATE" = "null" ]; then
103+
echo " [WARN] Could not determine last commit date for PR #$PR_NUM, skipping."
104+
continue
105+
fi
106+
107+
LAST_COMMIT_TS=$(parse_ts "$LAST_COMMIT_DATE")
108+
AGE_DAYS=$(( (NOW_TS - LAST_COMMIT_TS) / 86400 ))
109+
110+
echo " [INFO] PR #$PR_NUM last commit: $LAST_COMMIT_DATE (~${AGE_DAYS} days ago)"
111+
112+
if [ "$AGE_DAYS" -ge "$DAYS" ]; then
113+
STALE_PR="$PR_NUM"
114+
STALE_AGE_DAYS="$AGE_DAYS"
115+
break
116+
fi
117+
done
118+
119+
if [ -z "$STALE_PR" ]; then
120+
echo " [KEEP] No OPEN PR for $USER is stale (>= $DAYS days)."
121+
echo
122+
continue
123+
fi
124+
125+
echo " [DRY RUN] Would CLOSE PR #$STALE_PR (no commits for $STALE_AGE_DAYS days)"
126+
echo " [DRY RUN] Would UNASSIGN @$USER from issue #$ISSUE"
127+
echo
128+
129+
SUMMARY+=("Issue #$ISSUE → user @$USER → stale PR #$STALE_PR (no commits for $STALE_AGE_DAYS days)")
130+
done
131+
132+
echo
133+
done
134+
135+
if [ ${#SUMMARY[@]} -gt 0 ]; then
136+
echo "============================================================"
137+
echo " SUMMARY: Actions that WOULD be taken (no changes made)"
138+
echo "============================================================"
139+
for ITEM in "${SUMMARY[@]}"; do
140+
echo " - $ITEM"
141+
done
142+
else
143+
echo "No stale PRs / unassignments detected in this dry-run."
144+
fi
145+
146+
echo "------------------------------------------------------------"
147+
echo " DRY RUN COMPLETE — No changes were made."
148+
echo "------------------------------------------------------------"
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Inactivity Unassign Bot (Phase 2 - PR inactivity)
5+
# Env:
6+
# GH_TOKEN - provided by GitHub Actions
7+
# REPO - owner/repo (fallback to GITHUB_REPOSITORY)
8+
# DAYS - inactivity threshold in days (default 21)
9+
10+
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
11+
DAYS="${DAYS:-21}"
12+
13+
if [ -z "$REPO" ]; then
14+
echo "ERROR: REPO environment variable not set."
15+
exit 1
16+
fi
17+
18+
echo "------------------------------------------------------------"
19+
echo " Inactivity Unassign Bot (Phase 2 - PR inactivity)"
20+
echo " Repo: $REPO"
21+
echo " Threshold: $DAYS days (no commit activity on PR)"
22+
echo "------------------------------------------------------------"
23+
24+
NOW_TS=$(date +%s)
25+
26+
# Cross-platform timestamp parsing (Linux + macOS/BSD)
27+
parse_ts() {
28+
local ts="$1"
29+
if date --version >/dev/null 2>&1; then
30+
# GNU date (Linux)
31+
date -d "$ts" +%s
32+
else
33+
# macOS / BSD
34+
date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s"
35+
fi
36+
}
37+
38+
# Fetch open ISSUES (not PRs) that have assignees
39+
ISSUES=$(gh api "repos/$REPO/issues" \
40+
--paginate \
41+
--jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number')
42+
43+
if [ -z "$ISSUES" ]; then
44+
echo "No open issues with assignees found."
45+
exit 0
46+
fi
47+
48+
for ISSUE in $ISSUES; do
49+
echo "============================================================"
50+
echo " ISSUE #$ISSUE"
51+
echo "============================================================"
52+
53+
ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE")
54+
ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login')
55+
56+
if [ -z "$ASSIGNEES" ]; then
57+
echo "[INFO] No assignees? Skipping."
58+
echo
59+
continue
60+
fi
61+
62+
echo "[INFO] Assignees: $ASSIGNEES"
63+
echo
64+
65+
for USER in $ASSIGNEES; do
66+
echo " → Checking assignee: $USER"
67+
68+
# Find OPEN PRs linked to THIS issue, authored by THIS user
69+
PR_NUMBERS=$(gh api \
70+
-H "Accept: application/vnd.github.mockingbird-preview+json" \
71+
"repos/$REPO/issues/$ISSUE/timeline" \
72+
--jq ".[]
73+
| select(.event == \"cross-referenced\")
74+
| select(.source.issue.pull_request != null)
75+
| select(.source.issue.user.login == \"$USER\")
76+
| .source.issue.number")
77+
78+
if [ -z "$PR_NUMBERS" ]; then
79+
echo " [INFO] No linked PRs by $USER for this issue → Phase 1 covers the no-PR case."
80+
echo
81+
continue
82+
fi
83+
84+
echo " [INFO] Linked PRs by $USER: $PR_NUMBERS"
85+
86+
STALE_PR=""
87+
STALE_AGE_DAYS=0
88+
89+
# Look for a stale OPEN PR
90+
for PR_NUM in $PR_NUMBERS; do
91+
PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state')
92+
93+
if [ "$PR_STATE" != "OPEN" ]; then
94+
echo " [SKIP] PR #$PR_NUM is not open ($PR_STATE)."
95+
continue
96+
fi
97+
98+
# Last commit date on the PR (use API order + paginate, take last)
99+
COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate 2>/dev/null || echo "")
100+
101+
LAST_COMMIT_DATE=$(echo "$COMMITS_JSON" \
102+
| jq -r 'last | (.commit.committer.date // .commit.author.date)' 2>/dev/null || echo "")
103+
104+
if [ -z "$LAST_COMMIT_DATE" ] || [ "$LAST_COMMIT_DATE" = "null" ]; then
105+
echo " [WARN] Could not determine last commit date for PR #$PR_NUM, skipping."
106+
continue
107+
fi
108+
109+
LAST_COMMIT_TS=$(parse_ts "$LAST_COMMIT_DATE")
110+
AGE_DAYS=$(( (NOW_TS - LAST_COMMIT_TS) / 86400 ))
111+
112+
echo " [INFO] PR #$PR_NUM last commit: $LAST_COMMIT_DATE (~${AGE_DAYS} days ago)"
113+
114+
if [ "$AGE_DAYS" -ge "$DAYS" ]; then
115+
STALE_PR="$PR_NUM"
116+
STALE_AGE_DAYS="$AGE_DAYS"
117+
break
118+
fi
119+
done
120+
121+
if [ -z "$STALE_PR" ]; then
122+
echo " [KEEP] No OPEN PR for $USER is stale (>= $DAYS days)."
123+
echo
124+
continue
125+
fi
126+
127+
echo " [STALE] PR #$STALE_PR by $USER has had no commit activity for $STALE_AGE_DAYS days (>= $DAYS)."
128+
echo " [ACTION] Closing PR #$STALE_PR and unassigning @$USER from issue #$ISSUE."
129+
130+
MESSAGE=$(
131+
cat <<EOF
132+
Hi @$USER, this is InactivityBot.
133+
134+
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.
135+
136+
You are welcome to get assigned to an issue once again once you have capacity.
137+
138+
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.
139+
EOF
140+
)
141+
142+
# Comment on the PR
143+
gh pr comment "$STALE_PR" --repo "$REPO" --body "$MESSAGE"
144+
echo " [DONE] Commented on PR #$STALE_PR."
145+
146+
# Close the PR
147+
gh pr close "$STALE_PR" --repo "$REPO"
148+
echo " [DONE] Closed PR #$STALE_PR."
149+
150+
# Unassign the user from the issue
151+
gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER"
152+
echo " [DONE] Unassigned @$USER from issue #$ISSUE."
153+
echo
154+
done
155+
156+
echo
157+
done
158+
159+
echo "------------------------------------------------------------"
160+
echo " Inactivity Unassign Bot (Phase 2) complete."
161+
echo "------------------------------------------------------------"

.github/workflows/bot-inactivity-unassign-phase1.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
permissions:
99
contents: read
1010
issues: write
11-
pull-requests: read
11+
pull-requests: write
1212

1313
jobs:
1414
inactivity-unassign:
@@ -17,14 +17,22 @@ jobs:
1717
steps:
1818
- name: Checkout repository
1919
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
20+
2021
- name: Harden the runner
2122
uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2
2223
with:
2324
egress-policy: audit
2425

25-
- name: Unassign inactive assignees with NO PRs (Phase 1)
26+
- name: Phase 1 – unassign assignees with NO PRs
2627
env:
2728
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2829
REPO: ${{ github.repository }}
2930
DAYS: 21
3031
run: bash .github/scripts/inactivity_unassign_phase1.sh
32+
33+
- name: Phase 2 – unassign assignees with stale PRs
34+
env:
35+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
REPO: ${{ github.repository }}
37+
DAYS: 21
38+
run: bash .github/scripts/inactivity_unassign_phase2.sh

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
77
## [Unreleased]
88

99
### Added
10-
10+
- 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.
1111
- Added **str**() to CustomFixedFee and updated examples and tests accordingly.
1212
- Added a github template for good first issues
1313
- Added `.github/workflows/bot-assignment-check.yml` to limit non-maintainers to 2 concurrent issue assignments.

0 commit comments

Comments
 (0)