@@ -342,28 +342,166 @@ jobs:
342342 name : Project Check Suite
343343 runs-on : ubuntu-latest
344344 needs : [konsist, lint, ktlint, detekt]
345- if : ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
345+ # Run even if a previous job failed: that is precisely when there are issues to report.
346+ if : ${{ !cancelled() }}
347+ permissions :
348+ # Required to list the files modified by the pull request.
349+ pull-requests : read
346350 steps :
347- - uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
348- with :
349- # Ensure we are building the branch and not the branch after being merged on develop
350- # https://github.com/actions/checkout/issues/881
351- ref : ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
352- persist-credentials : false
353351 - name : Download reports from previous jobs
354352 uses : actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
355- - name : Prepare Danger
356- if : always()
357- run : |
358- npm install --save-dev @babel/core
359- npm install --save-dev @babel/plugin-transform-flow-strip-types
360- yarn add danger-plugin-lint-report --dev
361- - name : Danger lint
362- if : always()
363- uses : danger/danger-js@a236e3b8c41f0babbf133568db851a20a34f9a7b # 14.0.5
364- with :
365- args : " --dangerfile ./tools/danger/dangerfile-lint.js"
366- env :
367- DANGER_GITHUB_API_TOKEN : ${{ secrets.DANGER_GITHUB_API_TOKEN }}
368- # Fallback for forks
369- GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
353+ - name : Annotate the lint, ktlint and detekt issues
354+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
355+ with :
356+ script : |
357+ // GitHub only surfaces a handful of annotations per level and per step, so cap what we emit
358+ // and let the job summary carry the full list.
359+ const MAX_ANNOTATIONS = 20;
360+ const MAX_SUMMARY_ROWS = 200;
361+
362+ function unescapeXml(value) {
363+ return value
364+ .replace(/</g, "<")
365+ .replace(/>/g, ">")
366+ .replace(/"/g, "\"")
367+ .replace(/'/g, "'")
368+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
369+ .replace(/&/g, "&");
370+ }
371+
372+ function attribute(tag, name) {
373+ const match = new RegExp(`\\b${name}="([^"]*)"`).exec(tag);
374+ return match ? unescapeXml(match[1]) : undefined;
375+ }
376+
377+ // Paths in the reports are absolute paths on the runner which generated them, i.e.
378+ // /home/runner/work/<repo>/<repo>/<path>.
379+ const workspace = `/work/${context.repo.repo}/${context.repo.repo}/`;
380+ function repoRelative(path) {
381+ const index = path.indexOf(workspace);
382+ return index === -1 ? path : path.slice(index + workspace.length);
383+ }
384+
385+ // Checkstyle XML, as produced by ktlint and detekt:
386+ // <file name="…"><error line=".." column=".." severity=".." message=".." source=".."/></file>
387+ function parseCheckstyle(content) {
388+ const issues = [];
389+ const fileRegex = /<file\s+([^>]*?)(\/>|>([\s\S]*?)<\/file>)/g;
390+ let file;
391+ while ((file = fileRegex.exec(content)) !== null) {
392+ const path = attribute(file[1], "name");
393+ if (!path) continue;
394+ const errorRegex = /<error\s+([^>]*?)\/?>/g;
395+ let error;
396+ while ((error = errorRegex.exec(file[3] ?? "")) !== null) {
397+ issues.push({
398+ file: repoRelative(path),
399+ line: Number(attribute(error[1], "line")) || 1,
400+ column: Number(attribute(error[1], "column")) || undefined,
401+ severity: (attribute(error[1], "severity") ?? "warning").toLowerCase(),
402+ rule: attribute(error[1], "source"),
403+ message: attribute(error[1], "message") ?? "",
404+ });
405+ }
406+ }
407+ return issues;
408+ }
409+
410+ // Android lint XML, which is not Checkstyle:
411+ // <issue id=".." severity=".." message=".."><location file=".." line=".." column=".."/></issue>
412+ function parseAndroidLint(content) {
413+ const issues = [];
414+ const issueRegex = /<issue\s+([^>]*?)(\/>|>([\s\S]*?)<\/issue>)/g;
415+ let issue;
416+ while ((issue = issueRegex.exec(content)) !== null) {
417+ const location = /<location\s+([^>]*?)\/?>/.exec(issue[3] ?? "");
418+ if (!location) continue;
419+ const path = attribute(location[1], "file");
420+ if (!path) continue;
421+ issues.push({
422+ file: repoRelative(path),
423+ line: Number(attribute(location[1], "line")) || 1,
424+ column: Number(attribute(location[1], "column")) || undefined,
425+ severity: (attribute(issue[1], "severity") ?? "warning").toLowerCase(),
426+ rule: attribute(issue[1], "id"),
427+ message: attribute(issue[1], "message") ?? "",
428+ });
429+ }
430+ return issues;
431+ }
432+
433+ const reports = await (await glob.create("**/reports/**/*.xml")).glob();
434+ core.info(`Found ${reports.length} XML report(s).`);
435+
436+ const fs = require("fs");
437+ let issues = [];
438+ for (const report of reports) {
439+ const content = fs.readFileSync(report, "utf8");
440+ if (content.includes("<checkstyle")) {
441+ issues.push(...parseCheckstyle(content));
442+ } else if (content.includes("<issues")) {
443+ issues.push(...parseAndroidLint(content));
444+ }
445+ }
446+
447+ // Only report on the files touched by the pull request.
448+ const pr = context.payload.pull_request;
449+ if (pr) {
450+ const files = await github.paginate(github.rest.pulls.listFiles, {
451+ owner: context.repo.owner,
452+ repo: context.repo.repo,
453+ pull_number: pr.number
454+ });
455+ const changed = new Set(files.map(file => file.filename));
456+ issues = issues.filter(issue => changed.has(issue.file));
457+ }
458+
459+ const seen = new Set();
460+ issues = issues.filter(issue => {
461+ const key = `${issue.file}:${issue.line}:${issue.rule}:${issue.message}`;
462+ if (seen.has(key)) return false;
463+ seen.add(key);
464+ return true;
465+ });
466+
467+ if (issues.length === 0) {
468+ core.info("No issue found in the files modified by this pull request.");
469+ return;
470+ }
471+
472+ const level = severity => {
473+ if (severity === "error" || severity === "fatal") return "error";
474+ if (severity === "warning") return "warning";
475+ return "notice";
476+ };
477+ const rank = { error: 0, warning: 1, notice: 2 };
478+ issues.sort((a, b) =>
479+ rank[level(a.severity)] - rank[level(b.severity)] ||
480+ a.file.localeCompare(b.file) ||
481+ a.line - b.line
482+ );
483+
484+ issues.slice(0, MAX_ANNOTATIONS).forEach(issue => {
485+ const annotation = {
486+ title: issue.rule,
487+ file: issue.file,
488+ startLine: issue.line,
489+ startColumn: issue.column,
490+ };
491+ core[level(issue.severity)](issue.message, annotation);
492+ });
493+
494+ core.summary.addHeading(`${issues.length} code quality issue(s)`, 3);
495+ core.summary.addTable([
496+ [{ data: "Severity", header: true }, { data: "File", header: true }, { data: "Rule", header: true }, { data: "Message", header: true }],
497+ ...issues.slice(0, MAX_SUMMARY_ROWS).map(issue => [
498+ level(issue.severity),
499+ `${issue.file}:${issue.line}`,
500+ issue.rule ?? "",
501+ issue.message,
502+ ]),
503+ ]);
504+ if (issues.length > MAX_SUMMARY_ROWS) {
505+ core.summary.addRaw(`…and ${issues.length - MAX_SUMMARY_ROWS} more, see the uploaded reports.`, true);
506+ }
507+ await core.summary.write();
0 commit comments