- Apply cargo fmt to src-tauri/src/commands/kube.rs (CI was failing) - Update pr-review.yml to use qwen3-coder-next model via liteLLM - Add TICKET-kube-ui-feature-parity.md gap analysis for FreeLens parity Co-Authored-By: TFTSR Engineering <noreply@tftsr.com>
385 lines
18 KiB
YAML
385 lines
18 KiB
YAML
name: PR Review Automation
|
|
|
|
on:
|
|
pull_request:
|
|
types: [opened, synchronize, reopened, edited]
|
|
|
|
|
|
jobs:
|
|
review:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
pull-requests: write
|
|
container:
|
|
image: ubuntu:22.04
|
|
options: --dns 8.8.8.8 --dns 1.1.1.1
|
|
steps:
|
|
- name: Install dependencies
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
apt-get update -qq && apt-get install -y -qq git curl jq python3
|
|
|
|
- name: Checkout code
|
|
shell: bash
|
|
env:
|
|
REPOSITORY: ${{ github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
git init
|
|
git remote add origin "https://gogs.tftsr.com/${REPOSITORY}.git"
|
|
git fetch --depth=1 origin ${{ github.head_ref }}
|
|
git checkout FETCH_HEAD
|
|
|
|
- name: Build review context
|
|
id: context
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
git fetch origin ${{ github.base_ref }}
|
|
|
|
# List changed source files (exclude generated/lock files)
|
|
git diff --name-only origin/${{ github.base_ref }}..HEAD \
|
|
-- ':!Cargo.lock' ':!package-lock.json' ':!*.lock' \
|
|
> /tmp/pr_files.txt
|
|
|
|
FILE_COUNT=$(wc -l < /tmp/pr_files.txt | tr -d ' ')
|
|
echo "files_changed=${FILE_COUNT}" >> $GITHUB_OUTPUT
|
|
|
|
if [ "$FILE_COUNT" -eq 0 ]; then
|
|
echo "No reviewable files changed."
|
|
echo "diff_size=0" >> $GITHUB_OUTPUT
|
|
exit 0
|
|
fi
|
|
|
|
# Build context: full file content for each changed file.
|
|
# Files <= 500 lines: include complete content.
|
|
# Files > 500 lines: include the per-file diff with generous context (±50 lines).
|
|
#
|
|
# Secret scrubbing: match actual credential VALUES only — known API key formats,
|
|
# or keyword="long_quoted_literal" (25+ chars). Never scrub on keyword alone,
|
|
# which would silently delete function signatures, variable declarations, and tests.
|
|
SECRET_PATTERN='AKIA[A-Z0-9]{16}|gh[opsu]_[A-Za-z0-9_]{36,}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}|(password|token|api_key|secret)[[:space:]]*=[[:space:]]*["'"'"'][A-Za-z0-9+/_!@#-]{25,}["'"'"']'
|
|
# Only strip lines that are ENTIRELY a long base64 blob (e.g. PEM cert bodies)
|
|
B64_PATTERN='^[[:space:]]*[A-Za-z0-9+/]{60,}={0,2}[[:space:]]*$'
|
|
|
|
> /tmp/pr_context.txt
|
|
while IFS= read -r file; do
|
|
[ -f "$file" ] || continue
|
|
lines=$(wc -l < "$file" | tr -d ' ')
|
|
printf '\n════════ FILE: %s (%s lines) ════════\n' "$file" "$lines" >> /tmp/pr_context.txt
|
|
if [ "$lines" -le 500 ]; then
|
|
# Full file — model sees the complete implementation
|
|
grep -v -E "$SECRET_PATTERN" "$file" \
|
|
| grep -v -E "$B64_PATTERN" \
|
|
>> /tmp/pr_context.txt || true
|
|
else
|
|
# Large file — emit annotated diff hunks (±50 lines of context each)
|
|
printf '[File too large for full view (%s lines) — showing changed sections only]\n' "$lines" >> /tmp/pr_context.txt
|
|
git diff -U50 origin/${{ github.base_ref }}..HEAD -- "$file" \
|
|
| grep -v -E "$SECRET_PATTERN" \
|
|
| grep -v -E "$B64_PATTERN" \
|
|
>> /tmp/pr_context.txt || true
|
|
fi
|
|
done < /tmp/pr_files.txt
|
|
|
|
TOTAL=$(wc -l < /tmp/pr_context.txt | tr -d ' ')
|
|
echo "diff_size=${TOTAL}" >> $GITHUB_OUTPUT
|
|
|
|
# Cap at 6000 lines so we stay within the model's context window
|
|
if [ "$TOTAL" -gt 6000 ]; then
|
|
head -n 6000 /tmp/pr_context.txt > /tmp/pr_context_capped.txt
|
|
mv /tmp/pr_context_capped.txt /tmp/pr_context.txt
|
|
echo "[CONTEXT TRUNCATED at 6000 lines — ${TOTAL} total]" >> /tmp/pr_context.txt
|
|
fi
|
|
|
|
- name: Build codebase index
|
|
id: index
|
|
if: steps.context.outputs.diff_size != '0'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
# Build a compact index of everything that EXISTS in this codebase.
|
|
# Included in the prompt so the model cannot invent functions/commands/tables
|
|
# that are not present — any finding referencing something absent from this
|
|
# index is immediately suspect.
|
|
{
|
|
echo "## CODEBASE INDEX"
|
|
echo "These are the ONLY Tauri commands, TypeScript exports, Rust public functions,"
|
|
echo "and database tables that exist in this project. Before raising any finding,"
|
|
echo "confirm that every symbol you cite appears in this list or in the file"
|
|
echo "contents below. If it does not appear in either, your finding is fabricated."
|
|
echo ""
|
|
|
|
echo "### Registered Tauri commands (lib.rs generate_handler![]):"
|
|
grep -oE 'commands::[a-z_]+::[a-z_]+' src-tauri/src/lib.rs 2>/dev/null \
|
|
| sort -u | sed 's/^/ /' || true
|
|
echo ""
|
|
|
|
echo "### TypeScript invoke wrappers (src/lib/tauriCommands.ts):"
|
|
grep -E '^export (const|interface|type) ' src/lib/tauriCommands.ts 2>/dev/null \
|
|
| sed 's/^/ /' || true
|
|
echo ""
|
|
|
|
echo "### Public Rust functions in src-tauri/src/commands/:"
|
|
grep -rh --include='*.rs' '^pub ' src-tauri/src/commands/ 2>/dev/null \
|
|
| grep 'fn ' | sed 's/^/ /' | sort || true
|
|
echo ""
|
|
|
|
echo "### Database tables (src-tauri/src/db/migrations.rs):"
|
|
grep -oE '"[0-9]+_[a-z_]+"' src-tauri/src/db/migrations.rs 2>/dev/null \
|
|
| tr -d '"' | sed 's/^/ /' || true
|
|
echo ""
|
|
} > /tmp/codebase_index.txt
|
|
|
|
INDEX_LINES=$(wc -l < /tmp/codebase_index.txt | tr -d ' ')
|
|
echo "index_lines=${INDEX_LINES}" >> $GITHUB_OUTPUT
|
|
echo "Built codebase index: ${INDEX_LINES} lines"
|
|
|
|
- name: Fetch PR comment history
|
|
id: pr_history
|
|
if: steps.context.outputs.diff_size != '0'
|
|
shell: bash
|
|
env:
|
|
TF_TOKEN: ${{ secrets.TFT_GITEA_TOKEN }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
REPOSITORY: ${{ github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
> /tmp/pr_comments.txt
|
|
|
|
# Fetch automated review posts (what this action posts each round)
|
|
REVIEWS=$(curl -sf --max-time 30 --connect-timeout 10 \
|
|
"https://gogs.tftsr.com/api/v1/repos/${REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
|
-H "Authorization: Bearer $TF_TOKEN" || echo '[]')
|
|
|
|
# Fetch regular PR/issue comments (human responses, rebuttals, etc.)
|
|
COMMENTS=$(curl -sf --max-time 30 --connect-timeout 10 \
|
|
"https://gogs.tftsr.com/api/v1/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
|
-H "Authorization: Bearer $TF_TOKEN" || echo '[]')
|
|
|
|
{
|
|
printf '%s\n\n' '## PREVIOUS REVIEW ROUNDS'
|
|
printf '%s\n\n' '### Automated review posts (oldest first):'
|
|
echo "$REVIEWS" \
|
|
| jq -r '.[] | "#### Review by \(.user.login) [state: \(.state // "COMMENT")]:\n\(.body)\n---"' \
|
|
2>/dev/null || true
|
|
|
|
printf '\n%s\n\n' '### PR comments (oldest first):'
|
|
echo "$COMMENTS" \
|
|
| jq -r '.[] | "#### Comment by \(.user.login):\n\(.body)\n---"' \
|
|
2>/dev/null || true
|
|
} >> /tmp/pr_comments.txt
|
|
|
|
LINES=$(wc -l < /tmp/pr_comments.txt | tr -d ' ')
|
|
echo "comment_lines=${LINES}" >> $GITHUB_OUTPUT
|
|
echo "Fetched PR history: ${LINES} lines"
|
|
|
|
- name: Analyze with LLM
|
|
id: analyze
|
|
if: steps.context.outputs.diff_size != '0'
|
|
shell: bash
|
|
env:
|
|
LITELLM_URL: http://172.0.0.29:11434/v1
|
|
LITELLM_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
|
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
run: |
|
|
set -euo pipefail
|
|
CHANGED_FILES=$(tr '\n' ' ' < /tmp/pr_files.txt)
|
|
|
|
# Build prompt file. Use 'printf "%s\n" text' throughout so the format
|
|
# string is always "%s\n" and content with leading hyphens or embedded
|
|
# double-dashes is never misinterpreted as a printf option flag.
|
|
{
|
|
printf '%s\n\n' 'You are a senior engineer performing a code review.'
|
|
printf 'PR Title: %s\n' "$PR_TITLE"
|
|
printf 'Files changed: %s\n\n' "$CHANGED_FILES"
|
|
printf '%s\n' '---'
|
|
cat /tmp/codebase_index.txt
|
|
printf '%s\n\n' '---'
|
|
printf '%s\n\n' '## Changed file contents'
|
|
printf '%s\n' 'Each section is the COMPLETE, FINAL file after PR changes (not a diff).'
|
|
printf '%s\n\n' 'Files over 500 lines show only changed sections with surrounding context.'
|
|
printf '%s\n' '---'
|
|
cat /tmp/pr_context.txt
|
|
printf '%s\n\n' '---'
|
|
if [ -s /tmp/pr_comments.txt ]; then
|
|
cat /tmp/pr_comments.txt
|
|
printf '%s\n\n' '---'
|
|
printf '%s\n' '## CRITICAL: Prior review context above'
|
|
printf '%s\n' 'Before raising ANY finding, check the review history above.'
|
|
printf '%s\n' 'SILENTLY DISCARD any finding that has already been:'
|
|
printf '%s\n' ' - Marked as invalid or incorrect by a reviewer'
|
|
printf '%s\n' ' - Acknowledged as an intentional design decision or known limitation'
|
|
printf '%s\n\n' ' - Confirmed fixed in a prior commit'
|
|
printf '%s\n\n' 'Raising a previously-refuted finding is a critical error.'
|
|
printf '%s\n' '---'
|
|
fi
|
|
printf '%s\n\n' '## Instructions'
|
|
printf '%s\n' 'Before raising any finding:'
|
|
printf '%s\n' '1. Confirm every symbol you cite exists in the CODEBASE INDEX or file'
|
|
printf '%s\n' ' contents above. If absent from both, discard the finding.'
|
|
printf '%s\n' '2. Quote the exact line(s) from the file contents that support it.'
|
|
printf '%s\n' '3. Confirm the issue is genuine, not intentional design.'
|
|
printf '%s\n\n' '4. If any step fails, discard silently - do not mention it.'
|
|
printf '%s\n\n' 'Do NOT show reasoning. Only output confirmed issues.'
|
|
printf '%s\n' 'Severity:'
|
|
printf '%s\n' '- BLOCKER: fails to compile, corrupts data, or security vulnerability'
|
|
printf '%s\n' '- WARNING: real risk to address before merge'
|
|
printf '%s\n\n' '- SUGGESTION: minor improvement, follow-up PR fine'
|
|
printf '%s\n\n' 'Focus: security bugs, logic errors, data loss, injection, unhandled errors.'
|
|
printf '%s\n\n' 'Ignore: style, missing comments, speculative future concerns.'
|
|
printf '%s\n\n' '## Output format (strict)'
|
|
printf '%s\n\n' '**Summary** (2-3 sentences)'
|
|
printf '%s\n' '**Findings**'
|
|
printf '%s\n' '- [SEVERITY] file:line - description'
|
|
printf '%s\n' ' Evidence: quoted line'
|
|
printf '%s\n\n' ' Fix: concrete change'
|
|
printf '%s\n\n' '(Write "No findings." if none.)'
|
|
printf '%s\n' '**Verdict**: APPROVE / APPROVE WITH COMMENTS / REQUEST CHANGES'
|
|
} > /tmp/prompt.txt
|
|
|
|
# Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX.
|
|
jq -cn \
|
|
--arg model "qwen3-coder-next" \
|
|
--rawfile content /tmp/prompt.txt \
|
|
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
|
|
> /tmp/body.json
|
|
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling liteLLM API ($(wc -c < /tmp/body.json) bytes)..."
|
|
HTTP_CODE=$(curl -s --max-time 300 --connect-timeout 30 \
|
|
--retry 3 --retry-delay 10 --retry-connrefused --retry-max-time 300 \
|
|
-o /tmp/llm_response.json -w "%{http_code}" \
|
|
-X POST "$LITELLM_URL/chat/completions" \
|
|
-H "Authorization: Bearer $LITELLM_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
--data @/tmp/body.json)
|
|
echo "HTTP status: $HTTP_CODE"
|
|
echo "Response file size: $(wc -c < /tmp/llm_response.json) bytes"
|
|
if [ "$HTTP_CODE" != "200" ]; then
|
|
echo "ERROR: liteLLM returned HTTP $HTTP_CODE"
|
|
cat /tmp/llm_response.json
|
|
exit 1
|
|
fi
|
|
if ! jq empty /tmp/llm_response.json 2>/dev/null; then
|
|
echo "ERROR: Invalid JSON response from liteLLM"
|
|
cat /tmp/llm_response.json
|
|
exit 1
|
|
fi
|
|
REVIEW=$(jq -r '.choices[0].message.content // empty' /tmp/llm_response.json)
|
|
if [ -z "$REVIEW" ]; then
|
|
echo "ERROR: No content in liteLLM response"
|
|
exit 1
|
|
fi
|
|
echo "Review length: ${#REVIEW} chars"
|
|
echo "$REVIEW" > /tmp/pr_review.txt
|
|
|
|
- name: Verify findings against codebase
|
|
if: steps.analyze.outcome == 'success'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
# For each finding that contains a fenced code block under "Evidence:",
|
|
# grep at least one substantial line of that block against the FULL repository.
|
|
# Searching the full repo (not just changed files) prevents false UNVERIFIED
|
|
# tags when the model correctly quotes unchanged files, while still flagging
|
|
# fabricated code that doesn't exist anywhere in the codebase.
|
|
python3 - << 'PYEOF'
|
|
import re, os, subprocess
|
|
|
|
review = open('/tmp/pr_review.txt').read()
|
|
|
|
# Load ENTIRE tracked repository (all .rs, .ts, .tsx, .yml, .toml, .json files)
|
|
result = subprocess.run(
|
|
['git', 'ls-files', '--',
|
|
'*.rs', '*.ts', '*.tsx', '*.yml', '*.yaml', '*.toml', '*.json', '*.sql'],
|
|
capture_output=True, text=True
|
|
)
|
|
all_tracked = [f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
|
|
all_content_parts = []
|
|
for path in all_tracked:
|
|
if os.path.isfile(path):
|
|
try:
|
|
all_content_parts.append(open(path).read())
|
|
except Exception:
|
|
pass
|
|
all_content = '\n'.join(all_content_parts)
|
|
|
|
def evidence_exists(block: str) -> bool:
|
|
"""True if ≥1 significant line from the block is found verbatim in changed files."""
|
|
for raw in block.splitlines():
|
|
line = raw.lstrip('+-').strip()
|
|
# Skip blank, very short, pure-comment, or diff-header lines
|
|
if len(line) < 20:
|
|
continue
|
|
if line.startswith(('//','#','/*','*','Fix:','Evidence:','---','+++')):
|
|
continue
|
|
if line in all_content:
|
|
return True
|
|
return False
|
|
|
|
# Split on finding markers; re-join after optional tagging
|
|
severity_re = re.compile(r'\[(BLOCKER|WARNING|SUGGESTION)\]')
|
|
|
|
def tag_if_unverified(finding_text: str) -> str:
|
|
code_match = re.search(r'```[^\n]*\n(.*?)```', finding_text, re.DOTALL)
|
|
if code_match and not evidence_exists(code_match.group(1)):
|
|
# Replace first severity tag with a prefixed version
|
|
return severity_re.sub(
|
|
lambda m: f'[{m.group(1)} — ⚠️ UNVERIFIED: evidence not found in PR files]',
|
|
finding_text, count=1
|
|
)
|
|
return finding_text
|
|
|
|
# Split review into preamble + individual finding blocks
|
|
# Each block starts at a severity marker line
|
|
parts = re.split(r'(?=^\[(?:BLOCKER|WARNING|SUGGESTION)\])', review, flags=re.MULTILINE)
|
|
result = parts[0] # preamble (Summary, etc.)
|
|
for block in parts[1:]:
|
|
result += tag_if_unverified(block)
|
|
|
|
open('/tmp/pr_review.txt', 'w').write(result)
|
|
print(f"Verification complete — {len(parts)-1} finding(s) checked.")
|
|
PYEOF
|
|
|
|
- name: Post review comment
|
|
if: always() && steps.context.outputs.diff_size != '0'
|
|
shell: bash
|
|
env:
|
|
TF_TOKEN: ${{ secrets.TFT_GITEA_TOKEN }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
REPOSITORY: ${{ github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${TF_TOKEN:-}" ]; then
|
|
echo "ERROR: TFT_GITEA_TOKEN secret is not set"
|
|
exit 1
|
|
fi
|
|
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
|
|
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
|
|
BODY=$(jq -n \
|
|
--arg body "Automated PR Review (qwen3-coder-next via liteLLM):\n\n${REVIEW_BODY}" \
|
|
'{body: $body, event: "COMMENT"}')
|
|
else
|
|
BODY=$(jq -n \
|
|
'{body: "Automated PR Review could not be completed - LLM analysis failed or produced no output.", event: "COMMENT"}')
|
|
fi
|
|
HTTP_CODE=$(curl -s --max-time 30 --connect-timeout 10 \
|
|
-o /tmp/review_post_response.json -w "%{http_code}" \
|
|
-X POST "https://gogs.tftsr.com/api/v1/repos/${REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
|
-H "Authorization: Bearer $TF_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$BODY")
|
|
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Post review HTTP status: $HTTP_CODE"
|
|
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
|
|
echo "ERROR: Failed to post review (HTTP $HTTP_CODE)"
|
|
cat /tmp/review_post_response.json
|
|
exit 1
|
|
fi
|
|
|
|
- name: Cleanup
|
|
if: always()
|
|
shell: bash
|
|
run: rm -f /tmp/pr_diff.txt /tmp/pr_context.txt /tmp/codebase_index.txt /tmp/pr_comments.txt /tmp/prompt.txt /tmp/body.json /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json /tmp/pr_files.txt
|