Skip to content

Commit d57a5db

Browse files
committed
bot: unify inactivity unassign logic (Phase 1 + Phase 2)
Signed-off-by: Akshat Kumar <akshat230405@gmail.com>
1 parent a3ed4ed commit d57a5db

File tree

1 file changed

+86
-76
lines changed

1 file changed

+86
-76
lines changed

.github/scripts/inactivity_unassign.sh

Lines changed: 86 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -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

99
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
1010
DAYS="${DAYS:-21}"
1111
DRY_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)
1414
shopt -s nocasematch
1515
case "$DRY_RUN" in
1616
"true") DRY_RUN=1 ;;
@@ -30,96 +30,105 @@ echo " Threshold $DAYS days"
3030
echo " DRY_RUN: $DRY_RUN"
3131
echo "------------------------------------------------------------"
3232

33+
# current time (epoch seconds)
3334
NOW_TS=$(date +%s)
3435

35-
# Convert GitHub ISO timestamp epoch seconds
36+
# Convert GitHub ISO timestamp -> epoch seconds (works on Linux/BSD)
3637
parse_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)
4857
ISSUES=$(
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

5563
for 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
133142
EOF
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
190201
EOF
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

Comments
 (0)