From e379f95b3f310dab57bad2317f03a5a05279db0a Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 26 Jun 2026 12:59:19 +0100 Subject: [PATCH] Restore Codacy analysis and coverage --- .codacy.yml | 41 +++++ .github/workflows/codacy.yml | 26 +++ .github/workflows/codecoverage.yml | 32 ++-- .github/workflows/semgrep.yml | 49 ------ .semgrepignore | 19 --- scripts/semgrep-sync-issues.mjs | 256 ----------------------------- 6 files changed, 81 insertions(+), 342 deletions(-) create mode 100644 .codacy.yml create mode 100644 .github/workflows/codacy.yml delete mode 100644 .github/workflows/semgrep.yml delete mode 100644 .semgrepignore delete mode 100644 scripts/semgrep-sync-issues.mjs diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..913fd99 --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,41 @@ +exclude_paths: + # Ignore the test projects entirely + - EstateManagementUI.BlazorServer.Tests/** + - EstateManagementUI.IntegrationTests/** + + # Common build / IDE artifacts + - bin/** + - obj/** + - .vs/** + - .vscode/** + - .idea/** + - packages/** + + # Node / frontend artifacts + - node_modules/** + - wwwroot/lib/** + + # Generated code and designer files + - "**/*.g.cs" + - "**/*.g.i.cs" + - "**/*.designer.cs" + - "**/*.Generated.cs" + - "**/Generated/**" + + # Coverage and test results + - coverage/** + - coverage.* + - coverage.xml + - lcov.info + - TestResults/** + + # Archives / legacy folders + - Archive/** + - "**/Archive/**" + + # Other artifacts + - "*.user" + - "*.suo" + - "*.db" + - "*.sqlite" + - "*StubTestData.cs" diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml new file mode 100644 index 0000000..6998ec3 --- /dev/null +++ b/.github/workflows/codacy.yml @@ -0,0 +1,26 @@ +name: Codacy Issue Sync + +on: + push: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + codacy-issue-sync: + uses: TransactionProcessing/org-ci-workflows/.github/workflows/sync-codacy-issues.yml@main + secrets: inherit + with: + # Optional: you can omit both and the reusable workflow will default them + # to the calling repo owner/name. Keeping them explicit is fine too. + codacy_org: TransactionProcessing + codacy_repo: EstateManagementUI + + # Optional knobs + dry_run: false + severity_filter: "Error,High,Warning,Info" + extra_labels: "" + assignees: "" diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 6f764a5..deee452 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -5,15 +5,8 @@ on: # branches to consider in the event; optional, defaults to all branches: - main - pull_request: - branches: - - main workflow_dispatch: -permissions: - contents: read - code-quality: write - jobs: codecoverage: name: "Code Coverage" @@ -23,9 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/checkout@v2.3.4 - name: Restore Nuget Packages run: dotnet restore EstateManagementUI.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} @@ -36,13 +27,18 @@ jobs: - name: Run Unit Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - mkdir -p "$GITHUB_WORKSPACE/coverage" - dotnet test EstateManagementUI.BlazorServer.Tests/EstateManagementUI.BlazorServer.Tests.csproj --settings .runsettings /p:CollectCoverage=true /p:Exclude="[xunit*]*" /p:ExcludeByAttribute="Obsolete" /p:ExcludeByAttribute="GeneratedCodeAttribute" /p:ExcludeByAttribute="ExcludeFromCodeCoverageAttribute" /p:CoverletOutput="$GITHUB_WORKSPACE/coverage/coverage" /maxcpucount:1 /p:CoverletOutputFormat="cobertura" + dotnet test EstateManagementUI.BlazorServer.Tests/EstateManagementUI.BlazorServer.Tests.csproj --settings .runsettings /p:CollectCoverage=true /p:Exclude="[xunit*]*" /p:ExcludeByAttribute="Obsolete" /p:ExcludeByAttribute="GeneratedCodeAttribute" /p:ExcludeByAttribute="ExcludeFromCodeCoverageAttribute" /p:CoverletOutput="../lcov1.info" /maxcpucount:1 /p:CoverletOutputFormat="lcov" + + - name: Install LCOV merger + run: npm install -g lcov-result-merger + + - name: Merge LCOV reports + run: | + mkdir -p coverage + lcov-result-merger "*.info" > lcov.info - - name: Upload coverage report - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: actions/upload-code-coverage@v1 + - name: Upload merged coverage to Codacy + uses: codacy/codacy-coverage-reporter-action@v1 with: - file: ${{ github.workspace }}/coverage/coverage.cobertura.xml - language: C# - label: code-coverage/blazor + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: ./lcov.info diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml deleted file mode 100644 index 2f263db..0000000 --- a/.github/workflows/semgrep.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Semgrep - -on: - push: - branches: - - main - schedule: - - cron: "0 3 * * *" - workflow_dispatch: - inputs: - min_severity: - description: "Minimum severity for GitHub issues" - required: false - default: warning - type: choice - options: - - warning - - error - -permissions: - contents: read - issues: write - -jobs: - scan: - name: Scan and Sync - runs-on: ubuntu-latest - - env: - SEMGREP_MIN_SEVERITY: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.min_severity || vars.SEMGREP_MIN_SEVERITY || 'warning' }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run Semgrep - run: | - set +e - docker run --rm -v "$GITHUB_WORKSPACE:/src" -w /src semgrep/semgrep:latest semgrep scan --config auto --json --output /src/semgrep.json /src - code=$? - set -e - if [ "$code" -gt 1 ]; then - exit "$code" - fi - - - name: Sync Semgrep findings to GitHub issues - env: - GITHUB_TOKEN: ${{ github.token }} - run: node scripts/semgrep-sync-issues.mjs semgrep.json diff --git a/.semgrepignore b/.semgrepignore deleted file mode 100644 index 9a15542..0000000 --- a/.semgrepignore +++ /dev/null @@ -1,19 +0,0 @@ -Archive/** -**/Archive/** - -EstateManagementUI.BlazorServer.Tests/** -EstateManagementUI.IntegrationTests/** - -bin/** -obj/** -.vs/** -.vscode/** -.idea/** -node_modules/** -TestResults/** - -**/*.g.cs -**/*.g.i.cs -**/*.designer.cs -**/*.Generated.cs -**/Generated/** diff --git a/scripts/semgrep-sync-issues.mjs b/scripts/semgrep-sync-issues.mjs deleted file mode 100644 index 86eac9d..0000000 --- a/scripts/semgrep-sync-issues.mjs +++ /dev/null @@ -1,256 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; - -const repo = process.env.GITHUB_REPOSITORY; -const token = process.env.GITHUB_TOKEN; -const threshold = normalizeSeverity(process.env.SEMGREP_MIN_SEVERITY || 'warning'); -const runUrl = process.env.GITHUB_RUN_ID && repo - ? `https://github.com/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}` - : ''; - -if (!repo) { - throw new Error('GITHUB_REPOSITORY is required.'); -} - -if (!token) { - throw new Error('GITHUB_TOKEN is required.'); -} - -const inputPath = process.argv[2] || 'semgrep.json'; -const raw = await fs.readFile(inputPath, 'utf8').catch(() => ''); -const parsed = raw.trim() ? JSON.parse(raw) : {}; -const results = Array.isArray(parsed.results) ? parsed.results : []; -const findings = results - .map(normalizeFinding) - .filter((finding) => finding && severityRank(finding.severity) >= threshold.rank); - -const seenMarkers = new Set(); -const existingIssues = await searchIssues(`repo:${repo} is:issue "semgrep-finding-id:" in:body`); - -for (const finding of findings) { - const marker = finding.marker; - seenMarkers.add(marker); - - const matches = await searchIssues(`repo:${repo} is:issue "${marker}" in:body`); - const canonical = chooseCanonicalIssue(matches); - - if (matches.length > 1) { - for (const duplicate of matches) { - if (canonical && duplicate.id === canonical.id) { - continue; - } - await closeIssue(duplicate.number, `Consolidated into #${canonical?.number ?? 'N/A'} for the same Semgrep finding marker.`); - } - } - - if (canonical) { - await upsertIssue(canonical, finding, marker); - } else { - await createIssue(finding, marker); - } -} - -for (const issue of existingIssues) { - const marker = extractMarker(issue.body || ''); - if (!marker || seenMarkers.has(marker)) { - continue; - } - - if (issue.state === 'open') { - await closeIssue(issue.number, 'Resolved by a later Semgrep scan.'); - } -} - -function normalizeFinding(result) { - const path = result?.path || result?.location?.path || ''; - const ruleId = result?.check_id || result?.rule_id || result?.ruleId || result?.id || 'unknown-rule'; - const message = normalizeText(result?.extra?.message || result?.message || ''); - const severity = normalizeSeverity(result?.extra?.severity || result?.severity || 'warning').name; - const startLine = result?.start?.line || result?.location?.start_line || result?.start_line || null; - const endLine = result?.end?.line || result?.location?.end_line || result?.end_line || startLine; - const snippet = normalizeText(result?.extra?.lines || result?.lines || ''); - - if (!path) { - return null; - } - - const markerSource = [ - ruleId, - cleanPath(path), - message, - snippet, - ].join('|'); - - return { - path: cleanPath(path), - ruleId, - message, - severity, - startLine, - endLine, - snippet, - marker: sha256(markerSource), - }; -} - -function chooseCanonicalIssue(issues) { - if (!issues.length) { - return null; - } - - const openIssues = issues.filter((issue) => issue.state === 'open'); - const pool = openIssues.length ? openIssues : issues; - - return pool - .slice() - .sort((left, right) => new Date(right.updated_at) - new Date(left.updated_at))[0]; -} - -async function upsertIssue(issue, finding, marker) { - const title = buildTitle(finding); - const body = buildBody(finding, marker); - - if (issue.state !== 'open') { - await api(`/repos/${repo}/issues/${issue.number}`, 'PATCH', { state: 'open' }); - } - - if (issue.title !== title || issue.body !== body) { - await api(`/repos/${repo}/issues/${issue.number}`, 'PATCH', { title, body }); - } -} - -async function createIssue(finding, marker) { - const title = buildTitle(finding); - const body = buildBody(finding, marker); - - await api(`/repos/${repo}/issues`, 'POST', { title, body }); -} - -async function closeIssue(number, comment) { - if (comment) { - await api(`/repos/${repo}/issues/${number}/comments`, 'POST', { body: comment }); - } - - await api(`/repos/${repo}/issues/${number}`, 'PATCH', { state: 'closed' }); -} - -function buildTitle(finding) { - const linePart = finding.startLine ? `:${finding.startLine}` : ''; - const title = `[Semgrep][${finding.severity.toUpperCase()}] ${finding.ruleId} ${finding.path}${linePart}`; - return title.length > 240 ? title.slice(0, 240) : title; -} - -function buildBody(finding, marker) { - const lines = finding.startLine && finding.endLine && finding.startLine !== finding.endLine - ? `${finding.startLine}-${finding.endLine}` - : (finding.startLine ? `${finding.startLine}` : 'unknown'); - - const parts = [ - ``, - ``, - ``, - ``, - '', - `Semgrep reported a ${finding.severity} finding in \`${finding.path}\`.`, - '', - `- Rule: \`${finding.ruleId}\``, - `- Path: \`${finding.path}\``, - `- Lines: \`${lines}\``, - `- Severity: \`${finding.severity}\``, - ]; - - if (runUrl) { - parts.push(`- Scan: ${runUrl}`); - } - - if (finding.message) { - parts.push('', '### Message', finding.message); - } - - if (finding.snippet) { - parts.push('', '### Match', '```text', finding.snippet, '```'); - } - - return parts.join('\n'); -} - -function extractMarker(body) { - const match = body.match(//i); - return match ? match[1] : null; -} - -function normalizeSeverity(value) { - const normalized = String(value || 'warning').trim().toLowerCase(); - - if (normalized === 'info' || normalized === 'information') { - return { name: 'info', rank: 1 }; - } - - if (normalized === 'warning' || normalized === 'warn') { - return { name: 'warning', rank: 2 }; - } - - if (normalized === 'error' || normalized === 'high' || normalized === 'critical') { - return { name: 'error', rank: 3 }; - } - - return { name: 'warning', rank: 2 }; -} - -function severityRank(value) { - return normalizeSeverity(value).rank; -} - -function normalizeText(value) { - return String(value || '').replace(/\s+/g, ' ').trim(); -} - -function cleanPath(value) { - return String(value || '').replace(/\\/g, '/'); -} - -function sha256(value) { - return crypto.createHash('sha256').update(value).digest('hex'); -} - -async function searchIssues(query) { - const items = []; - for (let page = 1; page < 10; page += 1) { - const result = await api(`/search/issues?q=${encodeURIComponent(query)}&per_page=100&page=${page}`); - if (!Array.isArray(result.items) || result.items.length === 0) { - break; - } - - items.push(...result.items.filter((item) => !item.pull_request)); - - if (result.items.length < 100) { - break; - } - } - - return items; -} - -async function api(path, method = 'GET', body) { - const response = await fetch(`https://api.github.com${path}`, { - method, - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...(body ? { 'Content-Type': 'application/json' } : {}), - }, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`GitHub API request failed for ${method} ${path}: ${response.status} ${response.statusText}\n${text}`); - } - - if (response.status === 204) { - return null; - } - - return response.json(); -}