22set -euo pipefail
33
44# Unified Inactivity Bot (Phase 1 + Phase 2)
5- # DRY_RUN:
6- # 1 → simulate only (no changes)
7- # 0 → real actions
5+ # DRY_RUN controls behaviour :
6+ # DRY_RUN = 1 → simulate only (no changes, just logs )
7+ # DRY_RUN = 0 → real actions (comments, closes, unassigns)
88
99REPO=" ${REPO:- ${GITHUB_REPOSITORY:- } } "
1010DAYS=" ${DAYS:- 21} "
2525
2626echo " ------------------------------------------------------------"
2727echo " Unified Inactivity Unassign Bot"
28- echo " Repo: $REPO "
29- echo " Threshold: $DAYS days"
30- echo " DRY_RUN: $DRY_RUN "
28+ echo " Repo: $REPO "
29+ echo " Threshold $DAYS days"
30+ echo " DRY_RUN: $DRY_RUN "
3131echo " ------------------------------------------------------------"
3232
3333NOW_TS=$( date +%s)
3434
35- # Convert GitHub timestamp → unix epoch
35+ # Convert GitHub ISO timestamp → epoch seconds
3636parse_ts () {
3737 local ts=" $1 "
3838 if date --version > /dev/null 2>&1 ; then
39- # GNU date
39+ # GNU date (Linux)
4040 date -d " $ts " +%s
4141 else
42- # macOS / BSD
42+ # BSD / macOS
4343 date -j -f " %Y-%m-%dT%H:%M:%SZ" " $ts " +" %s"
4444 fi
4545}
4646
47- # Fetch all open issues with assignees (non- PRs)
47+ # Fetch all open issues with assignees (no PRs)
4848ISSUES=$(
4949 gh api " repos/$REPO /issues" --paginate \
50- --jq ' .[]
51- | select(.state=="open" and (.assignees| length> 0) and (.pull_request| not))
50+ --jq ' .[]
51+ | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not))
5252 | .number'
5353)
5454
55- if [[ -z " $ISSUES " ]]; then
56- echo " [INFO] No open issues with assignees found."
57- fi
58-
5955for ISSUE in $ISSUES ; do
6056 echo " ============================================================"
6157 echo " ISSUE #$ISSUE "
6258 echo " ============================================================"
6359
6460 ISSUE_JSON=$( gh api " repos/$REPO /issues/$ISSUE " )
6561 ISSUE_CREATED_AT=$( echo " $ISSUE_JSON " | jq -r ' .created_at' )
66- ISSUE_CREATED_TS=$( parse_ts " $ISSUE_CREATED_AT " )
67-
6862 ASSIGNEES=$( echo " $ISSUE_JSON " | jq -r ' .assignees[].login' )
6963
7064 echo " [INFO] Issue created at: $ISSUE_CREATED_AT "
65+ echo
7166
72- # Fetch timeline once per issue (used for assignment + PR links)
73- TIMELINE=$( gh api \
74- -H " Accept: application/vnd.github.mockingbird-preview+json" \
75- " repos/$REPO /issues/$ISSUE /timeline"
67+ # Fetch timeline once (used for assignment events + PR links)
68+ TIMELINE=$(
69+ gh api \
70+ -H " Accept: application/vnd.github.mockingbird-preview+json" \
71+ " repos/$REPO /issues/$ISSUE /timeline"
7672 )
7773
7874 for USER in $ASSIGNEES ; do
79- echo
8075 echo " → Checking assignee: $USER "
8176
82- # -------------------------------
83- # Determine assignment time for USER
84- # -------------------------------
85- ASSIGNED_AT_STR=$(
86- echo " $TIMELINE " | jq -r --arg user " $USER " '
77+ # Determine assignment timestamp for this user
78+ ASSIGN_EVENT_JSON=$(
79+ echo " $TIMELINE " | jq -c --arg user " $USER " '
8780 [ .[]
88- | select(.event=="assigned" and .assignee.login==$user)
89- | .created_at
90- ]
91- | sort
92- | last // "null"
81+ | select(.event == "assigned")
82+ | select(.assignee.login == $user)
83+ ]
84+ | last // empty
9385 '
9486 )
9587
96- ASSIGNMENT_SOURCE=" assignment_event"
97-
98- if [[ -z " $ASSIGNED_AT_STR " || " $ASSIGNED_AT_STR " == " null" ]]; then
99- # Fallback: no explicit assignment event -> use issue creation time
100- ASSIGNED_AT_STR=" $ISSUE_CREATED_AT "
101- ASSIGNMENT_SOURCE=" issue_created_at (no explicit assignment event)"
88+ if [[ -n " $ASSIGN_EVENT_JSON " && " $ASSIGN_EVENT_JSON " != " null" ]]; then
89+ ASSIGNED_AT=$( echo " $ASSIGN_EVENT_JSON " | jq -r ' .created_at' )
90+ ASSIGN_SOURCE=" assignment_event"
91+ else
92+ # Fallback: use issue creation time when no explicit assignment event
93+ ASSIGNED_AT=" $ISSUE_CREATED_AT "
94+ ASSIGN_SOURCE=" issue_created_at (no explicit assignment event)"
10295 fi
10396
104- ASSIGNED_TS=$( parse_ts " $ASSIGNED_AT_STR " )
97+ ASSIGNED_TS=$( parse_ts " $ASSIGNED_AT " )
10598 ASSIGNED_AGE_DAYS=$(( (NOW_TS - ASSIGNED_TS) / 86400 ))
10699
107- echo " [INFO] Assignment source: $ASSIGNMENT_SOURCE "
108- echo " [INFO] Assigned at: $ASSIGNED_AT_STR (~${ASSIGNED_AGE_DAYS} days ago)"
100+ echo " [INFO] Assignment source: $ASSIGN_SOURCE "
101+ echo " [INFO] Assigned at: $ASSIGNED_AT (~${ASSIGNED_AGE_DAYS} days ago)"
109102
110- # -------------------------------
111- # Find linked PRs for THIS user in THIS repo
112- # -------------------------------
103+ # Determine PRs linked to this issue for this user
113104 PR_NUMBERS=$(
114105 echo " $TIMELINE " | jq -r --arg repo " $REPO " --arg user " $USER " '
115106 .[]
@@ -121,40 +112,50 @@ for ISSUE in $ISSUES; do
121112 '
122113 )
123114
115+ # ===========================
116+ # PHASE 1: ISSUE HAS NO PR(s)
117+ # ===========================
124118 if [[ -z " $PR_NUMBERS " ]]; then
125119 echo " [INFO] Linked PRs: none"
126- else
127- echo " [INFO] Linked PRs: $PR_NUMBERS "
128- fi
129120
130- # ============================================================
131- # PHASE 1: ISSUE HAS NO PR FOR THIS USER
132- # ============================================================
133- if [[ -z " $PR_NUMBERS " ]]; then
134121 if (( ASSIGNED_AGE_DAYS >= DAYS )) ; then
135- echo " [RESULT] Phase 1 → no PR linked + stale (>= $DAYS days)"
122+ echo " [RESULT] Phase 1 → stale assignment (>= $DAYS days, no PR )"
136123
137124 if (( DRY_RUN == 0 )) ; then
125+ MESSAGE=$(
126+ cat << EOF
127+ Hi @$USER , this is InactivityBot 👋
128+
129+ You were assigned to this issue **${ASSIGNED_AGE_DAYS} days** ago, and there is currently no open pull request linked to it.
130+ To keep the backlog available for active contributors, I'm unassigning you for now.
131+
132+ If you'd like to continue working on this later, feel free to get re-assigned or comment here and we'll gladly assign it back to you. 🙂
133+ EOF
134+ )
135+
136+ gh issue comment " $ISSUE " --repo " $REPO " --body " $MESSAGE "
138137 gh issue edit " $ISSUE " --repo " $REPO " --remove-assignee " $USER "
139- echo " [ACTION] Unassigned @$USER from issue #$ISSUE "
138+ echo " [ACTION] Commented and unassigned @$USER from issue #$ISSUE "
140139 else
141- echo " [DRY RUN] Would unassign @$USER from issue #$ISSUE "
140+ echo " [DRY RUN] Would comment + unassign @$USER from issue #$ISSUE (Phase 1 stale) "
142141 fi
143142 else
144143 echo " [RESULT] Phase 1 → no PR linked but not stale (< $DAYS days) → KEEP"
145144 fi
146145
147- # No PRs means no Phase 2 work required for this user
146+ echo
148147 continue
149148 fi
150149
151- # ============================================================
152- # PHASE 2: ISSUE HAS PR(s) → check last commit activity
153- # ============================================================
154- PHASE2_TOOK_ACTION=0
150+ # ===========================
151+ # PHASE 2: ISSUE HAS PR(s)
152+ # ===========================
153+ echo " [INFO] Linked PRs: $PR_NUMBERS "
154+
155+ PHASE2_TOUCHED=0
155156
156157 for PR_NUM in $PR_NUMBERS ; do
157- # Ensure PR exists in this repo
158+ # Safe PR existence check
158159 if ! PR_STATE=$( gh pr view " $PR_NUM " --repo " $REPO " --json state --jq ' .state' 2> /dev/null) ; then
159160 echo " [SKIP] #$PR_NUM is not a valid PR in $REPO "
160161 continue
@@ -167,40 +168,48 @@ for ISSUE in $ISSUES; do
167168 continue
168169 fi
169170
170- # Fetch all commits & take the last one (API order + paginate)
171- COMMITS=$( gh api " repos/$REPO /pulls/$PR_NUM /commits" --paginate)
172- LAST_TS_STR=$( echo " $COMMITS " | jq -r ' last | (.commit.committer.date // .commit.author.date)' )
171+ COMMITS_JSON=$( gh api " repos/$REPO /pulls/$PR_NUM /commits" --paginate)
172+ LAST_TS_STR=$( echo " $COMMITS_JSON " | jq -r ' last | (.commit.committer.date // .commit.author.date)' )
173173 LAST_TS=$( parse_ts " $LAST_TS_STR " )
174174 PR_AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 ))
175175
176176 echo " [INFO] PR #$PR_NUM last commit: $LAST_TS_STR (~${PR_AGE_DAYS} days ago)"
177177
178178 if (( PR_AGE_DAYS >= DAYS )) ; then
179+ PHASE2_TOUCHED=1
179180 echo " [RESULT] Phase 2 → PR #$PR_NUM is stale (>= $DAYS days since last commit)"
180- PHASE2_TOOK_ACTION=1
181181
182182 if (( DRY_RUN == 0 )) ; then
183+ MESSAGE=$(
184+ cat << EOF
185+ Hi @$USER , this is InactivityBot 👋
186+
187+ This pull request has had no new commits for **${PR_AGE_DAYS} days**, so I'm closing it and unassigning you from the linked issue to keep the backlog healthy.
188+
189+ You're very welcome to open a new PR or ask to be re-assigned when you're ready to continue working on this. 🚀
190+ EOF
191+ )
192+
193+ gh pr comment " $PR_NUM " --repo " $REPO " --body " $MESSAGE "
183194 gh pr close " $PR_NUM " --repo " $REPO "
184195 gh issue edit " $ISSUE " --repo " $REPO " --remove-assignee " $USER "
185- echo " [ACTION] Closed PR #$PR_NUM and unassigned @$USER from issue #$ISSUE "
196+
197+ echo " [ACTION] Commented on PR #$PR_NUM , closed it, and unassigned @$USER from issue #$ISSUE "
186198 else
187- echo " [DRY RUN] Would close PR #$PR_NUM and unassign @$USER from issue #$ISSUE "
199+ echo " [DRY RUN] Would comment, close PR #$PR_NUM , and unassign @$USER from issue #$ISSUE "
188200 fi
189-
190- # Per current spec, first stale PR per user/issue is enough
191- break
192201 else
193202 echo " [INFO] PR #$PR_NUM is active (< $DAYS days) → KEEP"
194203 fi
195204 done
196205
197- if (( PHASE 2 _TOOK_ACTION == 0 )) ; then
206+ if (( PHASE 2 _TOUCHED == 0 )) ; then
198207 echo " [RESULT] Phase 2 → all linked PRs active or not applicable → KEEP"
199208 fi
200209
210+ echo
201211 done
202212
203- echo
204213done
205214
206215echo " ------------------------------------------------------------"
0 commit comments