From fe5a6005bf05dd32b246639918f68a731e357202 Mon Sep 17 00:00:00 2001 From: valerii Date: Fri, 27 Feb 2026 12:46:59 +0100 Subject: [PATCH 1/3] Fix AI review parsing and follow-up SHA detection --- .github/scripts/multi_agent_review.py | 107 ++++++++++++++++++-------- .github/workflows/review.yml | 4 +- 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/.github/scripts/multi_agent_review.py b/.github/scripts/multi_agent_review.py index 62a969b..4546269 100644 --- a/.github/scripts/multi_agent_review.py +++ b/.github/scripts/multi_agent_review.py @@ -112,27 +112,34 @@ def sanitize_jsonish(s: str) -> str: return s -def extract_first_json_object(s: str) -> Optional[str]: +def strip_json_fence(s: str) -> str: if not s: - return None + return s stripped = s.strip() + if not stripped.startswith("```"): + return stripped - # Strip a leading markdown fence if present - if stripped.startswith("```"): - lines = stripped.splitlines() - if lines: - # Check for language identifier (json, jsonl, etc.) - first_line = lines[0].lower() - if "json" in first_line or first_line.strip() == "```": - lines = lines[1:] # drop first fence line - else: - # Not a JSON code block, might be text - pass - - if lines and lines[-1].strip().startswith("```"): - lines = lines[:-1] - stripped = "\n".join(lines).strip() + lines = stripped.splitlines() + if not lines: + return stripped + + # Only strip fences when the block is generic or json-like. + first_line = lines[0].strip().lower() + if first_line != "```" and "json" not in first_line: + return stripped + + body = lines[1:] + if body and body[-1].strip().startswith("```"): + body = body[:-1] + return "\n".join(body).strip() + + +def extract_first_json_object(s: str) -> Optional[str]: + if not s: + return None + + stripped = strip_json_fence(s) # Try to find JSON object start = stripped.find("{") @@ -140,14 +147,33 @@ def extract_first_json_object(s: str) -> Optional[str]: return None depth = 0 + in_string = False + escaped = False for i in range(start, len(stripped)): ch = stripped[i] + if in_string: + if escaped: + escaped = False + continue + if ch == "\\": + escaped = True + continue + if ch == '"': + in_string = False + continue + + if ch == '"': + in_string = True + continue if ch == "{": depth += 1 - elif ch == "}": + continue + if ch == "}": depth -= 1 if depth == 0: return stripped[start:i + 1] + if depth < 0: + return None return None @@ -158,18 +184,22 @@ def safe_json_loads(s: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: try: s = sanitize_jsonish(s) - js = extract_first_json_object(s) + clean = strip_json_fence(s) + + # Fast path: whole payload is a JSON object. + try: + parsed = json.loads(clean) + if not isinstance(parsed, dict): + return None, f"JSON is not an object (got {type(parsed).__name__})" + return parsed, None + except json.JSONDecodeError: + pass + except Exception: + pass + + js = extract_first_json_object(clean) if not js: - # Try parsing the whole string as JSON (maybe it's already clean) - try: - parsed = json.loads(s.strip()) - if not isinstance(parsed, dict): - return None, f"JSON is not an object (got {type(parsed).__name__})" - return parsed, None - except json.JSONDecodeError: - return None, "No JSON object found in response" - except Exception: - return None, "No JSON object found in response" + return None, "No JSON object found in response" js = sanitize_jsonish(js) parsed = json.loads(js) if not isinstance(parsed, dict): @@ -252,15 +282,24 @@ def must_mention_file(line: str, allowed_files: List[str]) -> bool: def mentions_only_allowed_files(line: str, allowed_files: List[str]) -> bool: """ - Conservative: if line mentions a path with a known extension, + Conservative: if line mentions a file-like path, ensure every mentioned path is within allowed_files. """ import re - paths = re.findall(r"[\w./-]+\.(?:py|yml|yaml|kt|java|md)", line or "") + + paths = re.findall( + r"(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+\.[A-Za-z][A-Za-z0-9]{0,7}", + line or "", + ) if not paths: return True - allowed_set = set(allowed_files) - return all(p in allowed_set for p in paths) + + allowed_set = {f.lower() for f in allowed_files} + for path in paths: + normalized = path.strip("`'\"()[]{}<>,:;!?").rstrip(".").lower() + if normalized and normalized not in allowed_set: + return False + return True def diff_facts(diff: str, allowed_files: List[str], max_lines: int = 60) -> str: @@ -324,6 +363,8 @@ def filter_bullets_by_fact_gate(items: List[str], diff_lower: str, allowed_files continue if allowed_files and not mentions_only_allowed_files(s, allowed_files): continue + if STRICT_FACT_GATING and not gate_claims_to_diff(s, diff_lower): + continue out.append(s) break diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 92103af..f26b590 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -29,7 +29,9 @@ jobs: run: | $pr = "${{ github.event.pull_request.number }}" $comments = gh api repos/${{ github.repository }}/issues/$pr/comments | ConvertFrom-Json - $ai = $comments | Where-Object { $_.body -match "Reviewed-Head-SHA:" } | Select-Object -Last 1 + $ai = $comments | Where-Object { + $_.body -match "AI_REVIEW:HEAD_SHA=" -or $_.body -match "Reviewed-Head-SHA:" + } | Select-Object -Last 1 if ($null -ne $ai) { $m = [regex]::Match($ai.body, "AI_REVIEW:HEAD_SHA=([0-9a-fA-F]{7,40})") if ($m.Success) { From bd65b26191c7628a6f4ccd7556b4e38753014c09 Mon Sep 17 00:00:00 2001 From: valerii Date: Fri, 27 Feb 2026 12:55:19 +0100 Subject: [PATCH 2/3] Fix PowerShell-safe incremental diff generation --- .github/workflows/review.yml | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index f26b590..c022e68 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -47,19 +47,39 @@ jobs: run: | git fetch origin ${{ github.base_ref }} --depth=1 + $baseRange = "origin/${{ github.base_ref }}...HEAD" + $diffBuilt = $false + if ($env:PREV_SHA) { # Validate that PREV_SHA exists in this checkout (force-push can break it) - git cat-file -e "$env:PREV_SHA^{commit}" 2>$null + git cat-file -e "$($env:PREV_SHA)^{commit}" 2>$null if ($LASTEXITCODE -eq 0) { - git diff --unified=3 $env:PREV_SHA...HEAD > diff.txt - Write-Host "Using incremental diff: $env:PREV_SHA...HEAD" + # Ensure PREV_SHA is still in the current branch history. + git merge-base --is-ancestor "$env:PREV_SHA" HEAD 2>$null + if ($LASTEXITCODE -eq 0) { + $incrementalRange = "$($env:PREV_SHA)...HEAD" + git diff --unified=3 "$incrementalRange" > diff.txt + if ($LASTEXITCODE -eq 0) { + Write-Host "Using incremental diff: $incrementalRange" + $diffBuilt = $true + } else { + Write-Host "Incremental diff command failed. Falling back to full diff." + } + } else { + Write-Host "PREV_SHA is not an ancestor of HEAD. Falling back to full diff." + } } else { Write-Host "PREV_SHA not found locally (maybe force-push). Falling back to full diff." - git diff --unified=3 origin/${{ github.base_ref }}...HEAD > diff.txt } - } else { - git diff --unified=3 origin/${{ github.base_ref }}...HEAD > diff.txt - Write-Host "Using full PR diff against base." + } + + if (-not $diffBuilt) { + git diff --unified=3 "$baseRange" > diff.txt + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to build diff for range: $baseRange" + exit 1 + } + Write-Host "Using full PR diff against base: $baseRange" } Get-Content diff.txt | Measure-Object -Line -Word -Character | Format-List From ab01d4417ffce646e5aca5c2f3472c7d092e5e6e Mon Sep 17 00:00:00 2001 From: valerii Date: Fri, 27 Feb 2026 12:56:07 +0100 Subject: [PATCH 3/3] Update review script bytecode cache --- .../multi_agent_review.cpython-313.pyc | Bin 39947 -> 40799 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/scripts/__pycache__/multi_agent_review.cpython-313.pyc b/.github/scripts/__pycache__/multi_agent_review.cpython-313.pyc index 85aa8fc56d66fd68069017a147030a4099d7753a..f74c117f706ead54a2e37f4e382a18d3609240ac 100644 GIT binary patch delta 6152 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 RXW~7ie3%pX$;?jG{{ROK-x>e_ delta 5552 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