diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index 7d852df..dd58306 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -94,6 +94,76 @@ is_blocked() { | jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1 } +# ============================================================================= +# STALENESS DETECTION FOR IN-PROGRESS ISSUES +# ============================================================================= + +# Check if a tmux session for a specific issue is alive +# Args: project_name issue_number +# Returns: 0 if session is alive, 1 if not +session_is_alive() { + local project="$1" issue="$2" + local session="dev-${project}-${issue}" + tmux has-session -t "$session" 2>/dev/null +} + +# Check if there's an open PR for a specific issue +# Args: project_name issue_number +# Returns: 0 if open PR exists, 1 if not +open_pr_exists() { + local project="$1" issue="$2" + local branch="fix/issue-${issue}" + local pr_num + + pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${API}/pulls?state=open&limit=20" | \ + jq -r --arg branch "$branch" \ + '.[] | select(.head.ref == $branch) | .number' | head -1) || true + + [ -n "$pr_num" ] +} + +# Relabel a stale in-progress issue to blocked with diagnostic comment +# Args: issue_number reason +# Uses shared helpers from lib/issue-lifecycle.sh +relabel_stale_issue() { + local issue="$1" reason="$2" + + log "relabeling stale in-progress issue #${issue} to blocked: ${reason}" + + # Remove in-progress label + local ip_id + ip_id=$(_ilc_in_progress_id) + if [ -n "$ip_id" ]; then + curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \ + "${API}/issues/${issue}/labels/${ip_id}" >/dev/null 2>&1 || true + fi + + # Add blocked label + local bk_id + bk_id=$(_ilc_blocked_id) + if [ -n "$bk_id" ]; then + curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/issues/${issue}/labels" \ + -d "{\"labels\":[${bk_id}]}" >/dev/null 2>&1 || true + fi + + # Post diagnostic comment using shared helper + local comment_body + comment_body=$( + printf '### Stale in-progress issue detected\n\n' + printf '| Field | Value |\n|---|---|\n' + printf '| Detection reason | `%s` |\n' "$reason" + printf '| Timestamp | `%s` |\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '\n**Status:** This issue was labeled `in-progress` but no active tmux session exists.\n' + printf '**Action required:** A maintainer should triage this issue.\n' + ) + _ilc_post_comment "$issue" "$comment_body" + + _ilc_log "stale issue #${issue} relabeled to blocked: ${reason}" +} + # ============================================================================= # HELPER: handle CI-exhaustion check/block (DRY for 3 call sites) # Sets CI_FIX_ATTEMPTS for caller use. Returns 0 if exhausted, 1 if not. @@ -320,6 +390,25 @@ ORPHAN_COUNT=$(echo "$ORPHANS_JSON" | jq 'length') if [ "$ORPHAN_COUNT" -gt 0 ]; then ISSUE_NUM=$(echo "$ORPHANS_JSON" | jq -r '.[0].number') + # Staleness check: if no tmux session and no open PR, the issue is stale + SESSION_ALIVE=false + OPEN_PR=false + if tmux has-session -t "dev-${PROJECT_NAME}-${ISSUE_NUM}" 2>/dev/null; then + SESSION_ALIVE=true + fi + if curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${API}/pulls?state=open&limit=20" | \ + jq -e --arg branch "fix/issue-${ISSUE_NUM}" \ + '.[] | select(.head.ref == $branch)' >/dev/null 2>&1; then + OPEN_PR=true + fi + + if [ "$SESSION_ALIVE" = false ] && [ "$OPEN_PR" = false ]; then + log "issue #${ISSUE_NUM} is stale (no active tmux session, no open PR) — relabeling to blocked" + relabel_stale_issue "$ISSUE_NUM" "no_active_session_no_open_pr" + exit 0 + fi + # Formula guard: formula-labeled issues should not be worked on by dev-agent. # Remove in-progress label and skip to prevent infinite respawn cycle (#115). ORPHAN_LABELS=$(echo "$ORPHANS_JSON" | jq -r '.[0].labels[].name' 2>/dev/null) || true diff --git a/lib/issue-lifecycle.sh b/lib/issue-lifecycle.sh index 81586f9..6b14090 100644 --- a/lib/issue-lifecycle.sh +++ b/lib/issue-lifecycle.sh @@ -161,6 +161,27 @@ issue_release() { _ilc_log "released issue #${issue}" } +# --------------------------------------------------------------------------- +# _ilc_post_comment — Post a comment to an issue (internal helper) +# Args: issue_number body_text +# Uses a temp file to avoid large inline strings. +# --------------------------------------------------------------------------- +_ilc_post_comment() { + local issue="$1" body="$2" + + local tmpfile tmpjson + tmpfile=$(mktemp /tmp/ilc-comment-XXXXXX.md) + tmpjson="${tmpfile}.json" + printf '%s' "$body" > "$tmpfile" + jq -Rs '{body:.}' < "$tmpfile" > "$tmpjson" + curl -sf -o /dev/null -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${FORGE_API}/issues/${issue}/comments" \ + --data-binary @"$tmpjson" 2>/dev/null || true + rm -f "$tmpfile" "$tmpjson" +} + # --------------------------------------------------------------------------- # issue_block — add "blocked" label, post diagnostic comment, remove in-progress. # Args: issue_number reason [result_text] @@ -187,14 +208,9 @@ issue_block() { fi } > "$tmpfile" - # Post comment - jq -Rs '{body:.}' < "$tmpfile" > "${tmpfile}.json" - curl -sf -o /dev/null -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_API}/issues/${issue}/comments" \ - --data-binary @"${tmpfile}.json" 2>/dev/null || true - rm -f "$tmpfile" "${tmpfile}.json" + # Post comment using shared helper + _ilc_post_comment "$issue" "$(cat "$tmpfile")" + rm -f "$tmpfile" # Remove in-progress, add blocked local ip_id bk_id