Skip to content

Commit 9d14d05

Browse files
sylvansysclaude
andauthored
fix: Prevent extract-result.sh crashes and ensure webhook fires on failure (#72)
Three bugs caused review checks to pass in comments but fail in GitHub checks: Bug A: extract-result.sh crashed under set -e -o pipefail because grep/perl return non-zero on no match, and Strategy 4's Python regex was fundamentally broken. Rewrote all strategies with || true guards, proper jq -e '.checks' validation, and a brace-depth-counting Python extractor. Strategy 1 now correctly picks the last valid json block (skipping examples). Bug B: Webhook steps used bare `if: matrix.agent == 'X'` conditions, which GitHub Actions implicitly wraps as `if: success() && ...`. When the review action failed, the webhook was skipped and comments showed stale data. Changed to `if: always() && ...` with default output guards. Bug C: Added REQUIRED comment on pull-requests: read permission to flag it as critical for provisioning (external repos had actions: read instead). Closes #71 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8f3763b commit 9d14d05

2 files changed

Lines changed: 121 additions & 31 deletions

File tree

.github/actions/shared/extract-result.sh

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,101 @@ extract_review_result() {
4949
# Get ALL text from assistant messages, joined together
5050
ALL_TEXT=$(jq -r '[.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text] | join("\n")' "$EXEC" 2>/dev/null || echo "")
5151

52-
# Strategy 1: Extract from ```json block
53-
JSON=$(echo "$ALL_TEXT" | sed -n '/```json/,/```/{/```/d;p;}' | head -100)
52+
JSON=""
5453

55-
# Strategy 2: If no json block, try to find raw JSON object with "checks" key
56-
if ! echo "$JSON" | jq . >/dev/null 2>&1; then
57-
JSON=$(echo "$ALL_TEXT" | grep -oP '\{"checks":\s*\[.*\]\s*(,"message":[^}]*)?\}' | head -1)
54+
# Strategy 1: Extract from ```json blocks — take the LAST valid one with .checks
55+
# (Claude may output example JSON before the actual result)
56+
if [ -z "$JSON" ]; then
57+
local BLOCK=""
58+
local IN_BLOCK=false
59+
while IFS= read -r line; do
60+
if [[ "$line" == '```json'* ]]; then
61+
IN_BLOCK=true
62+
BLOCK=""
63+
continue
64+
fi
65+
if [[ "$line" == '```'* ]] && [ "$IN_BLOCK" = true ]; then
66+
IN_BLOCK=false
67+
if echo "$BLOCK" | jq -e '.checks' >/dev/null 2>&1; then
68+
JSON="$BLOCK"
69+
# Don't break — keep going to find the LAST valid block
70+
fi
71+
continue
72+
fi
73+
if [ "$IN_BLOCK" = true ]; then
74+
BLOCK="${BLOCK}${line}"$'\n'
75+
fi
76+
done <<< "$ALL_TEXT"
77+
if [ -n "$JSON" ]; then
78+
echo " [extract] Strategy 1 matched (json code block)" >&2
79+
fi
5880
fi
5981

60-
# Strategy 3: Extract any JSON object starting with {"checks"
61-
if ! echo "$JSON" | jq . >/dev/null 2>&1; then
62-
JSON=$(echo "$ALL_TEXT" | perl -ne 'print if /\{"checks":\[/.../"message":/' | tr '\n' ' ')
82+
# Strategy 2: Try to find raw JSON object with "checks" key on a single line
83+
if [ -z "$JSON" ] || ! echo "$JSON" | jq -e '.checks' >/dev/null 2>&1; then
84+
JSON=""
85+
local CANDIDATE
86+
CANDIDATE=$(echo "$ALL_TEXT" | grep -oP '\{"checks":\s*\[.*\]\s*(,"message":[^}]*)?\}' 2>/dev/null | head -1 || true)
87+
if [ -n "$CANDIDATE" ] && echo "$CANDIDATE" | jq -e '.checks' >/dev/null 2>&1; then
88+
JSON="$CANDIDATE"
89+
echo " [extract] Strategy 2 matched (grep single-line)" >&2
90+
fi
6391
fi
6492

65-
# Strategy 4: Use Python for robust multiline JSON extraction
66-
if ! echo "$JSON" | jq . >/dev/null 2>&1; then
67-
JSON=$(echo "$ALL_TEXT" | python3 -c "import re,sys; t=sys.stdin.read(); m=re.search(r'\{[^{}]*\"checks\"[^{}]*\[.*?\][^{}]*\}',t,re.DOTALL); print(m.group() if m else '')" 2>/dev/null)
93+
# Strategy 3: Extract JSON starting with {"checks" using perl range operator
94+
if [ -z "$JSON" ] || ! echo "$JSON" | jq -e '.checks' >/dev/null 2>&1; then
95+
JSON=""
96+
local CANDIDATE
97+
CANDIDATE=$(echo "$ALL_TEXT" | perl -ne 'print if /\{"checks":\s*\[/.../"message"\s*:/' 2>/dev/null | tr '\n' ' ' || true)
98+
if [ -n "$CANDIDATE" ] && echo "$CANDIDATE" | jq -e '.checks' >/dev/null 2>&1; then
99+
JSON="$CANDIDATE"
100+
echo " [extract] Strategy 3 matched (perl range)" >&2
101+
fi
68102
fi
69103

70-
if echo "$JSON" | jq . >/dev/null 2>&1; then
104+
# Strategy 4: Use Python brace-depth counter for robust multiline extraction
105+
if [ -z "$JSON" ] || ! echo "$JSON" | jq -e '.checks' >/dev/null 2>&1; then
106+
JSON=""
107+
local CANDIDATE
108+
CANDIDATE=$(echo "$ALL_TEXT" | python3 -c "
109+
import sys, json
110+
111+
text = sys.stdin.read()
112+
# Find all top-level JSON objects containing 'checks'
113+
results = []
114+
i = 0
115+
while i < len(text):
116+
if text[i] == '{':
117+
depth = 0
118+
start = i
119+
while i < len(text):
120+
if text[i] == '{':
121+
depth += 1
122+
elif text[i] == '}':
123+
depth -= 1
124+
if depth == 0:
125+
candidate = text[start:i+1]
126+
try:
127+
obj = json.loads(candidate)
128+
if 'checks' in obj:
129+
results.append(candidate)
130+
except (json.JSONDecodeError, ValueError):
131+
pass
132+
break
133+
i += 1
134+
i += 1
135+
136+
# Print the last valid match (actual result comes after examples)
137+
if results:
138+
print(results[-1])
139+
" 2>/dev/null || true)
140+
if [ -n "$CANDIDATE" ] && echo "$CANDIDATE" | jq -e '.checks' >/dev/null 2>&1; then
141+
JSON="$CANDIDATE"
142+
echo " [extract] Strategy 4 matched (python brace-depth)" >&2
143+
fi
144+
fi
145+
146+
if [ -n "$JSON" ] && echo "$JSON" | jq -e '.checks' >/dev/null 2>&1; then
71147
# Count checks by status
72148
CHECKS_PASSED=$(echo "$JSON" | jq '[.checks[]? | select(.status == "passed")] | length')
73149
CHECKS_FAILED=$(echo "$JSON" | jq '[.checks[]? | select(.status == "failed")] | length')
@@ -82,6 +158,8 @@ extract_review_result() {
82158
fi
83159

84160
RESULT=$(echo "$JSON" | jq -c '.')
161+
else
162+
echo " [extract] No strategy matched — using default failed result" >&2
85163
fi
86164
fi
87165
}

.github/templates/constellos-review.yml

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ on:
1212
permissions:
1313
contents: read
1414
issues: read
15-
pull-requests: read
15+
pull-requests: read # REQUIRED: agents need this to fetch changed files via gh pr view
1616

1717
jobs:
1818
# Read config and determine which agents to run
@@ -55,9 +55,13 @@ jobs:
5555
github_token: ${{ secrets.GITHUB_TOKEN }}
5656

5757
- name: Send requirements results to webhook
58-
if: matrix.agent == 'requirements'
58+
if: always() && matrix.agent == 'requirements'
5959
env:
60-
RESULT_JSON: ${{ steps.requirements.outputs.result }}
60+
RESULT_JSON: ${{ steps.requirements.outputs.result || '{"checks":[],"message":"Review failed to complete"}' }}
61+
PASSED: ${{ steps.requirements.outputs.passed || 'false' }}
62+
CHECKS_PASSED: ${{ steps.requirements.outputs.checks_passed || '0' }}
63+
CHECKS_FAILED: ${{ steps.requirements.outputs.checks_failed || '0' }}
64+
CHECKS_SKIPPED: ${{ steps.requirements.outputs.checks_skipped || '0' }}
6165
run: |
6266
# Build JSON payload safely using jq
6367
jq -n \
@@ -67,11 +71,11 @@ jobs:
6771
--arg sha "${{ github.event.pull_request.head.sha }}" \
6872
--argjson run_id "${{ github.run_id }}" \
6973
--arg review_name "Requirements" \
70-
--argjson passed "${{ steps.requirements.outputs.passed }}" \
74+
--argjson passed "$PASSED" \
7175
--argjson result_json "$RESULT_JSON" \
72-
--argjson checks_passed "${{ steps.requirements.outputs.checks_passed || 0 }}" \
73-
--argjson checks_failed "${{ steps.requirements.outputs.checks_failed || 0 }}" \
74-
--argjson checks_skipped "${{ steps.requirements.outputs.checks_skipped || 0 }}" \
76+
--argjson checks_passed "$CHECKS_PASSED" \
77+
--argjson checks_failed "$CHECKS_FAILED" \
78+
--argjson checks_skipped "$CHECKS_SKIPPED" \
7579
--arg prompt_file ".constellos/agents/requirements.md" \
7680
'{
7781
owner: $owner,
@@ -103,9 +107,13 @@ jobs:
103107
github_token: ${{ secrets.GITHUB_TOKEN }}
104108

105109
- name: Send code-quality results to webhook
106-
if: matrix.agent == 'code-quality'
110+
if: always() && matrix.agent == 'code-quality'
107111
env:
108-
RESULT_JSON: ${{ steps.code-quality.outputs.result }}
112+
RESULT_JSON: ${{ steps.code-quality.outputs.result || '{"checks":[],"message":"Review failed to complete"}' }}
113+
PASSED: ${{ steps.code-quality.outputs.passed || 'false' }}
114+
CHECKS_PASSED: ${{ steps.code-quality.outputs.checks_passed || '0' }}
115+
CHECKS_FAILED: ${{ steps.code-quality.outputs.checks_failed || '0' }}
116+
CHECKS_SKIPPED: ${{ steps.code-quality.outputs.checks_skipped || '0' }}
109117
run: |
110118
# Build JSON payload safely using jq
111119
jq -n \
@@ -115,11 +123,11 @@ jobs:
115123
--arg sha "${{ github.event.pull_request.head.sha }}" \
116124
--argjson run_id "${{ github.run_id }}" \
117125
--arg review_name "Code Quality" \
118-
--argjson passed "${{ steps.code-quality.outputs.passed }}" \
126+
--argjson passed "$PASSED" \
119127
--argjson result_json "$RESULT_JSON" \
120-
--argjson checks_passed "${{ steps.code-quality.outputs.checks_passed || 0 }}" \
121-
--argjson checks_failed "${{ steps.code-quality.outputs.checks_failed || 0 }}" \
122-
--argjson checks_skipped "${{ steps.code-quality.outputs.checks_skipped || 0 }}" \
128+
--argjson checks_passed "$CHECKS_PASSED" \
129+
--argjson checks_failed "$CHECKS_FAILED" \
130+
--argjson checks_skipped "$CHECKS_SKIPPED" \
123131
--arg prompt_file ".constellos/agents/code-quality.md" \
124132
'{
125133
owner: $owner,
@@ -151,9 +159,13 @@ jobs:
151159
github_token: ${{ secrets.GITHUB_TOKEN }}
152160

153161
- name: Send context results to webhook
154-
if: matrix.agent == 'context'
162+
if: always() && matrix.agent == 'context'
155163
env:
156-
RESULT_JSON: ${{ steps.context.outputs.result }}
164+
RESULT_JSON: ${{ steps.context.outputs.result || '{"checks":[],"message":"Review failed to complete"}' }}
165+
PASSED: ${{ steps.context.outputs.passed || 'false' }}
166+
CHECKS_PASSED: ${{ steps.context.outputs.checks_passed || '0' }}
167+
CHECKS_FAILED: ${{ steps.context.outputs.checks_failed || '0' }}
168+
CHECKS_SKIPPED: ${{ steps.context.outputs.checks_skipped || '0' }}
157169
run: |
158170
# Build JSON payload safely using jq
159171
jq -n \
@@ -163,11 +175,11 @@ jobs:
163175
--arg sha "${{ github.event.pull_request.head.sha }}" \
164176
--argjson run_id "${{ github.run_id }}" \
165177
--arg review_name "Context" \
166-
--argjson passed "${{ steps.context.outputs.passed }}" \
178+
--argjson passed "$PASSED" \
167179
--argjson result_json "$RESULT_JSON" \
168-
--argjson checks_passed "${{ steps.context.outputs.checks_passed || 0 }}" \
169-
--argjson checks_failed "${{ steps.context.outputs.checks_failed || 0 }}" \
170-
--argjson checks_skipped "${{ steps.context.outputs.checks_skipped || 0 }}" \
180+
--argjson checks_passed "$CHECKS_PASSED" \
181+
--argjson checks_failed "$CHECKS_FAILED" \
182+
--argjson checks_skipped "$CHECKS_SKIPPED" \
171183
--arg prompt_file ".constellos/agents/context.md" \
172184
'{
173185
owner: $owner,

0 commit comments

Comments
 (0)