From e8b0a1ce57eed46c3ced0ac09a9c4ea0ab06e2f6 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 29 Sep 2026 16:55:12 +0100 Subject: [PATCH] updated deployer code --- SonarDesk/Models/Models.swift | 17 ++ SonarDesk/Services/KeychainService.swift | 23 +++ SonarDesk/Services/SonarQubeClient.swift | 48 ++++- SonarDesk/Stores/AppState.swift | 58 ++++++ SonarDesk/Views/Dashboard/DashboardView.swift | 9 + .../Views/Metrics/GlobalMetricView.swift | 16 +- .../Views/Projects/CreateProjectView.swift | 129 +++++++++++++ .../Views/Projects/ProjectDetailView.swift | 169 +++++++++++++++++- SonarDesk/Views/RootView.swift | 6 + README.md => docs/README.md | 0 .../RELEASE-HARDENING.md | 0 11 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 SonarDesk/Views/Projects/CreateProjectView.swift rename README.md => docs/README.md (100%) rename RELEASE-HARDENING.md => docs/RELEASE-HARDENING.md (100%) diff --git a/SonarDesk/Models/Models.swift b/SonarDesk/Models/Models.swift index 9f94fde..a6b86a8 100644 --- a/SonarDesk/Models/Models.swift +++ b/SonarDesk/Models/Models.swift @@ -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 +} diff --git a/SonarDesk/Services/KeychainService.swift b/SonarDesk/Services/KeychainService.swift index 25f50ac..c9df283 100644 --- a/SonarDesk/Services/KeychainService.swift +++ b/SonarDesk/Services/KeychainService.swift @@ -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) } diff --git a/SonarDesk/Services/SonarQubeClient.swift b/SonarDesk/Services/SonarQubeClient.swift index 3bab845..a865a9a 100644 --- a/SonarDesk/Services/SonarQubeClient.swift +++ b/SonarDesk/Services/SonarQubeClient.swift @@ -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(_ 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(_ 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 } } } diff --git a/SonarDesk/Stores/AppState.swift b/SonarDesk/Stores/AppState.swift index 9262879..47c3d83 100644 --- a/SonarDesk/Stores/AppState.swift +++ b/SonarDesk/Stores/AppState.swift @@ -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." } +} diff --git a/SonarDesk/Views/Dashboard/DashboardView.swift b/SonarDesk/Views/Dashboard/DashboardView.swift index e43ea53..3d3437d 100644 --- a/SonarDesk/Views/Dashboard/DashboardView.swift +++ b/SonarDesk/Views/Dashboard/DashboardView.swift @@ -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) } diff --git a/SonarDesk/Views/Metrics/GlobalMetricView.swift b/SonarDesk/Views/Metrics/GlobalMetricView.swift index 7ed85b3..8c0165a 100644 --- a/SonarDesk/Views/Metrics/GlobalMetricView.swift +++ b/SonarDesk/Views/Metrics/GlobalMetricView.swift @@ -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 } } diff --git a/SonarDesk/Views/Projects/CreateProjectView.swift b/SonarDesk/Views/Projects/CreateProjectView.swift new file mode 100644 index 0000000..544b14e --- /dev/null +++ b/SonarDesk/Views/Projects/CreateProjectView.swift @@ -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 } + } +} diff --git a/SonarDesk/Views/Projects/ProjectDetailView.swift b/SonarDesk/Views/Projects/ProjectDetailView.swift index e274116..8a3b586 100644 --- a/SonarDesk/Views/Projects/ProjectDetailView.swift +++ b/SonarDesk/Views/Projects/ProjectDetailView.swift @@ -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" } } } diff --git a/SonarDesk/Views/RootView.swift b/SonarDesk/Views/RootView.swift index 1a8c925..0576150 100644 --- a/SonarDesk/Views/RootView.swift +++ b/SonarDesk/Views/RootView.swift @@ -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) diff --git a/README.md b/docs/README.md similarity index 100% rename from README.md rename to docs/README.md diff --git a/RELEASE-HARDENING.md b/docs/RELEASE-HARDENING.md similarity index 100% rename from RELEASE-HARDENING.md rename to docs/RELEASE-HARDENING.md