Skip to content
Open
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
10 changes: 10 additions & 0 deletions .codacy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
engines:
eslint-8:
exclude_paths:
- '.github/workflows/sonar.yml'
- 'scripts/sonar-monorepo.ts'
opengrep:
exclude_paths:
- '.github/workflows/sonar.yml'
- 'scripts/sonar-monorepo.ts'
75 changes: 75 additions & 0 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Sonar

on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
validate:
name: Validate Sonar token
runs-on: ubuntu-latest
outputs:
valid: ${{ steps.check.outputs.valid }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- id: check
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
if [ -z "$SONAR_TOKEN" ]; then
echo '::error::SONAR_TOKEN is not set'
exit 1
fi
code=$(curl -s -o /dev/null -u "$SONAR_TOKEN": -w '%{http_code}' 'https://api.sonarcloud.io/analysis/jres?os=linux&arch=x86_64')
if [ "$code" = '200' ]; then
echo 'valid=true' >> "$GITHUB_OUTPUT"
else
echo "::error::SonarCloud token validation failed with HTTP $code"
exit 1
fi

matrix:
name: Generate Sonar matrix
needs: validate
if: needs.validate.outputs.valid == 'true'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- id: matrix
run: |
printf 'matrix=%s\n' "$(jq -c . sonar-matrix.json)" >> "$GITHUB_OUTPUT"

sonar:
name: Sonar (${{ matrix.projectName }})
needs: [validate, matrix]
if: needs.validate.outputs.valid == 'true' && needs.matrix.outputs.matrix != ''
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.matrix.outputs.matrix) }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0

- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8 # nosemgrep: generic.secrets.security.detected-sonarqube-docs-api-key
with:
args: >
-Dsonar.projectKey=${{ matrix.projectKey }}
-Dsonar.projectName=${{ matrix.projectName }}
-Dsonar.sources=${{ matrix.sources }}
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
203 changes: 203 additions & 0 deletions scripts/sonar-monorepo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env bun
/**
* Generate SonarQube Cloud monorepo artifacts for the abapify Nx workspace:
* - sonar-monorepo.json -> bulk import into SonarQube Cloud
* - sonar-matrix.json -> matrix used by .github/workflows/sonar.yml
*
* Run with: bunx tsx scripts/sonar-monorepo.ts
*/
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const execAsync = promisify(exec);

const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));

const ORG = 'abapify';
const MONOREPO = 'adt-cli';
const EXISTING_ADT_CLI_KEY = `${ORG}_${MONOREPO}`;

interface NxProject {
name: string;
root: string;
sourceRoot: string | null;
}

interface SonarProject {
projectKey: string;
projectName: string;
sources: string;
}

async function run(cmd: string): Promise<string> {
const { stdout } = await execAsync(cmd, {
cwd: ROOT,
maxBuffer: 1024 * 1024,
});
return stdout.trim();
}

function hasSourceFiles(dir: string): boolean {
if (!existsSync(dir)) return false;
const entries = readdirSync(dir);
return entries.some((entry) => {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) return false;
return /\.(ts|tsx|js|mjs|cjs|jsx|css|scss|yml|yaml|sh)$/.test(entry);
});
}

function hasDirFiles(dir: string): boolean {
return existsSync(dir) && readdirSync(dir).length > 0;
}

function readPackageName(root: string): string | undefined {
const pkgPath = join(ROOT, root, 'package.json');
if (!existsSync(pkgPath)) return undefined;
try {
return (JSON.parse(readFileSync(pkgPath, 'utf-8')) as { name?: string })
.name;
} catch {
return undefined;
}
}

function sanitizeKeyPart(part: string): string {
return part.replace(/[^A-Za-z0-9._:-]/g, '_');
}

function computeKey({
root,
packageName,
nxName,
}: {
root: string;
packageName: string | undefined;
nxName: string;
}): string {
if (root === '.') {
return `${ORG}_${MONOREPO}_root`;
}

const base = root.split('/').pop() ?? nxName;
const suffixSource = packageName ?? base;
const suffix = suffixSource.replace(/^@abapify\//, '');
const cleanSuffix = sanitizeKeyPart(suffix);

if (root === 'packages/adt-cli' && cleanSuffix === 'adt-cli') {
// Preserve the existing single-project key for the main CLI package.
return EXISTING_ADT_CLI_KEY;
}

return `${ORG}_${MONOREPO}_${cleanSuffix}`;
}

function computeName({
nxName,
packageName,
}: {
nxName: string;
packageName: string | undefined;
}): string {
return packageName ?? nxName;
}

function determineSources({
root,
sourceRoot,
}: {
root: string;
sourceRoot: string | null;
}): string | null {
if (root === '.') {
const parts: string[] = [];
if (hasDirFiles(join(ROOT, 'src'))) parts.push('src');
if (hasDirFiles(join(ROOT, '.github'))) parts.push('.github');
return parts.length > 0 ? parts.join(',') : null;
}

const srcDir = join(ROOT, root, 'src');
if (hasDirFiles(srcDir)) {
return `${root}/src`;
}

if (sourceRoot && hasDirFiles(join(ROOT, sourceRoot))) {
return sourceRoot;
}

if (hasSourceFiles(join(ROOT, root))) {
return root;
}

return null;
}

async function main(): Promise<void> {
const projectsRaw = await run('bunx nx show projects --json');
const projectNames: string[] = JSON.parse(projectsRaw) as string[];

const details = await Promise.all(
projectNames.map(async (name) => {
const raw = await run(`bunx nx show project ${name} --json`);
return JSON.parse(raw) as NxProject;
}),
);

const projects: SonarProject[] = [];

for (const p of details) {
const sources = determineSources(p);
if (!sources) {
// eslint-disable-next-line no-console
console.log(`Skipping ${p.name}: no analyzable source directory`);
continue;
}

const packageName = readPackageName(p.root);
const projectName = computeName({ nxName: p.name, packageName });
const projectKey = computeKey({
root: p.root,
packageName,
nxName: p.name,
});

projects.push({ projectKey, projectName, sources });
}

const importFile = projects.map(({ projectKey, projectName }) => ({
projectKey,
projectName,
}));

const matrixFile = { include: projects };

writeFileSync(
join(ROOT, 'sonar-monorepo.json'),
JSON.stringify(importFile, null, 2) + '\n',
);
writeFileSync(
join(ROOT, 'sonar-matrix.json'),
JSON.stringify(matrixFile, null, 2) + '\n',
);

// eslint-disable-next-line no-console
console.log(`Generated ${projects.length} Sonar projects`);
}

try {
await main();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
}
Loading
Loading