@@ -3,14 +3,14 @@ set -euo pipefail
33
44# Unified Inactivity Bot (Phase 1 + Phase 2)
55# DRY_RUN controls behaviour:
6- # DRY_RUN = 1 → simulate only (no changes, just logs)
7- # DRY_RUN = 0 → real actions (comments, closes, unassigns)
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} "
1111DRY_RUN=" ${DRY_RUN:- 0} "
1212
13- # Normalise DRY_RUN input ("true"/"false" → 1/0, case-insensitive)
13+ # Normalise DRY_RUN input ("true"/"false" -> 1/0, case-insensitive)
1414shopt -s nocasematch
1515case " $DRY_RUN " in
1616 " true" ) DRY_RUN=1 ;;
@@ -30,96 +30,105 @@ echo " Threshold $DAYS days"
3030echo " DRY_RUN: $DRY_RUN "
3131echo " ------------------------------------------------------------"
3232
33+ # current time (epoch seconds)
3334NOW_TS=$( date +%s)
3435
35- # Convert GitHub ISO timestamp → epoch seconds
36+ # Convert GitHub ISO timestamp -> epoch seconds (works on Linux/BSD)
3637parse_ts () {
3738 local ts=" $1 "
3839 if date --version > /dev/null 2>&1 ; then
39- # GNU date (Linux)
4040 date -d " $ts " +%s
4141 else
42- # BSD / macOS
4342 date -j -f " %Y-%m-%dT%H:%M:%SZ" " $ts " +" %s"
4443 fi
4544}
4645
47- # Fetch all open issues with assignees (no PRs)
46+ # Quick gh availability/auth checks
47+ if ! command -v gh > /dev/null 2>&1 ; then
48+ echo " ERROR: gh CLI not found. Install it and ensure it's on PATH."
49+ exit 1
50+ fi
51+
52+ if ! gh auth status > /dev/null 2>&1 ; then
53+ echo " WARN: gh auth status failed — ensure gh is logged in for non-dry runs."
54+ fi
55+
56+ # Get list of open issues with assignees (pagination via gh)
4857ISSUES=$(
49- gh api " repos/$REPO /issues" --paginate \
50- --jq ' .[]
51- | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not))
52- | .number'
58+ gh api " repos/$REPO /issues" --paginate --jq ' .[]
59+ | select(.state=="open" and (.assignees|length>0) and (.pull_request|not))
60+ | .number' 2> /dev/null || true
5361)
5462
5563for ISSUE in $ISSUES ; do
5664 echo " ============================================================"
5765 echo " ISSUE #$ISSUE "
5866 echo " ============================================================"
5967
60- ISSUE_JSON=$( gh api " repos/$REPO /issues/$ISSUE " )
61- ISSUE_CREATED_AT=$( echo " $ISSUE_JSON " | jq -r ' .created_at' )
62- ASSIGNEES=$( echo " $ISSUE_JSON " | jq -r ' .assignees[].login' )
68+ ISSUE_JSON=$( gh api " repos/$REPO /issues/$ISSUE " 2> /dev/null || echo " {} " )
69+ ISSUE_CREATED_AT=$( echo " $ISSUE_JSON " | jq -r ' .created_at // empty ' )
70+ ASSIGNEES=$( echo " $ISSUE_JSON " | jq -r ' .assignees[]? .login' 2> /dev/null || true )
6371
64- echo " [INFO] Issue created at: $ISSUE_CREATED_AT "
72+ echo " [INFO] Issue created at: ${ ISSUE_CREATED_AT:- (unknown)} "
6573 echo
6674
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"
72- )
75+ # Fetch timeline once (used for assignment events + PR links).
76+ # If gh fails, default to a valid empty JSON array so jq never blocks.
77+ TIMELINE=$( gh api -H " Accept: application/vnd.github.mockingbird-preview+json" " repos/$REPO /issues/$ISSUE /timeline" 2> /dev/null || echo " []" )
78+ TIMELINE=${TIMELINE:- ' []' } # defensive default (ensures valid JSON)
79+
80+ # if there are no assignees, skip (defensive)
81+ if [[ -z " ${ASSIGNEES// } " ]]; then
82+ echo " [INFO] No assignees for this issue, skipping."
83+ echo
84+ continue
85+ fi
7386
7487 for USER in $ASSIGNEES ; do
7588 echo " → Checking assignee: $USER "
7689
77- # Determine assignment timestamp for this user
78- ASSIGN_EVENT_JSON=$(
79- echo " $TIMELINE " | jq -c --arg user " $USER " '
80- [ .[]
81- | select(.event == "assigned")
82- | select(.assignee.login == $user)
83- ]
84- | last // empty
85- '
86- )
90+ # Determine assignment timestamp for this user: find last assigned event for this user
91+ # Use here-string to pass TIMELINE into jq (prevents jq from reading stdin unexpectedly).
92+ ASSIGN_EVENT_JSON=$( jq -c --arg user " $USER " '
93+ [ .[] | select(.event == "assigned") | select(.assignee.login == $user) ] | last // empty' <<< " $TIMELINE" 2> /dev/null || echo " " )
8794
8895 if [[ -n " $ASSIGN_EVENT_JSON " && " $ASSIGN_EVENT_JSON " != " null" ]]; then
89- ASSIGNED_AT=$( echo " $ASSIGN_EVENT_JSON " | jq -r ' .created_at' )
96+ ASSIGNED_AT=$( echo " $ASSIGN_EVENT_JSON " | jq -r ' .created_at // empty ' )
9097 ASSIGN_SOURCE=" assignment_event"
9198 else
92- # Fallback : use issue creation time when no explicit assignment event
93- ASSIGNED_AT=" $ISSUE_CREATED_AT "
99+ # fallback : use issue creation time
100+ ASSIGNED_AT=" ${ ISSUE_CREATED_AT:- } "
94101 ASSIGN_SOURCE=" issue_created_at (no explicit assignment event)"
95102 fi
96103
97- ASSIGNED_TS=$( parse_ts " $ASSIGNED_AT " )
98- ASSIGNED_AGE_DAYS=$(( (NOW_TS - ASSIGNED_TS) / 86400 ))
104+ # compute assignment age safely (if no timestamp, set to 0)
105+ if [[ -n " $ASSIGNED_AT " ]]; then
106+ ASSIGNED_TS=$( parse_ts " $ASSIGNED_AT " )
107+ ASSIGNED_AGE_DAYS=$(( (NOW_TS - ASSIGNED_TS) / 86400 ))
108+ else
109+ ASSIGNED_AGE_DAYS=0
110+ fi
99111
100112 echo " [INFO] Assignment source: $ASSIGN_SOURCE "
101- echo " [INFO] Assigned at: $ASSIGNED_AT (~${ASSIGNED_AGE_DAYS} days ago)"
102-
103- # Determine PRs linked to this issue for this user
104- PR_NUMBERS=$(
105- echo " $TIMELINE " | jq -r --arg repo " $REPO " --arg user " $USER " '
106- .[]
107- | select(.event == "cross-referenced")
108- | select(.source.issue.pull_request != null)
109- | select(.source.issue.repository.full_name == $repo)
110- | select(.source.issue.user.login == $user)
111- | .source.issue.number
112- '
113- )
114-
115- # ===========================
116- # PHASE 1: ISSUE HAS NO PR(s)
117- # ===========================
118- if [[ -z " $PR_NUMBERS " ]]; then
113+ echo " [INFO] Assigned at: ${ASSIGNED_AT:- (unknown)} (~${ASSIGNED_AGE_DAYS} days ago)"
114+
115+ # Determine PRs cross-referenced from the same repo
116+ PR_NUMBERS=$( jq -r --arg repo " $REPO " '
117+ .[] |
118+ select(.event == "cross-referenced") |
119+ select(.source.issue.pull_request != null) |
120+ select(.source.issue.repository.full_name == $repo) |
121+ .source.issue.number' <<< " $TIMELINE" 2> /dev/null || true)
122+
123+ # Defensive normalization: remove blank lines and spaces
124+ PR_NUMBERS=$( echo " $PR_NUMBERS " | sed ' /^[[:space:]]*$/d' || true)
125+
126+ # PHASE 1: no PRs attached
127+ if [[ -z " ${PR_NUMBERS// } " ]]; then
119128 echo " [INFO] Linked PRs: none"
120129
121130 if (( ASSIGNED_AGE_DAYS >= DAYS )) ; then
122- echo " [RESULT] Phase 1 → stale assignment (>= $DAYS days, no PR)"
131+ echo " [RESULT] Phase 1 -> stale assignment (>= $DAYS days, no PR)"
123132
124133 if (( DRY_RUN == 0 )) ; then
125134 MESSAGE=$(
@@ -133,51 +142,53 @@ If you'd like to continue working on this later, feel free to get re-assigned or
133142EOF
134143 )
135144
136- gh issue comment " $ISSUE " --repo " $REPO " --body " $MESSAGE "
137- gh issue edit " $ISSUE " --repo " $REPO " --remove-assignee " $USER "
145+ gh issue comment " $ISSUE " --repo " $REPO " --body " $MESSAGE " || echo " WARN: couldn't post comment (gh error) "
146+ gh issue edit " $ISSUE " --repo " $REPO " --remove-assignee " $USER " || echo " WARN: couldn't remove assignee (gh error) "
138147 echo " [ACTION] Commented and unassigned @$USER from issue #$ISSUE "
139148 else
140149 echo " [DRY RUN] Would comment + unassign @$USER from issue #$ISSUE (Phase 1 stale)"
141150 fi
142151 else
143- echo " [RESULT] Phase 1 → no PR linked but not stale (< $DAYS days) → KEEP"
152+ echo " [RESULT] Phase 1 -> no PR linked but not stale (< $DAYS days) -> KEEP"
144153 fi
145154
146155 echo
147156 continue
148157 fi
149158
150- # ===========================
151- # PHASE 2: ISSUE HAS PR(s)
152- # ===========================
159+ # PHASE 2: process linked PR(s)
153160 echo " [INFO] Linked PRs: $PR_NUMBERS "
154-
155161 PHASE2_TOUCHED=0
156162
157163 for PR_NUM in $PR_NUMBERS ; do
158- # Safe PR existence check
164+ # Safe check: verify PR exists in this repo
159165 if ! PR_STATE=$( gh pr view " $PR_NUM " --repo " $REPO " --json state --jq ' .state' 2> /dev/null) ; then
160166 echo " [SKIP] #$PR_NUM is not a valid PR in $REPO "
161167 continue
162168 fi
163169
170+ # log state and continue only if open
164171 echo " [INFO] PR #$PR_NUM state: $PR_STATE "
165-
166172 if [[ " $PR_STATE " != " OPEN" ]]; then
167173 echo " [SKIP] PR #$PR_NUM is not open"
168174 continue
169175 fi
170176
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)' )
173- LAST_TS=$( parse_ts " $LAST_TS_STR " )
174- PR_AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 ))
177+ # get last commit time safely
178+ COMMITS_JSON=$( gh api " repos/$REPO /pulls/$PR_NUM /commits" --paginate 2> /dev/null || echo " []" )
179+ LAST_TS_STR=$( jq -r ' last? | (.commit.committer.date // .commit.author.date) // empty' <<< " $COMMITS_JSON" 2> /dev/null || echo " " )
180+ if [[ -n " $LAST_TS_STR " ]]; then
181+ LAST_TS=$( parse_ts " $LAST_TS_STR " )
182+ PR_AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 ))
183+ else
184+ PR_AGE_DAYS=$(( DAYS+ 1 )) # treat as stale if we cannot find commit timestamp
185+ fi
175186
176- echo " [INFO] PR #$PR_NUM last commit: $LAST_TS_STR (~${PR_AGE_DAYS} days ago)"
187+ echo " [INFO] PR #$PR_NUM last commit: ${ LAST_TS_STR:- (unknown)} (~${PR_AGE_DAYS} days ago)"
177188
178189 if (( PR_AGE_DAYS >= DAYS )) ; then
179190 PHASE2_TOUCHED=1
180- echo " [RESULT] Phase 2 → PR #$PR_NUM is stale (>= $DAYS days since last commit)"
191+ echo " [RESULT] Phase 2 -> PR #$PR_NUM is stale (>= $DAYS days since last commit)"
181192
182193 if (( DRY_RUN == 0 )) ; then
183194 MESSAGE=$(
@@ -190,21 +201,20 @@ You're very welcome to open a new PR or ask to be re-assigned when you're ready
190201EOF
191202 )
192203
193- gh pr comment " $PR_NUM " --repo " $REPO " --body " $MESSAGE "
194- gh pr close " $PR_NUM " --repo " $REPO "
195- gh issue edit " $ISSUE " --repo " $REPO " --remove-assignee " $USER "
196-
204+ gh pr comment " $PR_NUM " --repo " $REPO " --body " $MESSAGE " || echo " WARN: couldn't comment on PR"
205+ gh pr close " $PR_NUM " --repo " $REPO " || echo " WARN: couldn't close PR"
206+ gh issue edit " $ISSUE " --repo " $REPO " --remove-assignee " $USER " || echo " WARN: couldn't remove assignee"
197207 echo " [ACTION] Commented on PR #$PR_NUM , closed it, and unassigned @$USER from issue #$ISSUE "
198208 else
199209 echo " [DRY RUN] Would comment, close PR #$PR_NUM , and unassign @$USER from issue #$ISSUE "
200210 fi
201211 else
202- echo " [INFO] PR #$PR_NUM is active (< $DAYS days) → KEEP"
212+ echo " [INFO] PR #$PR_NUM is active (< $DAYS days) -> KEEP"
203213 fi
204214 done
205215
206216 if (( PHASE2 _TOUCHED == 0 )) ; then
207- echo " [RESULT] Phase 2 → all linked PRs active or not applicable → KEEP"
217+ echo " [RESULT] Phase 2 -> all linked PRs active or not applicable -> KEEP"
208218 fi
209219
210220 echo
0 commit comments