Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/reusable-ci-astro.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ jobs:
# Static analysis only — this Astro reusable has no test/coverage job, so no
# coverage artifact is handed over. !cancelled() so it still runs alongside
# build/lint regardless of their result.
if: inputs.enable-sonar && !cancelled()
if: inputs.enable-sonar && github.event_name != 'pull_request' && !cancelled()
needs: build
permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reusable-ci-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ jobs:
name: SonarQube
# !cancelled() so analysis still runs when tests fail (coverage may be partial)
# or when the test job is skipped (enable-test / enable-coverage off).
if: inputs.enable-sonar && !cancelled()
if: inputs.enable-sonar && github.event_name != 'pull_request' && !cancelled()
# needs: test so the coverage artifact exists before the scan downloads it.
needs: test
permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reusable-ci-node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ jobs:
sonarqube:
name: SonarQube
# !cancelled() so analysis still runs when tests fail (coverage may be partial).
if: inputs.enable-sonar && !cancelled()
if: inputs.enable-sonar && github.event_name != 'pull_request' && !cancelled()
# needs: test so the coverage artifact exists before the scan downloads it.
needs: test
permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reusable-ci-rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ jobs:
# !cancelled() so analysis runs even when the coverage job is skipped
# (enable-coverage false / release commit) or fails — keep enable-coverage on
# for full reports.
if: inputs.enable-sonar && !cancelled()
if: inputs.enable-sonar && github.event_name != 'pull_request' && !cancelled()
needs: coverage
permissions:
contents: read
Expand Down
187 changes: 2 additions & 185 deletions .github/workflows/reusable-sonarqube-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,33 +62,15 @@ concurrency:

permissions:
contents: read
pull-requests: write

jobs:
sonarqube:
name: SonarQube analysis
if: github.event_name != 'pull_request'
runs-on: ${{ inputs.runner != '' && inputs.runner || (github.event.repository.private && 'ferrlabs-k8s' || 'ubuntu-latest') }}
# Pull requests all analyse into the same `<key>-pr` sandbox project, so two
# concurrent PRs on one repo would overwrite each other's analysis. Serialise
# them per repo. Pushes to the default branch are unaffected (own group).
concurrency:
group: sonar-${{ inputs.project-key || github.event.repository.name }}-${{ github.event_name == 'pull_request' && 'pr' || github.ref }}
cancel-in-progress: false
env:
SONAR_HOST: ${{ vars.SONAR_HOST_URL || 'https://sonar.ferrlabs.com' }}
# SonarQube Community has no branch or pull-request analysis — the
# `api/project_pull_requests/*` endpoints return 404, and a scan carries no
# branch identity. So a scan launched from a PR used to land on the
# project's ONLY analysis and overwrite the default branch's picture with
# the PR's code: the dashboard showed whichever run finished last, and
# every "new code" figure was meaningless.
#
# A pull request therefore analyses into its own sandbox project. The
# default branch keeps a clean, stable analysis, and the two projects are
# comparable, which is what lets the diff step below report what this pull
# request actually introduces.
BASE_PROJECT: ${{ inputs.project-key || github.event.repository.name }}
SONAR_PROJECT: ${{ github.event_name == 'pull_request' && format('{0}-pr', inputs.project-key || github.event.repository.name) || (inputs.project-key || github.event.repository.name) }}
SONAR_PROJECT: ${{ inputs.project-key || github.event.repository.name }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
Expand Down Expand Up @@ -139,168 +121,3 @@ jobs:
-Dsonar.exclusions=${{ inputs.exclusions }}
${{ steps.sast.outputs.scanner-args }}
${{ inputs.args }}

# Everything below only runs on a pull request. It turns the two analyses
# (this PR's sandbox, and the default branch) into the one thing a reviewer
# wants: what did THIS change introduce.

- name: Wait for SonarQube to finish processing
if: github.event_name == 'pull_request'
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
working-directory: ${{ inputs.working-directory }}
# The scanner returns as soon as the report is uploaded; SonarQube
# ingests it asynchronously. Querying the API before the compute-engine
# task finishes returns the PREVIOUS analysis, so the diff would silently
# describe the wrong commit. report-task.txt carries the task id.
run: |
set -euo pipefail
task=$(grep '^ceTaskId=' .scannerwork/report-task.txt | cut -d= -f2)
for _ in $(seq 1 60); do
status=$(curl -sS -u "${SONAR_TOKEN}:" "${SONAR_HOST}/api/ce/task?id=${task}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["task"]["status"])')
case "$status" in
SUCCESS) echo "analyse ingérée"; exit 0 ;;
FAILED|CANCELED) echo "::error::SonarQube task $status"; exit 1 ;;
esac
sleep 5
done
echo "::error::SonarQube n'a pas fini d'ingérer l'analyse en 5 minutes"
exit 1

- name: Diff against the default branch
id: diff
if: github.event_name == 'pull_request'
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
python3 <<'PY'
import json, os, urllib.request, urllib.parse, base64, pathlib

host = os.environ["SONAR_HOST"].rstrip("/")
token = os.environ["SONAR_TOKEN"]
gh = os.environ["GH_TOKEN"]
repo = os.environ["REPO"]
pr = os.environ["PR_NUMBER"]

def get(url, headers):
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)

sonar_auth = {"Authorization": "Basic " + base64.b64encode(f"{token}:".encode()).decode()}
gh_auth = {"Authorization": f"Bearer {gh}", "Accept": "application/vnd.github+json"}

def issues(project):
"""Every open issue of a project, keyed by what survives a rebase."""
out, page = {}, 1
while True:
q = urllib.parse.urlencode({
"componentKeys": project, "resolved": "false",
"ps": 500, "p": page,
})
d = get(f"{host}/api/issues/search?{q}", sonar_auth)
for i in d.get("issues", []):
# Deliberately NOT keyed on line number: a pull request that
# only shifts lines would otherwise report every pre-existing
# issue in the file as newly introduced.
path = i.get("component", "").split(":", 1)[-1]
out[(i.get("rule"), path, i.get("message"))] = i
if page * 500 >= d.get("paging", {}).get("total", 0):
return out
page += 1

changed = set()
page = 1
while True:
d = get(f"https://api.github.com/repos/{repo}/pulls/{pr}/files?per_page=100&page={page}", gh_auth)
if not d:
break
changed.update(f["filename"] for f in d)
if len(d) < 100:
break
page += 1

pr_issues = issues(os.environ["SONAR_PROJECT"])
base_issues = issues(os.environ["BASE_PROJECT"])

introduced = [
v for k, v in pr_issues.items()
if k not in base_issues and k[1] in changed
]
fixed = [
v for k, v in base_issues.items()
if k not in pr_issues and k[1] in changed
]

rank = {"BLOCKER": 0, "HIGH": 1, "CRITICAL": 1, "MEDIUM": 2, "MAJOR": 2, "LOW": 3, "MINOR": 3, "INFO": 4}
introduced.sort(key=lambda i: rank.get(i.get("severity", "INFO"), 9))

pathlib.Path("sonar-delta.json").write_text(json.dumps({
"introduced": introduced, "fixed": len(fixed),
"host": host, "project": os.environ["SONAR_PROJECT"],
}))
print(f"{len(introduced)} introduite(s), {len(fixed)} corrigée(s)")
PY

- name: Comment on the pull request
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
python3 <<'PY'
import json, os, urllib.request

d = json.load(open("sonar-delta.json"))
gh, repo, pr = os.environ["GH_TOKEN"], os.environ["REPO"], os.environ["PR_NUMBER"]
# One comment per pull request, found by this marker and edited in
# place. A scan runs on every push; without this the thread fills with
# near-identical comments and nobody reads any of them.
MARKER = "<!-- ferrlabs-sonar-delta -->"

intro = d["introduced"]
if intro:
lines = [f"### SonarQube — {len(intro)} issue(s) introduite(s) par cette PR", ""]
for i in intro[:25]:
path = i.get("component", "").split(":", 1)[-1]
line = i.get("line") or (i.get("textRange") or {}).get("startLine")
where = f"`{path}`" + (f" L{line}" if line else "")
lines.append(f"- **{i.get('severity','?')}** {where} — {i.get('message')} <sub>`{i.get('rule')}`</sub>")
if len(intro) > 25:
lines.append(f"\n_… et {len(intro) - 25} autres._")
else:
lines = ["### SonarQube — aucune nouvelle issue", ""]
if d["fixed"]:
lines.append(f"\n{d['fixed']} issue(s) corrigée(s) sur les fichiers touchés.")
lines.append(
"\n<sub>Comparaison entre le projet bac à sable de cette PR et la branche par défaut : "
"SonarQube Community n'analyse pas les PR, ce delta est calculé côté CI. "
f"[Détail]({d['host']}/dashboard?id={d['project']})</sub>"
)
body = MARKER + "\n" + "\n".join(lines)

def api(method, url, payload=None):
req = urllib.request.Request(
url, method=method,
data=json.dumps(payload).encode() if payload else None,
headers={"Authorization": f"Bearer {gh}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)

existing = next(
(c for c in api("GET", f"https://api.github.com/repos/{repo}/issues/{pr}/comments?per_page=100")
if MARKER in c.get("body", "")), None)
if existing:
api("PATCH", f"https://api.github.com/repos/{repo}/issues/comments/{existing['id']}", {"body": body})
else:
api("POST", f"https://api.github.com/repos/{repo}/issues/{pr}/comments", {"body": body})
PY
Loading