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
26 changes: 26 additions & 0 deletions src/commands/architecture/_common/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,37 @@ import {
assertSingleLineProjectKey,
discoverProject,
} from '@/core/project-discovery/project-info.ts';
import { fetchServerVersion, isAtLeast, isFullYearVersion } from '@/core/server/server-info.ts';

const MIN_ARCHITECTURE_SERVER_VERSION = '2026.6';
const MIN_COMMUNITY_BUILD_YEAR = 20;

export async function resolveProject(
options: { project?: string },
ctx: CommandAuthenticatedInvocationContext,
): Promise<string> {
if (ctx.auth.connectionType !== 'cloud') {
const version = await fetchServerVersion(ctx.auth.serverUrl).catch(() => undefined);
if (
version !== undefined &&
!isFullYearVersion(version) &&
Number(version.split('.')[0]) >= MIN_COMMUNITY_BUILD_YEAR
) {
throw new CommandFailedError(
`SonarQube Community Build ${version} does not include Architecture; SonarQube Server ${MIN_ARCHITECTURE_SERVER_VERSION} or later is required.`,
{ remediationHint: 'Use SonarQube Server or SonarQube Cloud for Architecture commands.' },
);
}
if (
version !== undefined &&
(!isFullYearVersion(version) || !isAtLeast(version, MIN_ARCHITECTURE_SERVER_VERSION))
) {
throw new CommandFailedError(
`SonarQube Server ${version} does not support these Architecture commands; ${MIN_ARCHITECTURE_SERVER_VERSION} or later is required.`,
{ remediationHint: 'Upgrade SonarQube Server, then retry.' },
);
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
}
const projectKey =
options.project ??
(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ describe('Architecture API compatibility diagnostics', () => {
it(`offers an update for a non-JSON ${target} response on ${cloud ? 'Cloud' : 'Server'}`, async () => {
const builder = harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (project) =>
project.withArchitecture((architecture) =>
Expand Down Expand Up @@ -154,6 +155,7 @@ describe('Architecture API compatibility diagnostics', () => {
it(`explains an incompatible ${target} envelope on ${cloud ? 'Cloud' : 'Server'} without writing`, async () => {
const builder = harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (project) =>
project.withArchitecture((architecture) =>
Expand Down Expand Up @@ -202,6 +204,7 @@ describe('Architecture API compatibility diagnostics', () => {
] as const) {
const builder = harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (project) =>
project.withArchitecture((architecture) =>
Expand Down Expand Up @@ -235,6 +238,7 @@ describe('Architecture API compatibility diagnostics', () => {
it(`keeps an explicit directive 404 distinct while explaining Server availability on ${cloud ? 'Cloud' : 'Server'}`, async () => {
const builder = harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (project) => project.withArchitecture(() => {}));
const server = await (cloud ? builder.asSonarCloud() : builder).start();
Expand Down Expand Up @@ -264,6 +268,7 @@ describe('Architecture API compatibility diagnostics', () => {
it(`keeps valid empty results and invalid input separate from compatibility on ${cloud ? 'Cloud' : 'Server'}`, async () => {
const builder = harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (project) => project.withArchitecture(() => {}));
const server = await (cloud ? builder.asSonarCloud() : builder).start();
Expand Down Expand Up @@ -294,6 +299,7 @@ describe('Architecture API compatibility diagnostics', () => {
it(`preserves compatibility guidance for an unconfirmed write on ${cloud ? 'Cloud' : 'Server'} without replay`, async () => {
const builder = harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (project) =>
project.withArchitecture((architecture) =>
Expand Down Expand Up @@ -341,6 +347,7 @@ describe('Architecture API compatibility diagnostics', () => {
it(`does not diagnose HTTP ${status} as version incompatibility on ${cloud ? 'Cloud' : 'Server'}`, async () => {
const builder = harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (project) =>
project.withArchitecture((architecture) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe('public architecture findings contract', () => {
) {
const builder = h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('findings-token')
.withProject('shop', (p) => p.withArchitecture(configure));
if (cloud) builder.asSonarCloud();
Expand All @@ -119,6 +120,7 @@ describe('public architecture findings contract', () => {
async (main) => {
const builder = h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('findings-token')
.withProject('shop', (p) =>
p
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ describe('sonar architecture reads on the public API', () => {
it('uses public Server project lookup while current remains independent', async () => {
const server = await harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken(TOKEN)
.withProject(PROJECT, (p) => p.withArchitecture((a) => a.withStructure('java', GRAPH)))
.start();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ describe('Server public architecture project lookup', () => {
it('previews and creates intended architecture without an analyzed branch', async () => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) => p.withNoDefaultBranch().withArchitecture(() => undefined))
.start();
Expand Down Expand Up @@ -113,6 +114,7 @@ describe('Server public architecture project lookup', () => {
it('updates and clears intended architecture without an analyzed branch', async () => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) =>
p
Expand Down Expand Up @@ -157,6 +159,7 @@ describe('Server public architecture project lookup', () => {
it('previews and creates directives once on Server', async () => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) => p.withNoDefaultBranch().withArchitecture(() => undefined))
.start();
Expand All @@ -182,6 +185,7 @@ describe('Server public architecture project lookup', () => {
it('updates explicit directives and retains unspecified fields on Server', async () => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) =>
p.withNoDefaultBranch().withArchitecture((a) => a.withDirectives([storedDirective])),
Expand Down Expand Up @@ -211,6 +215,7 @@ describe('Server public architecture project lookup', () => {
};
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) =>
p
Expand All @@ -233,6 +238,7 @@ describe('Server public architecture project lookup', () => {
async (resource) => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) =>
p.withArchitecture((a) =>
Expand Down Expand Up @@ -263,6 +269,7 @@ describe('Server public architecture project lookup', () => {
])('refuses an unusable lookup before any mutation (%s)', async (status, body, message) => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop')
.withProjectsLookupResponse(status, body)
Expand All @@ -285,6 +292,7 @@ describe('Server public architecture project lookup', () => {
async (command) => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop')
.withProjectsLookupResponse(403, { message: 'Lookup forbidden' })
Expand All @@ -309,6 +317,7 @@ describe('Server public architecture project lookup', () => {
const id = '00000000-0000-4000-8000-000000000099';
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) =>
p.withArchitecture((a) =>
Expand Down Expand Up @@ -339,6 +348,7 @@ describe('Server public architecture project lookup', () => {
it(`reports a missing Architecture collection without blaming a resolved project on ${cloud ? 'Cloud' : 'Server'}`, async () => {
const builder = h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) =>
p.withArchitecture((a) =>
Expand Down Expand Up @@ -366,7 +376,11 @@ describe('Server public architecture project lookup', () => {

for (const cloud of [false, true]) {
it(`keeps missing-project guidance separate from Server API availability on ${cloud ? 'Cloud' : 'Server'}`, async () => {
const builder = h.newFakeServer().withAuthToken('test-token').withProject('shop');
const builder = h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop');
const server = await (cloud ? builder.asSonarCloud() : builder).start();
h.withAuth(
cloud ? server.recordedAuthServerUrl() : server.baseUrl(),
Expand All @@ -387,6 +401,7 @@ describe('Server public architecture project lookup', () => {
it('encodes the project key without changing the returned project id', async () => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop:api-v2', (p) => p.withArchitecture(() => undefined))
.start();
Expand All @@ -405,6 +420,7 @@ describe('Server public architecture project lookup', () => {
it('accepts an empty intended response with a null unsaved model id', async () => {
const server = await h
.newFakeServer()
.withVersion('2026.6')
.withAuthToken('test-token')
.withProject('shop', (p) =>
p.withArchitecture((a) =>
Expand Down
124 changes: 124 additions & 0 deletions tests/integration/specs/architecture/architecture-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* SonarQube CLI
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { describe, expect, it } from 'bun:test';

import { TestHarness } from '../../harness';

describe('Architecture Server version', () => {
it.each(['2025.1', '2026.5.4', '26.11'])(
'rejects Architecture operations on Server %s',
async (version) => {
const h = await TestHarness.create();
try {
const server = await h
.newFakeServer()
.withVersion(version)
.withAuthToken('test-token')
.withProject('shop', (p) => p.withArchitecture(() => undefined))
.start();
h.withAuth(server.baseUrl(), 'test-token');
h.cwd.writeFile('intended.json', JSON.stringify({ models: [] }));
h.cwd.writeFile('directives.json', JSON.stringify({ create: [] }));
for (const command of [
'arch current get -p shop',
'arch intended get -p shop',
'arch intended set -p shop --file intended.json --dry-run',
'arch directives get -p shop',
'arch directives set -p shop --file directives.json --dry-run',
'arch findings get -p shop',
]) {
const result = await h.run(command);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('2026.6 or later is required');
if (version === '26.11') {
expect(result.stderr).toContain('Community Build 26.11 does not include Architecture');
expect(result.stderr).toContain('Use SonarQube Server or SonarQube Cloud');
}
}
expect(
server.getRecordedRequests().filter((r) => r.path.includes('/architecture/')),
).toHaveLength(0);
} finally {
await h.dispose();
}
},
);

it('keeps Cloud available regardless of the Server version', async () => {
const h = await TestHarness.create();
try {
const server = await h
.newFakeServer()
.withVersion('2025.1')
.asSonarCloud()
.withAuthToken('test-token')
.withProject('shop', (p) => p.withArchitecture(() => undefined))
.start();
h.withAuth(server.recordedAuthServerUrl(), 'test-token', 'my-org');
const result = await h.run('arch current get -p shop --language java --format json');
expect(result.exitCode).toBe(0);
} finally {
await h.dispose();
}
});

it('uses the API compatibility check if the Server version cannot be read', async () => {
const h = await TestHarness.create();
try {
const server = await h
.newFakeServer()
.withSystemStatusCode(503)
.withAuthToken('test-token')
.withProject('shop', (p) => p.withArchitecture(() => undefined))
.start();
h.withAuth(server.baseUrl(), 'test-token');
const result = await h.run('arch current get -p shop --language java --format json');
expect(result.exitCode).toBe(0);
expect(server.getRecordedRequests().some((r) => r.path.includes('/architecture/'))).toBe(
true,
);
} finally {
await h.dispose();
}
});

it('reports an unavailable Architecture API when the Server version cannot be read', async () => {
const h = await TestHarness.create();
try {
const server = await h
.newFakeServer()
.withSystemStatusCode(503)
.withAuthToken('test-token')
.withProject('shop', (p) =>
p.withArchitecture((architecture) =>
architecture.withStatus('current-architectures', 404, '{}', 'GET'),
),
)
.start();
h.withAuth(server.baseUrl(), 'test-token');
const result = await h.run('arch current get -p shop --language java --format json');
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('The public Architecture API endpoint was not found');
expect(result.stderr).toContain('Server build that exposes the public Architecture APIs');
} finally {
await h.dispose();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe('sonar architecture intended set', () => {
it(`rejects another project's export before Architecture requests (Cloud=${cloud}, explicit=${explicit}, dryRun=${dryRun})`, async () => {
const builder = harness
.newFakeServer()
.withVersion('2026.6')
.withAuthToken(TOKEN)
.withProject('source', (p) => p.withArchitecture((a) => a.withIntended(SAVED)))
.withProject(PROJECT, (p) => p.withArchitecture((a) => a.withIntended(SAVED)));
Expand Down