updated deployer code
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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" } }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user