updated deployer code

This commit is contained in:
Mark Gregory
2026-09-29 16:55:12 +01:00
parent c0bc265ade
commit e8b0a1ce57
11 changed files with 470 additions and 5 deletions
+17
View File
@@ -61,3 +61,20 @@ struct ProjectSnapshot: Identifiable {
var measures: [String: String] = [:]
var qualityGate: String = "UNKNOWN"
}
struct TokenGenerateResponse: Codable {
let login: String?
let name: String?
let token: String
let createdAt: String?
let type: String?
}
struct CreatedProjectCredentials: Identifiable {
let id = UUID()
let projectKey: String
let projectName: String
let tokenName: String
let token: String
let serverURL: String
}
+23
View File
@@ -32,6 +32,29 @@ enum KeychainService {
return String(data: data, encoding: .utf8)
}
static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws {
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { throw KeychainError.emptyToken }
let account = "project|\(serverID.uuidString)|\(projectKey)"
let data = Data(clean.utf8)
SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
var add = query(account: account, synchronizable: kCFBooleanTrue)
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
}
static func projectToken(serverID: UUID, projectKey: String) -> String? {
let account = "project|\(serverID.uuidString)|\(projectKey)"
var lookup = query(account: account, synchronizable: kSecAttrSynchronizableAny)
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func deleteToken(serverID: UUID) {
SecItemDelete(query(account: serverID.uuidString, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
}
+47 -1
View File
@@ -34,6 +34,20 @@ actor SonarQubeClient {
try await get("api/qualitygates/project_status", query: ["projectKey": projectKey])
}
func createProject(name: String, key: String, visibility: String? = nil) async throws {
var fields = ["name": name, "project": key]
if let visibility, !visibility.isEmpty { fields["visibility"] = visibility }
try await postForm("api/projects/create", fields: fields)
}
func generateProjectAnalysisToken(projectKey: String, tokenName: String) async throws -> TokenGenerateResponse {
try await postForm("api/user_tokens/generate", fields: [
"name": tokenName,
"type": "PROJECT_ANALYSIS_TOKEN",
"projectKey": projectKey
])
}
func issues(projectKey: String? = nil, type: String? = nil, page: Int = 1, pageSize: Int = 25) async throws -> IssuesResponse {
var q = ["p":"\(page)", "ps":"\(pageSize)", "resolved":"false", "s":"CREATION_DATE", "asc":"false"]
if let projectKey { q["componentKeys"] = projectKey }
@@ -41,6 +55,37 @@ actor SonarQubeClient {
return try await get("api/issues/search", query: q)
}
private func postForm<T: Decodable>(_ path: String, fields: [String: String]) async throws -> T {
let data = try await postFormData(path, fields: fields)
do { return try JSONDecoder().decode(T.self, from: data) }
catch { throw APIError.decoding(error.localizedDescription) }
}
private func postForm(_ path: String, fields: [String: String]) async throws {
_ = try await postFormData(path, fields: fields)
}
private func postFormData(_ path: String, fields: [String: String]) async throws -> Data {
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: base + "/" + path) else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var components = URLComponents()
components.queryItems = fields.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) }
request.httpBody = components.percentEncodedQuery?.data(using: .utf8)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
if (200...299).contains(http.statusCode) { return data }
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errors = object["errors"] as? [[String: Any]],
let message = errors.first?["msg"] as? String { throw APIError.serverMessage(message) }
throw APIError.http(http.statusCode)
}
private func get<T: Decodable>(_ path: String, query: [String:String] = [:]) async throws -> T {
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard var components = URLComponents(string: base + "/" + path),
@@ -95,7 +140,7 @@ actor SonarQubeClient {
}
enum APIError: LocalizedError {
case invalidURL, insecureURL, invalidResponse, authentication, http(Int), decoding(String), notConfigured
case invalidURL, insecureURL, invalidResponse, authentication, http(Int), decoding(String), notConfigured, serverMessage(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "The server URL is invalid."
@@ -105,6 +150,7 @@ enum APIError: LocalizedError {
case .http(let code): return "SonarQube returned HTTP \(code)."
case .decoding(let text): return "Could not read the SonarQube response: \(text)"
case .notConfigured: return "Configure a SonarQube server in Settings."
case .serverMessage(let message): return message
}
}
}
+58
View File
@@ -70,6 +70,33 @@ final class AppState: ObservableObject {
} catch { errorMessage = error.localizedDescription }
}
func createSonarQubeProject(name: String, key: String, visibility: String, tokenName: String) async throws -> CreatedProjectCredentials {
guard selectedServer != nil else { throw APIError.notConfigured }
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanName.isEmpty, !cleanKey.isEmpty else { throw ProjectCreationError.missingNameOrKey }
let client = try client()
try await client.createProject(name: cleanName, key: cleanKey, visibility: visibility)
await refresh()
return try await generateScanCredentials(projectKey: cleanKey, projectName: cleanName, tokenName: tokenName)
}
func generateScanCredentials(projectKey: String, projectName: String? = nil, tokenName: String = "") async throws -> CreatedProjectCredentials {
guard let server = selectedServer else { throw APIError.notConfigured }
let cleanKey = projectKey.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedName = projectName ?? projects.first(where: { $0.project.key == cleanKey })?.project.name ?? cleanKey
let suppliedName = tokenName.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveTokenName = suppliedName.isEmpty ? "SonarDesk - \(resolvedName) - \(Int(Date().timeIntervalSince1970))" : suppliedName
let generated = try await client().generateProjectAnalysisToken(projectKey: cleanKey, tokenName: effectiveTokenName)
try KeychainService.saveProjectToken(generated.token, serverID: server.id, projectKey: cleanKey)
return CreatedProjectCredentials(projectKey: cleanKey, projectName: resolvedName, tokenName: effectiveTokenName, token: generated.token, serverURL: server.baseURL)
}
func storedProjectToken(projectKey: String) -> String? {
guard let server = selectedServer else { return nil }
return KeychainService.projectToken(serverID: server.id, projectKey: projectKey)
}
func loadIssues(projectKey: String? = nil, filter: IssueFilter = .all, page: Int = 1, pageSize: Int = 25) async {
isLoading = true; errorMessage = nil
defer { isLoading = false }
@@ -140,6 +167,31 @@ final class AppState: ObservableObject {
saveProjectOrder()
}
func moveProject(_ sourceKey: String, before targetKey: String) {
guard sourceKey != targetKey,
let sourceIndex = projects.firstIndex(where: { $0.project.key == sourceKey }),
let targetIndex = projects.firstIndex(where: { $0.project.key == targetKey }) else { return }
let item = projects.remove(at: sourceIndex)
let adjustedTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
projects.insert(item, at: max(0, min(adjustedTarget, projects.count)))
saveProjectOrder()
}
func allIssues(projectKey: String) async throws -> [Issue] {
let api = try client()
let pageSize = 500
var page = 1
var result: [Issue] = []
while true {
let response = try await api.issues(projectKey: projectKey, page: page, pageSize: pageSize)
result.append(contentsOf: response.issues)
let total = response.total ?? result.count
if result.count >= total || response.issues.isEmpty { break }
page += 1
}
return result
}
private func applySavedProjectOrder(to snapshots: [ProjectSnapshot]) -> [ProjectSnapshot] {
let alphabetical = snapshots.sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending }
let keys = (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json"))?.projectOrder ?? []
@@ -269,3 +321,9 @@ enum ServerConfigurationError: LocalizedError {
}
}
}
enum ProjectCreationError: LocalizedError {
case missingNameOrKey
var errorDescription: String? { "Enter both a project name and project key." }
}
@@ -9,6 +9,7 @@ struct DashboardView: View {
@State private var pageSize = 10
@State private var failedOnly = false
@State private var search = ""
@State private var showingCreateProject = false
private func total(_ key: String) -> Int { app.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) } }
private var failed: Int { app.projects.filter { $0.qualityGate != "OK" }.count }
@@ -41,6 +42,7 @@ struct DashboardView: View {
}.padding(26)
}
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingCreateProject) { CreateProjectView().environmentObject(app) }
.onChange(of: pageSize) { _, _ in projectPage = 1 }
.onChange(of: search) { _, _ in projectPage = 1 }
}
@@ -119,6 +121,7 @@ struct DashboardView: View {
}
Spacer()
if failedOnly { Button("Show All") { failedOnly = false; projectPage = 1 }.buttonStyle(.bordered) }
Button { showingCreateProject = true } label: { Label("New Project", systemImage: "plus") }.buttonStyle(.borderedProminent)
TextField("Search projects…", text: $search).textFieldStyle(.roundedBorder).frame(width: 210)
Picker("Rows", selection: $pageSize) { Text("10 per page").tag(10); Text("25 per page").tag(25); Text("50 per page").tag(50) }.labelsHidden().frame(width: 120)
}.padding(16)
@@ -148,6 +151,12 @@ struct DashboardView: View {
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary).frame(width: 18)
}.padding(.horizontal, 16).padding(.vertical, 10).contentShape(Rectangle())
}.buttonStyle(.plain)
.draggable(item.project.key)
.dropDestination(for: String.self) { keys, _ in
guard let sourceKey = keys.first else { return false }
app.moveProject(sourceKey, before: item.project.key)
return true
}
if index < pageRows.count - 1 { Divider().padding(.leading, 16) }
}
if filtered.isEmpty { ContentUnavailableView("No projects", systemImage: "shippingbox", description: Text("No projects match the current view.")).padding(30) }
+15 -1
View File
@@ -7,6 +7,7 @@ struct GlobalMetricView: View {
@State private var page = 1
@State private var pageSize = 25
@State private var search = ""
@State private var showingCreateProject = false
private var title: String {
switch metric { case .projects: "Projects"; case .failedGates: "Failed Quality Gates"; case .securityHotspots: "Security Hotspots"; case .qualityGates: "Quality Gates"; case .measures: "Measures"; case .coverage: "Coverage"; case .duplication: "Duplication"; case .lines: "Lines of Code"; case .bugs: "Bugs"; case .vulnerabilities: "Vulnerabilities"; case .codeSmells: "Code Smells" }
@@ -19,7 +20,13 @@ struct GlobalMetricView: View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 4) { Text(title).font(.system(size: 30, weight: .bold)); Text("Project-level SonarQube metrics").foregroundStyle(.secondary) }
Spacer(); TextField("Search projects…", text: $search).textFieldStyle(.roundedBorder).frame(width: 220)
Spacer()
if metric == .projects {
Button { showingCreateProject = true } label: { Label("New Project", systemImage: "plus") }
.buttonStyle(.borderedProminent)
.disabled(app.selectedServer == nil)
}
TextField("Search projects…", text: $search).textFieldStyle(.roundedBorder).frame(width: 220)
Picker("Rows", selection: $pageSize) { Text("25").tag(25); Text("50").tag(50); Text("100").tag(100) }.frame(width: 90)
}.padding(24)
Divider()
@@ -33,10 +40,17 @@ struct GlobalMetricView: View {
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
}.padding(.vertical, 6).contentShape(Rectangle())
}.buttonStyle(.plain)
.draggable(item.project.key)
.dropDestination(for: String.self) { keys, _ in
guard metric == .projects, let sourceKey = keys.first else { return false }
app.moveProject(sourceKey, before: item.project.key)
return true
}
}.listStyle(.inset)
Divider()
HStack { Text("\(rows.count) projects").font(.caption).foregroundStyle(.secondary); Spacer(); Button { page -= 1 } label: { Image(systemName:"chevron.left") }.disabled(page <= 1); Text("Page \(page) of \(pages)").font(.caption.monospacedDigit()); Button { page += 1 } label: { Image(systemName:"chevron.right") }.disabled(page >= pages) }.padding(12)
}
.sheet(isPresented: $showingCreateProject) { CreateProjectView().environmentObject(app) }
.onChange(of: search) { _,_ in page = 1 }.onChange(of: pageSize) { _,_ in page = 1 }
}
@@ -0,0 +1,129 @@
import SwiftUI
import AppKit
struct CreateProjectView: View {
@EnvironmentObject private var app: AppState
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var key = ""
@State private var visibility = "private"
@State private var tokenName = ""
@State private var creating = false
@State private var error: String?
@State private var created: CreatedProjectCredentials?
var body: some View {
Group {
if let created { success(created) } else { form.padding(24) }
}
.frame(width: 760)
.frame(minHeight: 430)
}
private var form: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text("Create SonarQube Project").font(.title2.bold())
Text("Creates a real project on \(app.selectedServer?.name ?? "the selected SonarQube server") and generates a project analysis token.").foregroundStyle(.secondary)
}
Form {
TextField("Project name", text: $name)
TextField("Project key", text: $key).textContentType(.none)
Picker("Visibility", selection: $visibility) { Text("Private").tag("private"); Text("Public").tag("public") }
TextField("Analysis token name (optional)", text: $tokenName)
}.formStyle(.grouped)
if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) }
HStack { Spacer(); Button("Cancel") { dismiss() }; Button { Task { await create() } } label: { if creating { ProgressView().controlSize(.small) } else { Text("Create Project") } }.buttonStyle(.borderedProminent).disabled(creating || name.trimmingCharacters(in: .whitespaces).isEmpty || key.trimmingCharacters(in: .whitespaces).isEmpty) }
}
}
private func success(_ value: CreatedProjectCredentials) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Label("Project Created", systemImage: "checkmark.seal.fill").font(.title2.bold()).foregroundStyle(.green)
Text("The project is ready to scan. The token is stored securely in Keychain; SonarQube only returns a newly generated token once.").foregroundStyle(.secondary)
valueRow("Project", value.projectName)
valueRow("Project key", value.projectKey)
valueRow("Server", value.serverURL)
codeBox(title: "Analysis token", text: value.token, copyLabel: "Copy Token")
codeBox(title: "sonar-project.properties", text: properties(value), copyLabel: "Copy")
codeBox(title: "scripts/scan.sh", text: scanScript(value), copyLabel: "Copy Script")
HStack {
Button { saveScanScript(value) } label: { Label("Save scan.sh…", systemImage: "square.and.arrow.down") }.buttonStyle(.borderedProminent)
Button { saveProperties(value) } label: { Label("Save sonar-project.properties…", systemImage: "doc.badge.plus") }.buttonStyle(.bordered)
}
instructions
HStack { Spacer(); Button("Done") { dismiss() }.buttonStyle(.borderedProminent) }
}.padding(24)
}
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 8) {
Text("On-screen instructions").font(.headline)
Text("1. Save scan.sh into your project's scripts directory.\n2. Open Terminal in the project directory.\n3. Make the script executable: chmod +x scripts/scan.sh\n4. Set the analysis token: export SONAR_TOKEN=\"YOUR_GENERATED_TOKEN\"\n5. Run the scan: ./scripts/scan.sh\n6. Return to SonarDesk and refresh the project to see the latest analysis.")
.font(.system(.body, design: .monospaced)).textSelection(.enabled)
}.padding(14).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
}
private func codeBox(title: String, text: String, copyLabel: String) -> some View {
VStack(alignment: .leading, spacing: 7) {
HStack { Text(title).font(.headline); Spacer(); Button(copyLabel) { copy(text) } }
Text(text).font(.system(.body, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}.padding(14).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
}
private func properties(_ value: CreatedProjectCredentials) -> String {
"sonar.projectKey=\(value.projectKey)\nsonar.projectName=\(value.projectName)\nsonar.sources=.\nsonar.host.url=\(value.serverURL)"
}
private func scanScript(_ value: CreatedProjectCredentials) -> String {
let projectKey = bashEscape(value.projectKey), projectName = bashEscape(value.projectName), url = bashEscape(value.serverURL)
return """
#!/usr/bin/env bash
set -euo pipefail
PROJECT_KEY="\(projectKey)"
PROJECT_NAME="\(projectName)"
SONAR_URL="\(url)"
: "${SONAR_TOKEN:?SONAR_TOKEN is not set}"
echo "Scanning: ${PROJECT_NAME}"
echo "Project key: ${PROJECT_KEY}"
echo "SonarQube: ${SONAR_URL}"
echo
sonar-scanner \\
-Dsonar.projectKey="${PROJECT_KEY}" \\
-Dsonar.projectName="${PROJECT_NAME}" \\
-Dsonar.sources=. \\
-Dsonar.host.url="${SONAR_URL}" \\
-Dsonar.token="${SONAR_TOKEN}" \\
-Dsonar.exclusions="vendor/**,node_modules/**,storage/**,bootstrap/cache/**,public/build/**,.scannerwork/**"
"""
}
private func bashEscape(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "$", with: "\\$").replacingOccurrences(of: "`", with: "\\`") }
private func copy(_ text: String) { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(text, forType: .string) }
private func valueRow(_ label: String, _ value: String) -> some View { HStack { Text(label).foregroundStyle(.secondary).frame(width: 100, alignment: .leading); Text(value).textSelection(.enabled); Spacer() } }
private func saveScanScript(_ value: CreatedProjectCredentials) { save(text: scanScript(value), suggestedName: "scan.sh", executable: true) }
private func saveProperties(_ value: CreatedProjectCredentials) { save(text: properties(value) + "\n", suggestedName: "sonar-project.properties", executable: false) }
private func save(text: String, suggestedName: String, executable: Bool) {
let panel = NSSavePanel(); panel.nameFieldStringValue = suggestedName; panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
try text.write(to: url, atomically: true, encoding: .utf8)
if executable { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) }
} catch { self.error = error.localizedDescription }
}
@MainActor private func create() async {
creating = true; error = nil
defer { creating = false }
do { created = try await app.createSonarQubeProject(name: name, key: key, visibility: visibility, tokenName: tokenName) }
catch { self.error = error.localizedDescription }
}
}
@@ -1,9 +1,15 @@
import SwiftUI
import AppKit
struct ProjectDetailView: View {
@EnvironmentObject var app: AppState
let projectKey: String
@State private var section: ProjectSection = .overview
@State private var scanCredentials: CreatedProjectCredentials?
@State private var credentialError: String?
@State private var generatingCredentials = false
@State private var exportingAI = false
@State private var exportError: String?
private var item: ProjectSnapshot? { app.projects.first { $0.project.key == projectKey } }
var body: some View {
@@ -11,7 +17,11 @@ struct ProjectDetailView: View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 4) { Text(item.project.name).font(.system(size: 30, weight: .bold)); Text(item.project.key).foregroundStyle(.secondary) }
Spacer(); GateBadge(status: item.qualityGate)
Spacer()
Button { Task { await exportAIReport(item) } } label: {
if exportingAI { ProgressView().controlSize(.small) } else { Label("Export for AI Analysis…", systemImage: "brain.head.profile") }
}.buttonStyle(.bordered).disabled(exportingAI)
GateBadge(status: item.qualityGate)
}.padding(.horizontal, 26).padding(.top, 22).padding(.bottom, 14)
ScrollView(.horizontal, showsIndicators: false) {
@@ -26,6 +36,7 @@ struct ProjectDetailView: View {
projectNav(.lines, "chevron.left.forwardslash.chevron.right", .blue)
projectNav(.qualityGate, "checkmark.seal.fill", .green)
projectNav(.measures, "chart.bar.xaxis", .cyan)
projectNav(.scanDetails, "key.fill", .indigo)
}.padding(.horizontal, 26).padding(.bottom, 14)
}
Divider()
@@ -41,6 +52,7 @@ struct ProjectDetailView: View {
case .lines: metricDetail("Lines of Code", item.measures["ncloc"] ?? "0", "chevron.left.forwardslash.chevron.right", .blue, "Non-comment lines of code measured by SonarQube.")
case .qualityGate: metricDetail("Quality Gate", item.qualityGate == "OK" ? "Passed" : item.qualityGate.capitalized, item.qualityGate == "OK" ? "checkmark.seal.fill" : "xmark.seal.fill", item.qualityGate == "OK" ? .green : .red, "Current quality-gate result for this project.")
case .measures: measures(item)
case .scanDetails: scanDetails(item)
}
}
} else { ContentUnavailableView("Project unavailable", systemImage: "shippingbox") }
@@ -78,9 +90,160 @@ struct ProjectDetailView: View {
private func measures(_ item: ProjectSnapshot) -> some View {
ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) { ForEach(item.measures.keys.sorted(), id: \.self) { key in MetricCard(title: key.replacingOccurrences(of: "_", with: " ").capitalized, value: item.measures[key] ?? "—", systemImage: "chart.bar.xaxis", tint: .cyan) } }.padding(26) }
}
private func scanDetails(_ item: ProjectSnapshot) -> some View {
let credentials = scanCredentials ?? app.storedProjectToken(projectKey: projectKey).map {
CreatedProjectCredentials(projectKey: item.project.key, projectName: item.project.name, tokenName: "Stored token", token: $0, serverURL: app.selectedServer?.baseURL ?? "")
}
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
Text("Scan Details").font(.title2.bold())
Text("Everything required to analyse this project with SonarScanner. Tokens are stored in Keychain and are never written into scan.sh.").foregroundStyle(.secondary)
detailRow("Project name", item.project.name)
detailRow("Project key", item.project.key)
detailRow("Server", app.selectedServer?.baseURL ?? "—")
if let credentials { secretRow(credentials.token) }
else { Text("No analysis token is stored for this project on this Mac. Generate one below.").foregroundStyle(.secondary) }
codeBox("sonar-project.properties", properties(item))
codeBox("scripts/scan.sh", scanScript(item))
HStack {
Button { saveText(scanScript(item), name: "scan.sh", executable: true) } label: { Label("Save scan.sh…", systemImage: "square.and.arrow.down") }.buttonStyle(.borderedProminent)
Button { saveText(properties(item) + "\n", name: "sonar-project.properties", executable: false) } label: { Label("Save sonar-project.properties…", systemImage: "doc.badge.plus") }.buttonStyle(.bordered)
}
VStack(alignment: .leading, spacing: 8) {
Text("How to run the scan").font(.headline)
Text("1. Save scan.sh into your project's scripts directory.\n2. Open Terminal in the project directory.\n3. Run: chmod +x scripts/scan.sh\n4. Set the token: export SONAR_TOKEN=\"YOUR_ANALYSIS_TOKEN\"\n5. Run: ./scripts/scan.sh\n6. Return to SonarDesk and refresh this project.")
.font(.system(.body, design: .monospaced)).textSelection(.enabled)
}.padding(14).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
if let credentialError { Text(credentialError).foregroundStyle(.red).textSelection(.enabled) }
if let exportError { Text(exportError).foregroundStyle(.red).textSelection(.enabled) }
Button { Task { await generateCredentials(item) } } label: {
if generatingCredentials { ProgressView().controlSize(.small) } else { Label("Generate New Analysis Token", systemImage: "key.fill") }
}.buttonStyle(.borderedProminent).disabled(generatingCredentials)
}.padding(26).frame(maxWidth: 950, alignment: .leading)
}
}
private func properties(_ item: ProjectSnapshot) -> String {
"sonar.projectKey=\(item.project.key)\nsonar.projectName=\(item.project.name)\nsonar.sources=.\nsonar.host.url=\(app.selectedServer?.baseURL ?? "")"
}
private func scanScript(_ item: ProjectSnapshot) -> String {
let key = bashEscape(item.project.key), name = bashEscape(item.project.name), url = bashEscape(app.selectedServer?.baseURL ?? "")
return """
#!/usr/bin/env bash
set -euo pipefail
PROJECT_KEY="\(key)"
PROJECT_NAME="\(name)"
SONAR_URL="\(url)"
: "${SONAR_TOKEN:?SONAR_TOKEN is not set}"
echo "Scanning: ${PROJECT_NAME}"
echo "Project key: ${PROJECT_KEY}"
echo "SonarQube: ${SONAR_URL}"
echo
sonar-scanner \\
-Dsonar.projectKey="${PROJECT_KEY}" \\
-Dsonar.projectName="${PROJECT_NAME}" \\
-Dsonar.sources=. \\
-Dsonar.host.url="${SONAR_URL}" \\
-Dsonar.token="${SONAR_TOKEN}" \\
-Dsonar.exclusions="vendor/**,node_modules/**,storage/**,bootstrap/cache/**,public/build/**,.scannerwork/**"
"""
}
private func codeBox(_ title: String, _ text: String) -> some View {
VStack(alignment: .leading, spacing: 7) {
HStack { Text(title).font(.headline); Spacer(); Button("Copy") { copy(text) } }
Text(text).font(.system(.body, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}.padding(14).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
}
private func bashEscape(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "$", with: "\\$").replacingOccurrences(of: "`", with: "\\`") }
private func copy(_ text: String) { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(text, forType: .string) }
private func saveText(_ text: String, name: String, executable: Bool) {
let panel = NSSavePanel(); panel.nameFieldStringValue = name; panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try text.write(to: url, atomically: true, encoding: .utf8); if executable { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) } }
catch { credentialError = error.localizedDescription }
}
@MainActor private func exportAIReport(_ item: ProjectSnapshot) async {
exportingAI = true; exportError = nil
defer { exportingAI = false }
do {
let issues = try await app.allIssues(projectKey: item.project.key)
let report = aiReport(item, issues: issues)
let panel = NSSavePanel(); panel.nameFieldStringValue = "\(item.project.key)-sonarqube-ai-analysis.md"; panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
try report.write(to: url, atomically: true, encoding: .utf8)
} catch { exportError = error.localizedDescription }
}
private func aiReport(_ item: ProjectSnapshot, issues: [Issue]) -> String {
let sorted = issues.sorted { ($0.severity ?? "") > ($1.severity ?? "") }
var out = """
# SonarQube AI Analysis Report
## AI Analysis Instructions
Analyse this SonarQube report as a senior software engineer. Prioritise bugs and reliability problems, security vulnerabilities and hotspots, high-severity code smells, maintainability problems, duplicated code, complexity, and test coverage gaps. For each finding explain the problem and impact, identify the affected file and line where available, recommend a concrete fix, and provide corrected code where enough context is available. Group related findings and avoid changing application behaviour unnecessarily.
## Project
- Name: \(item.project.name)
- Key: \(item.project.key)
- Server: \(app.selectedServer?.baseURL ?? "Unknown")
- Quality Gate: \(item.qualityGate)
- Last analysis: \(item.project.lastAnalysisDate ?? "Unknown")
- Exported: \(ISO8601DateFormatter().string(from: Date()))
## Measures
"""
for key in item.measures.keys.sorted() { out += "\n- \(key): \(item.measures[key] ?? "—")" }
out += "\n\n## Issues (\(issues.count))\n"
if sorted.isEmpty { out += "\nNo unresolved issues were returned by SonarQube.\n" }
for (index, issue) in sorted.enumerated() {
out += """
### \(index + 1). \(issue.message ?? "Issue")
- Type: \(issue.type ?? "Unknown")
- Severity: \(issue.severity ?? "Unknown")
- Rule: \(issue.rule ?? "Unknown")
- Component: \(issue.component ?? "Unknown")
- Line: \(issue.line.map(String.init) ?? "Unknown")
- Status: \(issue.status ?? "Unknown")
- Effort: \(issue.effort ?? "Unknown")
- Created: \(issue.creationDate ?? "Unknown")
- SonarQube issue key: \(issue.key)
"""
}
return out + "\n"
}
private func detailRow(_ label: String, _ value: String) -> some View {
HStack(alignment: .firstTextBaseline) { Text(label).foregroundStyle(.secondary).frame(width: 120, alignment: .leading); Text(value).textSelection(.enabled); Spacer() }
}
private func secretRow(_ token: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Analysis token").font(.headline)
HStack { Text(token).font(.system(.body, design: .monospaced)).textSelection(.enabled); Spacer(); Button("Copy") { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(token, forType: .string) } }
}.padding(14).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
}
@MainActor private func generateCredentials(_ item: ProjectSnapshot) async {
generatingCredentials = true; credentialError = nil
defer { generatingCredentials = false }
do { scanCredentials = try await app.generateScanCredentials(projectKey: item.project.key, projectName: item.project.name) }
catch { credentialError = error.localizedDescription }
}
}
private enum ProjectSection: Hashable {
case overview, bugs, vulnerabilities, codeSmells, securityHotspots, coverage, duplication, lines, qualityGate, measures
var title: String { switch self { case .overview:"Overview"; case .bugs:"Bugs"; case .vulnerabilities:"Vulnerabilities"; case .codeSmells:"Code Smells"; case .securityHotspots:"Hotspots"; case .coverage:"Coverage"; case .duplication:"Duplication"; case .lines:"Lines"; case .qualityGate:"Quality Gate"; case .measures:"Measures" } }
case overview, bugs, vulnerabilities, codeSmells, securityHotspots, coverage, duplication, lines, qualityGate, measures, scanDetails
var title: String { switch self { case .overview:"Overview"; case .bugs:"Bugs"; case .vulnerabilities:"Vulnerabilities"; case .codeSmells:"Code Smells"; case .securityHotspots:"Hotspots"; case .coverage:"Coverage"; case .duplication:"Duplication"; case .lines:"Lines"; case .qualityGate:"Quality Gate"; case .measures:"Measures"; case .scanDetails:"Scan Details" } }
}
+6
View File
@@ -66,6 +66,12 @@ struct RootView: View {
sidebarHeading("PROJECTS")
ForEach(app.projects) { snapshot in
sidebarItem(snapshot.project.name, "shippingbox", .project(snapshot.project.key), SonarTheme.info)
.draggable(snapshot.project.key)
.dropDestination(for: String.self) { keys, _ in
guard let sourceKey = keys.first else { return false }
app.moveProject(sourceKey, before: snapshot.project.key)
return true
}
.contextMenu {
Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }.disabled(app.projects.first?.project.key == snapshot.project.key)
Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }.disabled(app.projects.last?.project.key == snapshot.project.key)
View File