diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dad790f..9dbd564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,12 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 +permissions: + contents: read + security-events: write + pull-requests: write + id-token: write + jobs: check: name: Check @@ -58,3 +64,160 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo build --release + + cora-review: + name: Cora Review + runs-on: ubuntu-latest + needs: [check, fmt, clippy, test] + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Fetch LLM secrets from Infisical + uses: Infisical/secrets-action@v1.0.9 + with: + method: "oidc" + identity-id: "6bd2b8d8-a9a3-4331-8b37-bf2764fc320b" + project-slug: "github-actions" + env-slug: "prod" + domain: "https://infisical.ajianaz.dev" + + - name: Run cora review + run: | + cargo build --release + echo "=== CORA REVIEW (SARIF) ===" >> $GITHUB_STEP_SUMMARY + ./target/release/cora review \ + --base origin/develop \ + --format sarif \ + --severity major \ + --quiet \ + > cora-results.sarif 2>&1 || true + + echo "### ๐Ÿ” Cora AI Code Review" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -s cora-results.sarif ]; then + echo '```json' >> $GITHUB_STEP_SUMMARY + cat cora-results.sarif >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + else + echo "No issues found." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload SARIF to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: cora-results.sarif + category: cora-review + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let sarifContent; + try { + sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); + } catch (e) { + sarifContent = null; + } + + let body; + if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { + body = `## ๐Ÿ” Cora AI Code Review\n\nโœ… **No issues found.** Code looks good!`; + } else { + const results = sarifContent.runs[0].results || []; + if (results.length === 0) { + body = `## ๐Ÿ” Cora AI Code Review\n\nโœ… **No issues found.** Code looks good!`; + } else { + // Group by severity + const grouped = {}; + for (const r of results) { + const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); + if (!grouped[sev]) grouped[sev] = []; + grouped[sev].push(r); + } + + const severityOrder = ['Error', 'Warning', 'Note']; + let table = ''; + + for (const sev of severityOrder) { + if (!grouped[sev]) continue; + const icon = sev === 'Error' ? '๐Ÿ”ด' : sev === 'Warning' ? '๐ŸŸก' : '๐Ÿ”ต'; + table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; + for (const r of grouped[sev]) { + const loc = r.locations?.[0]?.physicalLocation; + const file = loc?.artifactLocation?.uri || 'unknown'; + const line = loc?.region?.startLine || '?'; + const msg = r.message?.text || r.message?.markdown || 'No message'; + table += `- \`${file}:${line}\` โ€” ${msg}\n`; + } + } + + const hasErrors = (grouped['Error'] || []).length > 0; + const hasWarnings = (grouped['Warning'] || []).length > 0; + const status = hasErrors ? 'โŒ **Blocked** โ€” critical issues found.' : + hasWarnings ? 'โš ๏ธ **Review recommended** โ€” warnings found.' : + 'โœ… **Passed** โ€” only informational notes.'; + + body = `## ๐Ÿ” Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/ajianaz/cora-cli) ยท BYOK ยท MIT_`; + } + } + + // Find existing cora comment and update it, or create new + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## ๐Ÿ” Cora AI Code Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Check for blocking issues + if: always() + run: | + if [ -f cora-results.sarif ]; then + # Check for error-level results in SARIF + ERRORS=$(cat cora-results.sarif | python3 -c " + import json, sys + try: + data = json.load(sys.stdin) + results = data.get('runs', [{}])[0].get('results', []) + errors = [r for r in results if r.get('level') in ('error', 'error')] + print(len(errors)) + except: + print(0) + " 2>/dev/null || echo "0") + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Cora found $ERRORS blocking issue(s). Review the Code Scanning results." + exit 1 + fi + fi + echo "No blocking issues." diff --git a/src/formatters/sarif.rs b/src/formatters/sarif.rs index de5a7e6..5e08451 100644 --- a/src/formatters/sarif.rs +++ b/src/formatters/sarif.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use chrono::Utc; use serde_json::{Value, json}; use crate::engine::{ReviewIssue, ReviewResponse, ScanResponse, Severity}; @@ -27,24 +26,33 @@ impl Formatter for SarifFormatter { /// Build a SARIF v2.1.0 document from a list of issues. fn build_sarif(issues: &[ReviewIssue]) -> Value { - let rules: Vec = issues - .iter() - .map(|issue| { - json!({ - "id": issue.issue_type.as_ref().map(|t| t.to_string()).unwrap_or_else(|| "unknown".to_string()), - "shortDescription": { - "text": issue.title.clone() - }, - "fullDescription": { - "text": issue.body.clone() - }, - "helpUri": null, - "defaultConfiguration": { - "level": severity_to_sarif_level(&issue.severity) - } - }) - }) - .collect(); + // Deduplicate rules by id (multiple issues can share same rule) + let mut rule_map = serde_json::Map::new(); + for issue in issues { + let rule_id = issue + .issue_type + .as_ref() + .map(|t| t.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + if !rule_map.contains_key(&rule_id) { + rule_map.insert( + rule_id.clone(), + json!({ + "id": rule_id, + "shortDescription": { + "text": issue.title.clone() + }, + "fullDescription": { + "text": issue.body.clone() + }, + "defaultConfiguration": { + "level": severity_to_sarif_level(&issue.severity) + } + }), + ); + } + } + let rules: Vec = rule_map.into_values().collect(); let results: Vec = issues .iter() @@ -73,17 +81,23 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { // Add fix suggestion if available if let Some(ref fix) = issue.suggested_fix { - let mut replacements = Vec::new(); - replacements.push(json!({ - "description": { - "text": "Suggested fix" - } - // We don't have a full replacement spec, but we can include the text - })); result["fixes"] = json!([{ "description": { "text": fix.clone() - } + }, + "artifactChanges": [{ + "artifactLocation": { + "uri": issue.file.clone() + }, + "replacements": [{ + "deletedRegion": { + "startLine": issue.line.unwrap_or(1) + }, + "insertedContent": { + "text": fix.clone() + } + }] + }] }]); } @@ -103,12 +117,7 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { "rules": rules } }, - "results": results, - "invocation": { - "executionSuccessful": true, - "startTimeUtc": Utc::now().to_rfc3339(), - "endTimeUtc": Utc::now().to_rfc3339() - } + "results": results }] }) }