diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index 924c78e..6dd3678 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -60,10 +60,12 @@ json.dump(d,open(f,'w')) # Check whether an issue/PR has been escalated to supervisor (unprocessed or processed) is_escalated() { local issue="$1" pr="$2" - local esc_file="${FACTORY_ROOT}/supervisor/escalations.jsonl" - local done_file="${FACTORY_ROOT}/supervisor/escalations.done.jsonl" python3 -c " import json, sys +try: + issue, pr = int('${issue}'), int('${pr}') +except (ValueError, TypeError): + sys.exit(1) for path in ['${FACTORY_ROOT}/supervisor/escalations.jsonl', '${FACTORY_ROOT}/supervisor/escalations.done.jsonl']: try: @@ -73,7 +75,7 @@ for path in ['${FACTORY_ROOT}/supervisor/escalations.jsonl', if not line: continue d = json.loads(line) - if d.get('issue') == ${issue} and d.get('pr') == ${pr}: + if d.get('issue') == issue and d.get('pr') == pr: sys.exit(0) except OSError: pass diff --git a/supervisor/supervisor-poll.sh b/supervisor/supervisor-poll.sh index 8d6e7f9..48c2834 100755 --- a/supervisor/supervisor-poll.sh +++ b/supervisor/supervisor-poll.sh @@ -221,7 +221,13 @@ ESCALATION_FILE="${FACTORY_ROOT}/supervisor/escalations.jsonl" ESCALATION_DONE="${FACTORY_ROOT}/supervisor/escalations.done.jsonl" if [ -s "$ESCALATION_FILE" ]; then - ESCALATION_COUNT=$(wc -l < "$ESCALATION_FILE") + # Atomically snapshot the file before processing to prevent race with + # concurrent dev-poll appends: new entries go to a fresh ESCALATION_FILE + # while we process the snapshot, so nothing is ever silently dropped. + ESCALATION_SNAP="${ESCALATION_FILE}.processing.$$" + mv "$ESCALATION_FILE" "$ESCALATION_SNAP" + + ESCALATION_COUNT=$(wc -l < "$ESCALATION_SNAP") flog "Processing ${ESCALATION_COUNT} escalation(s) from dev-agent" while IFS= read -r esc_entry; do @@ -245,9 +251,15 @@ if [ -s "$ESCALATION_FILE" ]; then ESC_PIPELINE="" ESC_SUB_ISSUES_CREATED=0 ESC_GENERIC_FAIL="" + ESC_LOGS_AVAILABLE=0 if [ -n "$ESC_PR_SHA" ]; then - ESC_PIPELINE=$(wpdb -c "SELECT number FROM pipelines WHERE repo_id=${WOODPECKER_REPO_ID} AND commit='${ESC_PR_SHA}' ORDER BY created DESC LIMIT 1;" 2>/dev/null | xargs || true) + # Validate SHA is a 40-char hex string before interpolating into SQL + if [[ "$ESC_PR_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + ESC_PIPELINE=$(wpdb -c "SELECT number FROM pipelines WHERE repo_id=${WOODPECKER_REPO_ID} AND commit='${ESC_PR_SHA}' ORDER BY created DESC LIMIT 1;" 2>/dev/null | xargs || true) + else + flog "WARNING: ESC_PR_SHA '${ESC_PR_SHA}' is not a valid hex SHA — skipping pipeline lookup" + fi fi if [ -n "$ESC_PIPELINE" ]; then @@ -258,8 +270,10 @@ if [ -s "$ESCALATION_FILE" ]; then while IFS=$'\t' read -r step_pid step_name; do [ -z "$step_pid" ] && continue + [[ "$step_pid" =~ ^[0-9]+$ ]] || { flog "WARNING: invalid step_pid '${step_pid}' — skipping"; continue; } step_logs=$(woodpecker-cli pipeline log show "${CODEBERG_REPO}" "${ESC_PIPELINE}" "${step_pid}" 2>/dev/null | tail -150 || true) [ -z "$step_logs" ] && continue + ESC_LOGS_AVAILABLE=1 if echo "$step_name" | grep -qi "shellcheck"; then # Create one sub-issue per file with ShellCheck errors @@ -267,8 +281,10 @@ if [ -s "$ESCALATION_FILE" ]; then while IFS= read -r sc_file; do [ -z "$sc_file" ] && continue - file_errors=$(echo "$step_logs" | grep -A3 "In ${sc_file} line" | head -30) - sc_codes=$(echo "$step_logs" | grep -oP 'SC\d+' | sort -u | tr '\n' ' ' | sed 's/ $//' || true) + # grep -F for literal filename match (dots in filenames are regex wildcards) + file_errors=$(echo "$step_logs" | grep -F -A3 "In ${sc_file} line" | head -30) + # SC codes only from this file's errors, not the whole step log + sc_codes=$(echo "$file_errors" | grep -oP 'SC\d+' | sort -u | tr '\n' ' ' | sed 's/ $//' || true) sub_title="fix: ShellCheck errors in ${sc_file} (from PR #${ESC_PR})" sub_body="## ShellCheck CI failure — \`${sc_file}\` @@ -304,9 +320,14 @@ Fix all ShellCheck errors${sc_codes:+ (${sc_codes})} in \`${sc_file}\` so PR #${ else # Accumulate non-ShellCheck failures for one combined issue - ESC_GENERIC_FAIL="${ESC_GENERIC_FAIL} -=== ${step_name} === + esc_section="=== ${step_name} === $(echo "$step_logs" | tail -50)" + if [ -z "$ESC_GENERIC_FAIL" ]; then + ESC_GENERIC_FAIL="$esc_section" + else + ESC_GENERIC_FAIL="${ESC_GENERIC_FAIL} +${esc_section}" + fi fi done <<< "$FAILED_STEPS" fi @@ -319,7 +340,8 @@ $(echo "$step_logs" | tail -50)" Spawned by supervisor from escalated issue #${ESC_ISSUE} (PR #${ESC_PR} failed CI after ${ESC_ATTEMPTS} attempt(s)). ### Failed step output -\`\`\`${ESC_GENERIC_FAIL} +\`\`\` +${ESC_GENERIC_FAIL} \`\`\` ### Context @@ -342,14 +364,24 @@ Spawned by supervisor from escalated issue #${ESC_ISSUE} (PR #${ESC_PR} failed C fi fi - # Fallback: no CI logs available — create a generic investigation issue + # Fallback: no sub-issues created — differentiate logs-unavailable from creation failure if [ "$ESC_SUB_ISSUES_CREATED" -eq 0 ]; then sub_title="fix: investigate CI failure for PR #${ESC_PR} (from issue #${ESC_ISSUE})" - sub_body="## CI failure — investigation required + if [ "$ESC_LOGS_AVAILABLE" -eq 1 ]; then + # Logs were fetched but all issue creation API calls failed + sub_body="## CI failure — investigation required + +Spawned by supervisor from escalated issue #${ESC_ISSUE} (PR #${ESC_PR} failed CI after ${ESC_ATTEMPTS} attempt(s)). CI logs were retrieved but sub-issue creation failed (API error). + +Check PR #${ESC_PR} CI output, identify the failing checks, and fix them so the PR can merge." + else + # Could not retrieve CI logs at all + sub_body="## CI failure — investigation required Spawned by supervisor from escalated issue #${ESC_ISSUE} (PR #${ESC_PR} failed CI after ${ESC_ATTEMPTS} attempt(s)). CI logs were unavailable at escalation time. Check PR #${ESC_PR} CI output, identify the failing checks, and fix them so the PR can merge." + fi new_issue=$(curl -sf -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ @@ -367,10 +399,9 @@ Check PR #${ESC_PR} CI output, identify the failing checks, and fix them so the # Mark as processed echo "$esc_entry" >> "$ESCALATION_DONE" - done < "$ESCALATION_FILE" + done < "$ESCALATION_SNAP" - # Clear processed escalations - > "$ESCALATION_FILE" + rm -f "$ESCALATION_SNAP" flog "Escalations processed — moved to $(basename "$ESCALATION_DONE")" fi