22set -euo pipefail
33
44# Unified Inactivity Bot (Phase 1 + Phase 2)
5- # Supports DRY_RUN mode :
6- # DRY_RUN = 1 → simulate only (no changes)
7- # DRY_RUN = 0 → real actions
5+ # DRY_RUN:
6+ # 1 → simulate only (no changes)
7+ # 0 → real actions
88
99REPO=" ${REPO:- ${GITHUB_REPOSITORY:- } } "
1010DAYS=" ${DAYS:- 21} "
1111DRY_RUN=" ${DRY_RUN:- 0} "
1212
13- # Normalize DRY_RUN input ("true"/"false" → 1/0)
13+ # Normalise DRY_RUN input ("true"/"false" → 1/0, case-insensitive )
1414shopt -s nocasematch
1515case " $DRY_RUN " in
1616 " true" ) DRY_RUN=1 ;;
@@ -32,120 +32,175 @@ echo "------------------------------------------------------------"
3232
3333NOW_TS=$( date +%s)
3434
35- # Converts GitHub timestamps → epoch
35+ # Convert GitHub timestamp → unix epoch
3636parse_ts () {
3737 local ts=" $1 "
3838 if date --version > /dev/null 2>&1 ; then
39+ # GNU date
3940 date -d " $ts " +%s
4041 else
42+ # macOS / BSD
4143 date -j -f " %Y-%m-%dT%H:%M:%SZ" " $ts " +" %s"
4244 fi
4345}
4446
45- # Fetch all open issues that have assignees
47+ # Fetch all open issues with assignees (non-PRs)
4648ISSUES=$(
4749 gh api " repos/$REPO /issues" --paginate \
4850 --jq ' .[]
4951 | select(.state=="open" and (.assignees|length>0) and (.pull_request|not))
5052 | .number'
5153)
5254
55+ if [[ -z " $ISSUES " ]]; then
56+ echo " [INFO] No open issues with assignees found."
57+ fi
58+
5359for ISSUE in $ISSUES ; do
5460 echo " ============================================================"
5561 echo " ISSUE #$ISSUE "
5662 echo " ============================================================"
5763
5864 ISSUE_JSON=$( gh api " repos/$REPO /issues/$ISSUE " )
65+ ISSUE_CREATED_AT=$( echo " $ISSUE_JSON " | jq -r ' .created_at' )
66+ ISSUE_CREATED_TS=$( parse_ts " $ISSUE_CREATED_AT " )
67+
5968 ASSIGNEES=$( echo " $ISSUE_JSON " | jq -r ' .assignees[].login' )
60- CREATED_AT=$( echo " $ISSUE_JSON " | jq -r ' .created_at' )
61- CREATED_TS=$( parse_ts " $CREATED_AT " )
69+
70+ echo " [INFO] Issue created at: $ISSUE_CREATED_AT "
71+
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"
76+ )
6277
6378 for USER in $ASSIGNEES ; do
79+ echo
6480 echo " → Checking assignee: $USER "
6581
66- # Fetch timeline (for PR cross-references)
67- TIMELINE=$( gh api \
68- -H " Accept: application/vnd.github.mockingbird-preview+json" \
69- " repos/$REPO /issues/$ISSUE /timeline"
82+ # -------------------------------
83+ # Determine assignment time for USER
84+ # -------------------------------
85+ ASSIGNED_AT_STR=$(
86+ echo " $TIMELINE " | jq -r --arg user " $USER " '
87+ [ .[]
88+ | select(.event=="assigned" and .assignee.login==$user)
89+ | .created_at
90+ ]
91+ | sort
92+ | last // "null"
93+ '
7094 )
7195
72- # Filter only PRs from SAME repository
73- PR_NUMBERS=$( echo " $TIMELINE " | jq -r --arg repo " $REPO " '
74- .[]
75- | select(.event == "cross-referenced")
76- | select(.source.issue.pull_request != null)
77- | select(.source.issue.repository.full_name == $repo)
78- | .source.issue.number
79- ' )
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)"
102+ fi
103+
104+ ASSIGNED_TS=$( parse_ts " $ASSIGNED_AT_STR " )
105+ ASSIGNED_AGE_DAYS=$(( (NOW_TS - ASSIGNED_TS) / 86400 ))
106+
107+ echo " [INFO] Assignment source: $ASSIGNMENT_SOURCE "
108+ echo " [INFO] Assigned at: $ASSIGNED_AT_STR (~${ASSIGNED_AGE_DAYS} days ago)"
80109
81110 # -------------------------------
82- # PHASE 1: ISSUE HAS NO PR
111+ # Find linked PRs for THIS user in THIS repo
83112 # -------------------------------
113+ PR_NUMBERS=$(
114+ echo " $TIMELINE " | jq -r --arg repo " $REPO " --arg user " $USER " '
115+ .[]
116+ | select(.event == "cross-referenced")
117+ | select(.source.issue.pull_request != null)
118+ | select(.source.issue.repository.full_name == $repo)
119+ | select(.source.issue.user.login == $user)
120+ | .source.issue.number
121+ '
122+ )
123+
84124 if [[ -z " $PR_NUMBERS " ]]; then
85- AGE_DAYS=$(( (NOW_TS - CREATED_TS) / 86400 ))
86- echo " [INFO] Assigned for: ${AGE_DAYS} days"
125+ echo " [INFO] Linked PRs: none"
126+ else
127+ echo " [INFO] Linked PRs: $PR_NUMBERS "
128+ fi
87129
88- if (( AGE_DAYS >= DAYS )) ; then
89- echo " [PHASE 1 STALE] No PR linked + stale"
130+ # ============================================================
131+ # PHASE 1: ISSUE HAS NO PR FOR THIS USER
132+ # ============================================================
133+ if [[ -z " $PR_NUMBERS " ]]; then
134+ if (( ASSIGNED_AGE_DAYS >= DAYS )) ; then
135+ echo " [RESULT] Phase 1 → no PR linked + stale (>= $DAYS days)"
90136
91137 if (( DRY_RUN == 0 )) ; then
92138 gh issue edit " $ISSUE " --repo " $REPO " --remove-assignee " $USER "
93- echo " [ACTION] Unassigned $USER "
139+ echo " [ACTION] Unassigned @ $USER from issue # $ISSUE "
94140 else
95- echo " [DRY RUN] Would unassign $USER "
141+ echo " [DRY RUN] Would unassign @ $USER from issue # $ISSUE "
96142 fi
97143 else
98- echo " [KEEP] Not stale yet "
144+ echo " [RESULT] Phase 1 → no PR linked but not stale (< $DAYS days) → KEEP "
99145 fi
100146
147+ # No PRs means no Phase 2 work required for this user
101148 continue
102149 fi
103150
104- # -------------------------------
105- # PHASE 2: ISSUE HAS PR(s)
106- # -------------------------------
107- echo " [INFO] Linked PRs: $PR_NUMBERS "
151+ # ============================================================
152+ # PHASE 2: ISSUE HAS PR(s) → check last commit activity
153+ # ============================================================
154+ PHASE2_TOOK_ACTION=0
108155
109156 for PR_NUM in $PR_NUMBERS ; do
110-
111- # Safe PR check
157+ # Ensure PR exists in this repo
112158 if ! PR_STATE=$( gh pr view " $PR_NUM " --repo " $REPO " --json state --jq ' .state' 2> /dev/null) ; then
113159 echo " [SKIP] #$PR_NUM is not a valid PR in $REPO "
114160 continue
115161 fi
116162
163+ echo " [INFO] PR #$PR_NUM state: $PR_STATE "
164+
117165 if [[ " $PR_STATE " != " OPEN" ]]; then
118166 echo " [SKIP] PR #$PR_NUM is not open"
119167 continue
120168 fi
121169
122- # Last commit (paginate + last )
170+ # Fetch all commits & take the last one (API order + paginate )
123171 COMMITS=$( gh api " repos/$REPO /pulls/$PR_NUM /commits" --paginate)
124172 LAST_TS_STR=$( echo " $COMMITS " | jq -r ' last | (.commit.committer.date // .commit.author.date)' )
125173 LAST_TS=$( parse_ts " $LAST_TS_STR " )
174+ PR_AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 ))
126175
127- AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 ))
128-
129- echo " [INFO] PR #$PR_NUM → Last commit = $LAST_TS_STR (~${AGE_DAYS} days)"
176+ echo " [INFO] PR #$PR_NUM last commit: $LAST_TS_STR (~${PR_AGE_DAYS} days ago)"
130177
131- if (( AGE_DAYS >= DAYS )) ; then
132- echo " [STALE PR] PR #$PR_NUM is stale"
178+ if (( PR_AGE_DAYS >= DAYS )) ; then
179+ echo " [RESULT] Phase 2 → PR #$PR_NUM is stale (>= $DAYS days since last commit)"
180+ PHASE2_TOOK_ACTION=1
133181
134182 if (( DRY_RUN == 0 )) ; then
135183 gh pr close " $PR_NUM " --repo " $REPO "
136184 gh issue edit " $ISSUE " --repo " $REPO " --remove-assignee " $USER "
137- echo " [ACTION] Closed PR + unassigned $USER "
185+ echo " [ACTION] Closed PR # $PR_NUM and unassigned @ $USER from issue # $ISSUE "
138186 else
139- echo " [DRY RUN] Would close PR #$PR_NUM + unassign $USER "
187+ echo " [DRY RUN] Would close PR #$PR_NUM and unassign @ $USER from issue # $ISSUE "
140188 fi
189+
190+ # Per current spec, first stale PR per user/issue is enough
191+ break
141192 else
142- echo " [KEEP ] PR is active"
193+ echo " [INFO ] PR # $PR_NUM is active (< $DAYS days) → KEEP "
143194 fi
144-
145195 done
146196
197+ if (( PHASE2 _TOOK_ACTION == 0 )) ; then
198+ echo " [RESULT] Phase 2 → all linked PRs active or not applicable → KEEP"
199+ fi
200+
147201 done
148202
203+ echo
149204done
150205
151206echo " ------------------------------------------------------------"
0 commit comments