Skip to content

Commit a76bae1

Browse files
Merge pull request #500 from alirezarezvani/dev
2 parents 43295c1 + 44654d2 commit a76bae1

5 files changed

Lines changed: 516 additions & 0 deletions

File tree

.github/workflows/ci-quality-gate.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
echo "target_ref=${{ github.event.inputs.ref }}" >> "$GITHUB_OUTPUT"
3232
elif [[ "${{ github.event_name }}" == "repository_dispatch" && -n "${{ github.event.client_payload.ref }}" ]]; then
3333
echo "target_ref=${{ github.event.client_payload.ref }}" >> "$GITHUB_OUTPUT"
34+
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
35+
# Use commit SHA for PRs — branch names from forks don't exist in the base repo
36+
echo "target_ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
3437
else
3538
echo "target_ref=${{ github.head_ref || github.ref_name }}" >> "$GITHUB_OUTPUT"
3639
fi

.github/workflows/claude-code-review.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ jobs:
114114
115115
- name: Post fallback review note (quota/timeout)
116116
if: steps.claude-review.outcome != 'success'
117+
continue-on-error: true # Fork PRs have read-only GITHUB_TOKEN
117118
uses: actions/github-script@v7
118119
with:
119120
script: |
@@ -129,3 +130,13 @@ jobs:
129130
- Proceed with manual review to unblock
130131
`
131132
})
133+
134+
- name: Write review status to job summary
135+
if: steps.claude-review.outcome != 'success'
136+
run: |
137+
echo "## ⚠️ Automated Review Skipped" >> "$GITHUB_STEP_SUMMARY"
138+
echo "" >> "$GITHUB_STEP_SUMMARY"
139+
echo "The Claude review could not complete." >> "$GITHUB_STEP_SUMMARY"
140+
echo "For fork PRs this is expected — OIDC tokens are unavailable." >> "$GITHUB_STEP_SUMMARY"
141+
echo "" >> "$GITHUB_STEP_SUMMARY"
142+
echo "- Retry from the Actions tab, or proceed with manual review." >> "$GITHUB_STEP_SUMMARY"
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
---
2+
name: Skill Quality Review (Tessl)
3+
4+
'on':
5+
pull_request:
6+
types: [opened, synchronize, reopened]
7+
paths:
8+
- '**/SKILL.md'
9+
- '**/scripts/*.py'
10+
11+
concurrency:
12+
group: quality-review-${{ github.event.pull_request.number }}
13+
cancel-in-progress: true
14+
15+
jobs:
16+
detect-skills:
17+
name: Detect changed skills
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: read
21+
outputs:
22+
skills: ${{ steps.find.outputs.skills }}
23+
has_skills: ${{ steps.find.outputs.has_skills }}
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
with:
28+
fetch-depth: 0
29+
30+
- name: Find changed skill directories
31+
id: find
32+
run: |
33+
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD 2>/dev/null || echo "")
34+
if [ -z "$CHANGED" ]; then
35+
echo "skills=[]" >> "$GITHUB_OUTPUT"
36+
echo "has_skills=false" >> "$GITHUB_OUTPUT"
37+
exit 0
38+
fi
39+
40+
SKILLS=()
41+
SEEN=()
42+
while IFS= read -r file; do
43+
dir=$(echo "$file" | cut -d'/' -f1-2)
44+
case "$dir" in
45+
.github/*|.claude/*|.codex/*|.gemini/*|docs/*|scripts/*|commands/*|standards/*|eval-workspace/*|medium/*) continue ;;
46+
esac
47+
for candidate in "$dir"; do
48+
if [ -f "$candidate/SKILL.md" ] && [[ ! " ${SEEN[*]} " =~ " $candidate " ]]; then
49+
SKILLS+=("$candidate")
50+
SEEN+=("$candidate")
51+
break
52+
fi
53+
done
54+
done <<< "$CHANGED"
55+
56+
if [ ${#SKILLS[@]} -eq 0 ]; then
57+
echo "skills=[]" >> "$GITHUB_OUTPUT"
58+
echo "has_skills=false" >> "$GITHUB_OUTPUT"
59+
else
60+
JSON="["
61+
for i in "${!SKILLS[@]}"; do
62+
[ $i -gt 0 ] && JSON+=","
63+
JSON+="\"${SKILLS[$i]}\""
64+
done
65+
JSON+="]"
66+
echo "skills=$JSON" >> "$GITHUB_OUTPUT"
67+
echo "has_skills=true" >> "$GITHUB_OUTPUT"
68+
echo "Changed skills: $JSON"
69+
fi
70+
71+
review:
72+
name: Tessl quality review
73+
needs: detect-skills
74+
if: needs.detect-skills.outputs.has_skills == 'true'
75+
runs-on: ubuntu-latest
76+
permissions:
77+
contents: read
78+
pull-requests: write
79+
steps:
80+
- name: Checkout
81+
uses: actions/checkout@v4
82+
83+
- name: Set up Python
84+
uses: actions/setup-python@v5
85+
with:
86+
python-version: '3.11'
87+
88+
- name: Set up Node.js
89+
uses: actions/setup-node@v4
90+
with:
91+
node-version: 20
92+
93+
- name: Install Tessl CLI
94+
run: npm install -g tessl
95+
96+
- name: Review changed skills
97+
id: review
98+
run: |
99+
SKILLS='${{ needs.detect-skills.outputs.skills }}'
100+
REPORT_FILE=$(mktemp)
101+
OVERALL_EXIT=0
102+
THRESHOLD=70
103+
104+
echo "## 📊 Skill Quality Review (Tessl)" > "$REPORT_FILE"
105+
echo "" >> "$REPORT_FILE"
106+
echo "| Skill | Score | Description | Content | Verdict |" >> "$REPORT_FILE"
107+
echo "|-------|-------|-------------|---------|---------|" >> "$REPORT_FILE"
108+
109+
DETAILS_FILE=$(mktemp)
110+
111+
for skill_dir in $(echo "$SKILLS" | python3 -c "import sys,json; [print(s) for s in json.load(sys.stdin)]"); do
112+
echo "::group::Reviewing $skill_dir"
113+
114+
JSON_OUT=$(tessl skill review "$skill_dir" --json 2>&1) && EXIT_CODE=$? || EXIT_CODE=$?
115+
116+
# Parse results
117+
PARSED=$(echo "$JSON_OUT" | python3 -c "
118+
import sys, json
119+
try:
120+
d = json.load(sys.stdin)
121+
score = d.get('review', {}).get('reviewScore', 0)
122+
ds = round(d.get('descriptionJudge', {}).get('normalizedScore', 0) * 100)
123+
cs = round(d.get('contentJudge', {}).get('normalizedScore', 0) * 100)
124+
passed = d.get('validation', {}).get('overallPassed', False)
125+
name = d.get('validation', {}).get('skillName', 'unknown')
126+
127+
# Collect suggestions
128+
desc_suggestions = d.get('descriptionJudge', {}).get('evaluation', {}).get('suggestions', [])
129+
content_suggestions = d.get('contentJudge', {}).get('evaluation', {}).get('suggestions', [])
130+
131+
suggestions = []
132+
for s in desc_suggestions:
133+
suggestions.append(f'[description] {s}')
134+
for s in content_suggestions:
135+
suggestions.append(f'[content] {s}')
136+
137+
print(f'{name}|{score}|{ds}|{cs}|{\"PASS\" if passed else \"FAIL\"}|{json.dumps(suggestions)}')
138+
except Exception as e:
139+
print(f'unknown|0|0|0|ERROR|[]')
140+
" 2>/dev/null || echo "unknown|0|0|0|ERROR|[]")
141+
142+
IFS='|' read -r NAME SCORE DS CS VSTATUS SUGGESTIONS <<< "$PARSED"
143+
144+
# Determine verdict
145+
if [ "$SCORE" -ge "$THRESHOLD" ]; then
146+
ICON="✅"
147+
VERDICT="PASS"
148+
else
149+
ICON="⚠️"
150+
VERDICT="NEEDS WORK"
151+
OVERALL_EXIT=1
152+
fi
153+
154+
echo "| \`$skill_dir\` | **${SCORE}/100** ${ICON} | ${DS}% | ${CS}% | ${VERDICT} |" >> "$REPORT_FILE"
155+
156+
# Add suggestions as details
157+
SUGG_COUNT=$(echo "$SUGGESTIONS" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.readline())))" 2>/dev/null || echo "0")
158+
if [ "$SUGG_COUNT" -gt 0 ]; then
159+
echo "" >> "$DETAILS_FILE"
160+
echo "### \`$skill_dir\` — ${SCORE}/100" >> "$DETAILS_FILE"
161+
echo "" >> "$DETAILS_FILE"
162+
echo "$SUGGESTIONS" | python3 -c "
163+
import sys, json
164+
suggestions = json.loads(sys.stdin.readline())
165+
for s in suggestions:
166+
print(f'- {s}')
167+
" >> "$DETAILS_FILE"
168+
fi
169+
170+
echo "::endgroup::"
171+
done
172+
173+
# Add details section
174+
DETAILS_CONTENT=$(cat "$DETAILS_FILE")
175+
if [ -n "$DETAILS_CONTENT" ]; then
176+
echo "" >> "$REPORT_FILE"
177+
echo "<details><summary>Improvement suggestions</summary>" >> "$REPORT_FILE"
178+
echo "" >> "$REPORT_FILE"
179+
cat "$DETAILS_FILE" >> "$REPORT_FILE"
180+
echo "" >> "$REPORT_FILE"
181+
echo "</details>" >> "$REPORT_FILE"
182+
fi
183+
184+
echo "" >> "$REPORT_FILE"
185+
echo "_Threshold: ${THRESHOLD}/100 — skills below this score need improvement before merge._" >> "$REPORT_FILE"
186+
187+
echo "report_file=$REPORT_FILE" >> "$GITHUB_OUTPUT"
188+
echo "exit_code=$OVERALL_EXIT" >> "$GITHUB_OUTPUT"
189+
190+
- name: Run internal validators
191+
id: internal
192+
run: |
193+
SKILLS='${{ needs.detect-skills.outputs.skills }}'
194+
INTERNAL_REPORT=$(mktemp)
195+
INTERNAL_EXIT=0
196+
197+
echo "" >> "$INTERNAL_REPORT"
198+
echo "## 🔧 Internal Validation" >> "$INTERNAL_REPORT"
199+
echo "" >> "$INTERNAL_REPORT"
200+
201+
for skill_dir in $(echo "$SKILLS" | python3 -c "import sys,json; [print(s) for s in json.load(sys.stdin)]"); do
202+
# Structure validation
203+
STRUCT=$(python3 engineering/skill-tester/scripts/skill_validator.py "$skill_dir" --json 2>&1 | python3 -c "
204+
import sys, json
205+
try:
206+
d = json.load(sys.stdin)
207+
print(f'{d[\"overall_score\"]}|{d[\"compliance_level\"]}')
208+
except:
209+
print('0|ERROR')
210+
" 2>/dev/null || echo "0|ERROR")
211+
IFS='|' read -r SSCORE SLEVEL <<< "$STRUCT"
212+
213+
# Script testing (if scripts exist)
214+
SCRIPT_STATUS="N/A"
215+
if [ -d "$skill_dir/scripts" ] && ls "$skill_dir/scripts/"*.py >/dev/null 2>&1; then
216+
STEST=$(python3 engineering/skill-tester/scripts/script_tester.py "$skill_dir" --json 2>&1 | python3 -c "
217+
import sys, json
218+
text = sys.stdin.read()
219+
try:
220+
start = text.index('{')
221+
d = json.loads(text[start:])
222+
print(f'{d[\"summary\"][\"passed\"]}/{d[\"summary\"][\"total_scripts\"]} PASS')
223+
except:
224+
print('ERROR')
225+
" 2>/dev/null || echo "ERROR")
226+
SCRIPT_STATUS="$STEST"
227+
fi
228+
229+
# Security audit
230+
SEC=$(python3 engineering/skill-security-auditor/scripts/skill_security_auditor.py "$skill_dir" --strict --json 2>&1 | python3 -c "
231+
import sys, json
232+
try:
233+
d = json.load(sys.stdin)
234+
print(f'{d[\"verdict\"]}')
235+
except:
236+
print('ERROR')
237+
" 2>/dev/null || echo "ERROR")
238+
239+
if [ "$SEC" = "FAIL" ]; then
240+
INTERNAL_EXIT=1
241+
fi
242+
243+
echo "- \`$skill_dir\`: structure ${SSCORE}/100 (${SLEVEL}), scripts ${SCRIPT_STATUS}, security ${SEC}" >> "$INTERNAL_REPORT"
244+
done
245+
246+
echo "internal_report=$INTERNAL_REPORT" >> "$GITHUB_OUTPUT"
247+
echo "internal_exit=$INTERNAL_EXIT" >> "$GITHUB_OUTPUT"
248+
249+
- name: Post review as PR comment
250+
if: always()
251+
uses: actions/github-script@v7
252+
with:
253+
script: |
254+
const fs = require('fs');
255+
let body = '';
256+
try {
257+
body += fs.readFileSync('${{ steps.review.outputs.report_file }}', 'utf8');
258+
} catch (e) {
259+
body += '## 📊 Skill Quality Review\n\nNo Tessl report generated.\n';
260+
}
261+
try {
262+
body += '\n' + fs.readFileSync('${{ steps.internal.outputs.internal_report }}', 'utf8');
263+
} catch (e) {}
264+
265+
const { data: comments } = await github.rest.issues.listComments({
266+
owner: context.repo.owner,
267+
repo: context.repo.repo,
268+
issue_number: context.issue.number,
269+
});
270+
const marker = '## 📊 Skill Quality Review';
271+
const existing = comments.find(c => c.body.includes(marker));
272+
273+
if (existing) {
274+
await github.rest.issues.updateComment({
275+
owner: context.repo.owner,
276+
repo: context.repo.repo,
277+
comment_id: existing.id,
278+
body: body,
279+
});
280+
} else {
281+
await github.rest.issues.createComment({
282+
owner: context.repo.owner,
283+
repo: context.repo.repo,
284+
issue_number: context.issue.number,
285+
body: body,
286+
});
287+
}
288+
289+
- name: Fail on low quality or security issues
290+
if: steps.review.outputs.exit_code == '1' || steps.internal.outputs.internal_exit == '1'
291+
run: |
292+
if [ "${{ steps.internal.outputs.internal_exit }}" = "1" ]; then
293+
echo "::error::Security audit found CRITICAL/HIGH findings. Merge blocked."
294+
fi
295+
if [ "${{ steps.review.outputs.exit_code }}" = "1" ]; then
296+
echo "::error::Tessl quality review below threshold (70/100). Improve skill before merge."
297+
fi
298+
exit 1

.github/workflows/skill-security-audit.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,17 @@ jobs:
191191
echo "report_file=$REPORT_FILE" >> "$GITHUB_OUTPUT"
192192
echo "exit_code=$OVERALL_EXIT" >> "$GITHUB_OUTPUT"
193193
194+
- name: Post audit results to job summary
195+
if: always()
196+
run: |
197+
REPORT_FILE="${{ steps.audit.outputs.report_file }}"
198+
if [ -f "$REPORT_FILE" ]; then
199+
cat "$REPORT_FILE" >> "$GITHUB_STEP_SUMMARY"
200+
fi
201+
194202
- name: Post audit results as PR comment
195203
if: always()
204+
continue-on-error: true # Fork PRs have read-only GITHUB_TOKEN
196205
uses: actions/github-script@v7
197206
with:
198207
script: |

0 commit comments

Comments
 (0)