Skip to content

Commit 6140d2a

Browse files
authored
feat(github-actions): add inactivity unassign bot phase 1 (#956)
Signed-off-by: Akshat Kumar <akshat230405@gmail.com>
1 parent acf4d26 commit 6140d2a

File tree

4 files changed

+337
-0
lines changed

4 files changed

+337
-0
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# ===============================================================
5+
# DRY-RUN: Phase 1 Inactivity Unassign Bot
6+
# - Uses assignment timestamp
7+
# - Only considers OPEN PRs linked to the issue
8+
# - Logs everything without making changes
9+
# - Provides summary of users who would be unassigned
10+
# ===============================================================
11+
12+
REPO="${REPO:-}"
13+
DAYS="${DAYS:-21}"
14+
15+
if [ -z "$REPO" ]; then
16+
echo "ERROR: REPO environment variable not set. Example: export REPO=owner/repo"
17+
exit 1
18+
fi
19+
20+
echo "------------------------------------------------------------"
21+
echo " DRY RUN: Phase 1 Inactivity Unassign Check"
22+
echo " Repo: $REPO"
23+
echo " Threshold: $DAYS days"
24+
echo "------------------------------------------------------------"
25+
echo
26+
27+
NOW_TS=$(date +%s)
28+
29+
# -----------------------------
30+
# Cross-platform timestamp parsing
31+
# -----------------------------
32+
parse_timestamp() {
33+
local ts="$1"
34+
if date --version >/dev/null 2>&1; then
35+
date -d "$ts" +%s # GNU date
36+
else
37+
date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" # macOS/BSD
38+
fi
39+
}
40+
41+
# -----------------------------
42+
# Array to store summary of users to message
43+
# -----------------------------
44+
declare -a TO_UNASSIGN=()
45+
46+
# -----------------------------
47+
# Fetch all open issues with assignees (skip PRs)
48+
# -----------------------------
49+
ISSUES=$(gh api "repos/$REPO/issues" \
50+
--paginate \
51+
--jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number')
52+
53+
if [ -z "$ISSUES" ]; then
54+
echo "No open issues with assignees found."
55+
exit 0
56+
fi
57+
58+
echo "[INFO] Found issues: $ISSUES"
59+
echo
60+
61+
# -----------------------------
62+
# Iterate over issues
63+
# -----------------------------
64+
for ISSUE in $ISSUES; do
65+
echo "============================================================"
66+
echo " ISSUE #$ISSUE"
67+
echo "============================================================"
68+
69+
ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE")
70+
ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login')
71+
72+
if [ -z "$ASSIGNEES" ]; then
73+
echo "[INFO] No assignees? Skipping."
74+
echo
75+
continue
76+
fi
77+
78+
echo "[INFO] Assignees: $ASSIGNEES"
79+
echo
80+
81+
# -----------------------------
82+
# Iterate over assignees
83+
# -----------------------------
84+
for USER in $ASSIGNEES; do
85+
echo " → Checking assignee: $USER"
86+
87+
# -----------------------------
88+
# Find the assignment timestamp for this user
89+
# -----------------------------
90+
ASSIGN_TS=$(gh api "repos/$REPO/issues/$ISSUE/events" \
91+
--jq ".[] | select(.event==\"assigned\" and .assignee.login==\"$USER\") | .created_at" | tail -n1)
92+
93+
if [ -z "$ASSIGN_TS" ]; then
94+
echo " [WARN] Could not find assignment timestamp. Using issue creation date as fallback."
95+
ASSIGN_TS=$(echo "$ISSUE_JSON" | jq -r '.created_at')
96+
fi
97+
98+
ASSIGN_TS_SEC=$(parse_timestamp "$ASSIGN_TS")
99+
DIFF_DAYS=$(( (NOW_TS - ASSIGN_TS_SEC) / 86400 ))
100+
101+
echo " [INFO] Assigned at: $ASSIGN_TS"
102+
echo " [INFO] Assigned for: $DIFF_DAYS days"
103+
104+
# -----------------------------
105+
# Check if user has an OPEN PR linked to this issue
106+
# -----------------------------
107+
PR_NUMBERS=$(gh api \
108+
-H "Accept: application/vnd.github.mockingbird-preview+json" \
109+
"repos/$REPO/issues/$ISSUE/timeline" \
110+
--jq ".[]
111+
| select(.event == \"cross-referenced\")
112+
| select(.source.issue.pull_request != null)
113+
| select(.source.issue.user.login == \"$USER\")
114+
| .source.issue.number")
115+
116+
OPEN_PR_FOUND=""
117+
for PR_NUM in $PR_NUMBERS; do
118+
PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state')
119+
if [ "$PR_STATE" = "OPEN" ]; then
120+
OPEN_PR_FOUND="$PR_NUM"
121+
break
122+
fi
123+
done
124+
125+
if [ -n "$OPEN_PR_FOUND" ]; then
126+
echo " [KEEP] User $USER has an OPEN PR linked to this issue: $OPEN_PR_FOUND → skip unassign."
127+
echo
128+
continue
129+
fi
130+
131+
echo " [RESULT] User $USER has NO OPEN PRs linked to this issue."
132+
133+
# -----------------------------
134+
# Decide on DRY-RUN unassign
135+
# -----------------------------
136+
if [ "$DIFF_DAYS" -ge "$DAYS" ]; then
137+
UNASSIGN_MESSAGE="Hi @$USER, you have been assigned to this issue for $DIFF_DAYS days without any open PRs linked. You would be automatically unassigned to keep things tidy. Please re-assign yourself if you are still working on this."
138+
139+
echo " [DRY RUN] Would UNASSIGN $USER (assigned for $DIFF_DAYS days, threshold $DAYS)"
140+
echo " [DRY RUN] Message that would be posted:"
141+
echo " --------------------------------------------------"
142+
echo " $UNASSIGN_MESSAGE"
143+
echo " --------------------------------------------------"
144+
145+
TO_UNASSIGN+=("Issue #$ISSUE$USER (assigned $DIFF_DAYS days) → Message: $UNASSIGN_MESSAGE")
146+
else
147+
echo " [KEEP] Only $DIFF_DAYS days old → NOT stale yet."
148+
fi
149+
150+
151+
echo
152+
done
153+
154+
echo
155+
done
156+
157+
# -----------------------------
158+
# Summary of all users to message
159+
# -----------------------------
160+
if [ ${#TO_UNASSIGN[@]} -gt 0 ]; then
161+
echo "============================================================"
162+
echo " SUMMARY: Users who would be unassigned / messaged"
163+
echo "============================================================"
164+
for ITEM in "${TO_UNASSIGN[@]}"; do
165+
echo " - $ITEM"
166+
done
167+
else
168+
echo "No users would be unassigned in this dry-run."
169+
fi
170+
171+
echo "------------------------------------------------------------"
172+
echo " DRY RUN COMPLETE — No changes were made."
173+
echo "------------------------------------------------------------"
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Env:
5+
# GH_TOKEN - provided by GitHub Actions
6+
# REPO - owner/repo (fallback to GITHUB_REPOSITORY)
7+
# DAYS - inactivity threshold in days (default 21)
8+
9+
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
10+
DAYS="${DAYS:-21}"
11+
12+
if [ -z "$REPO" ]; then
13+
echo "ERROR: REPO environment variable not set."
14+
exit 1
15+
fi
16+
17+
echo "------------------------------------------------------------"
18+
echo " Inactivity Unassign Bot (Phase 1)"
19+
echo " Repo: $REPO"
20+
echo " Threshold: $DAYS days"
21+
echo "------------------------------------------------------------"
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+
date -d "$ts" +%s # GNU date (Linux)
31+
else
32+
date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" # macOS/BSD
33+
fi
34+
}
35+
36+
# Fetch open ISSUES (not PRs) that have assignees
37+
ISSUES=$(gh api "repos/$REPO/issues" \
38+
--paginate \
39+
--jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number')
40+
41+
if [ -z "$ISSUES" ]; then
42+
echo "No open issues with assignees found."
43+
exit 0
44+
fi
45+
46+
for ISSUE in $ISSUES; do
47+
echo "============================================================"
48+
echo " ISSUE #$ISSUE"
49+
echo "============================================================"
50+
51+
ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE")
52+
ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login')
53+
54+
if [ -z "$ASSIGNEES" ]; then
55+
echo "[INFO] No assignees? Skipping."
56+
echo
57+
continue
58+
fi
59+
60+
echo "[INFO] Assignees: $ASSIGNEES"
61+
echo
62+
63+
for USER in $ASSIGNEES; do
64+
echo " → Checking assignee: $USER"
65+
66+
# 1) Find when THIS USER was last assigned to THIS ISSUE
67+
ASSIGN_TS=$(gh api "repos/$REPO/issues/$ISSUE/events" \
68+
--jq ".[] | select(.event==\"assigned\" and .assignee.login==\"$USER\") | .created_at" \
69+
| tail -n1)
70+
71+
if [ -z "$ASSIGN_TS" ]; then
72+
echo " [WARN] No assignment event for $USER, falling back to issue creation."
73+
ASSIGN_TS=$(echo "$ISSUE_JSON" | jq -r '.created_at')
74+
fi
75+
76+
ASSIGN_TS_SEC=$(parse_ts "$ASSIGN_TS")
77+
DIFF_DAYS=$(( (NOW_TS - ASSIGN_TS_SEC) / 86400 ))
78+
79+
echo " [INFO] Assigned at: $ASSIGN_TS"
80+
echo " [INFO] Assigned for: $DIFF_DAYS days"
81+
82+
# 2) Check for OPEN PRs linked to THIS ISSUE by THIS USER (via timeline)
83+
PR_NUMBERS=$(gh api \
84+
-H "Accept: application/vnd.github.mockingbird-preview+json" \
85+
"repos/$REPO/issues/$ISSUE/timeline" \
86+
--jq ".[]
87+
| select(.event == \"cross-referenced\")
88+
| select(.source.issue.pull_request != null)
89+
| select(.source.issue.user.login == \"$USER\")
90+
| .source.issue.number")
91+
92+
OPEN_PR_FOUND=""
93+
for PR_NUM in $PR_NUMBERS; do
94+
PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state')
95+
if [ "$PR_STATE" = "OPEN" ]; then
96+
OPEN_PR_FOUND="$PR_NUM"
97+
break
98+
fi
99+
done
100+
101+
if [ -n "$OPEN_PR_FOUND" ]; then
102+
echo " [KEEP] $USER has OPEN PR #$OPEN_PR_FOUND linked to this issue → skip unassign."
103+
echo
104+
continue
105+
fi
106+
107+
echo " [RESULT] $USER has NO OPEN PRs linked to this issue."
108+
109+
# 3) Decide unassign
110+
if [ "$DIFF_DAYS" -lt "$DAYS" ]; then
111+
echo " [KEEP] Only $DIFF_DAYS days (< $DAYS) → not stale yet."
112+
echo
113+
continue
114+
fi
115+
116+
echo " [UNASSIGN] $USER (assigned $DIFF_DAYS days, threshold $DAYS)"
117+
118+
# Unassign via gh CLI helper
119+
gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER"
120+
121+
# Comment
122+
MESSAGE="Hi @$USER, you were automatically unassigned from this issue because there have been no open PRs linked to it for **$DIFF_DAYS days**. This helps keep issues available for contributors who are currently active. You're very welcome to re-assign yourself or pick this back up whenever you have time 🚀"
123+
124+
gh issue comment "$ISSUE" --repo "$REPO" --body "$MESSAGE"
125+
126+
echo " [DONE] Unassigned and commented on issue #$ISSUE for $USER."
127+
echo
128+
done
129+
130+
echo
131+
done
132+
133+
echo "------------------------------------------------------------"
134+
echo " Inactivity Unassign Bot (Phase 1) complete."
135+
echo "------------------------------------------------------------"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: bot-inactivity-unassign-phase1
2+
3+
on:
4+
schedule:
5+
- cron: "0 10 * * *"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
issues: write
11+
pull-requests: read
12+
13+
jobs:
14+
inactivity-unassign:
15+
runs-on: ubuntu-latest
16+
17+
steps:
18+
- name: Harden the runner
19+
uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2
20+
with:
21+
egress-policy: audit
22+
23+
- name: Unassign inactive assignees with NO PRs (Phase 1)
24+
env:
25+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26+
REPO: ${{ github.repository }}
27+
DAYS: 21
28+
run: bash .github/scripts/inactivity_unassign_phase1.sh

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
99
### Added
1010
- Add examples/tokens/token_create_transaction_pause_key.py example demonstrating token pause/unpause behavior and pause key usage (#833)
1111
- Added `docs/sdk_developers/training/transaction_lifecycle.md` to explain the typical lifecycle of executing a transaction using the Hedera Python SDK.
12+
- Add inactivity bot workflow to unassign stale issue assignees (#952)
1213
### Changed
1314
-
1415

0 commit comments

Comments
 (0)