189 lines
13 KiB
Swift
189 lines
13 KiB
Swift
import AppKit
|
|
import CoreGraphics
|
|
import Foundation
|
|
import UniformTypeIdentifiers
|
|
|
|
@MainActor
|
|
enum ProjectPDFReportExporter {
|
|
static func export(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws {
|
|
let panel = NSSavePanel()
|
|
panel.canCreateDirectories = true
|
|
panel.allowedContentTypes = [.pdf]
|
|
panel.nameFieldStringValue = "SonarDesk-\(safe(project.project.name))-\(dateStamp()).pdf"
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
|
try makePDF(project: project, issues: issues, serverURL: serverURL, serverVersion: serverVersion).write(to: url, options: .atomic)
|
|
}
|
|
|
|
static func makePDF(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws -> Data {
|
|
let data = NSMutableData()
|
|
guard let consumer = CGDataConsumer(data: data as CFMutableData) else { throw ExportError.couldNotCreatePDF }
|
|
var mediaBox = CGRect(x: 0, y: 0, width: 595, height: 842)
|
|
guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { throw ExportError.couldNotCreatePDF }
|
|
let writer = Writer(context: context, page: mediaBox)
|
|
|
|
writer.begin(title: "Project Quality Report", project: project.project.name)
|
|
writer.heading("Executive Summary")
|
|
writer.paragraph(summary(project: project, issues: issues))
|
|
writer.metricGrid([
|
|
("Quality Gate", project.qualityGate == "OK" ? "PASSED" : project.qualityGate),
|
|
("Bugs", metric(project, "bugs")),
|
|
("Vulnerabilities", metric(project, "vulnerabilities")),
|
|
("Code Smells", metric(project, "code_smells")),
|
|
("Security Hotspots", metric(project, "security_hotspots")),
|
|
("Coverage", metric(project, "coverage") + "%"),
|
|
("Duplications", metric(project, "duplicated_lines_density") + "%"),
|
|
("Lines of Code", metric(project, "ncloc"))
|
|
])
|
|
|
|
writer.heading("Issue Overview")
|
|
let severities = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"].map { severity in
|
|
(severity.capitalized, issues.filter { ($0.severity ?? "").uppercased() == severity }.count)
|
|
}
|
|
writer.barChart(severities)
|
|
writer.smallText("Open issues exported: \(issues.count). Counts and classifications are supplied by SonarQube.")
|
|
|
|
writer.heading("Project Information")
|
|
writer.keyValues([
|
|
("Project", project.project.name), ("Project key", project.project.key),
|
|
("SonarQube server", serverURL), ("SonarQube version", serverVersion ?? "Unknown"),
|
|
("Last analysis", project.project.lastAnalysisDate ?? "Unknown"),
|
|
("Report generated", Date().formatted(date: .long, time: .shortened))
|
|
])
|
|
|
|
writer.heading("Detailed Findings")
|
|
if issues.isEmpty {
|
|
writer.paragraph("No unresolved issues were returned by SonarQube for this project.")
|
|
} else {
|
|
for (index, issue) in sorted(issues).enumerated() {
|
|
writer.issue(index: index + 1, issue: issue)
|
|
}
|
|
}
|
|
|
|
writer.heading("About this report")
|
|
writer.paragraph("This report was generated locally by SonarDesk from data returned by the connected SonarQube server. SonarDesk has not independently audited the source code. Findings, ratings and quality-gate results should be interpreted in the context of the SonarQube configuration and analysis used for this project.")
|
|
writer.finish()
|
|
context.closePDF()
|
|
return data as Data
|
|
}
|
|
|
|
private static func metric(_ p: ProjectSnapshot, _ key: String) -> String { p.measures[key] ?? "0" }
|
|
private static func safe(_ value: String) -> String { value.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") }
|
|
private static func dateStamp() -> String { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: Date()) }
|
|
private static func sorted(_ issues: [Issue]) -> [Issue] {
|
|
let rank = ["BLOCKER": 0, "CRITICAL": 1, "MAJOR": 2, "MINOR": 3, "INFO": 4]
|
|
return issues.sorted { (rank[$0.severity?.uppercased() ?? ""] ?? 99) < (rank[$1.severity?.uppercased() ?? ""] ?? 99) }
|
|
}
|
|
private static func summary(project: ProjectSnapshot, issues: [Issue]) -> String {
|
|
let gate = project.qualityGate == "OK" ? "passed" : project.qualityGate.lowercased()
|
|
return "The SonarQube quality gate is \(gate). The current analysis reports \(metric(project, "bugs")) bugs, \(metric(project, "vulnerabilities")) vulnerabilities, \(metric(project, "code_smells")) code smells and \(metric(project, "security_hotspots")) security hotspots. Test coverage is \(metric(project, "coverage"))% and duplicated-lines density is \(metric(project, "duplicated_lines_density"))%. This report contains \(issues.count) unresolved findings returned by SonarQube."
|
|
}
|
|
|
|
enum ExportError: LocalizedError { case couldNotCreatePDF; var errorDescription: String? { "SonarDesk could not create the PDF report." } }
|
|
|
|
private final class Writer {
|
|
private let context: CGContext
|
|
private let page: CGRect
|
|
private let margin: CGFloat = 42
|
|
private var y: CGFloat = 0
|
|
private var pageNumber = 0
|
|
private var projectName = ""
|
|
private let accent = NSColor(calibratedRed: 0.95, green: 0.42, blue: 0.12, alpha: 1)
|
|
init(context: CGContext, page: CGRect) { self.context = context; self.page = page }
|
|
|
|
func begin(title: String, project: String) {
|
|
projectName = project; newPage()
|
|
text("SONARDESK", x: margin, y: y, width: 220, font: .boldSystemFont(ofSize: 13), color: accent); y += 28
|
|
text(title, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 30)); y += 42
|
|
text(project, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 17), color: .darkGray); y += 36
|
|
rule(); y += 18
|
|
}
|
|
func finish() {
|
|
footer()
|
|
endPage()
|
|
}
|
|
|
|
private func newPage() {
|
|
if pageNumber > 0 {
|
|
footer()
|
|
endPage()
|
|
}
|
|
|
|
context.beginPDFPage(nil)
|
|
context.saveGState()
|
|
|
|
// Quartz PDF pages use a bottom-left origin. The report layout below is
|
|
// intentionally expressed in normal AppKit top-left coordinates. Flip
|
|
// the PDF context once per page so NSString/NSBezierPath drawing lands
|
|
// on the page instead of being rendered outside/inverted.
|
|
context.translateBy(x: 0, y: page.height)
|
|
context.scaleBy(x: 1, y: -1)
|
|
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
|
|
|
|
pageNumber += 1
|
|
y = 52
|
|
|
|
if pageNumber > 1 {
|
|
text("SONARDESK • \(projectName)", x: margin, y: 25, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 8), color: .gray)
|
|
}
|
|
}
|
|
|
|
private func endPage() {
|
|
NSGraphicsContext.current = nil
|
|
context.restoreGState()
|
|
context.endPDFPage()
|
|
}
|
|
private func footer() {
|
|
rule(at: page.height - 37)
|
|
text("Generated by SonarDesk • SonarQube analysis data", x: margin, y: page.height - 27, width: 360, font: .systemFont(ofSize: 7.5), color: .gray)
|
|
text("Page \(pageNumber)", x: page.width - margin - 60, y: page.height - 27, width: 60, font: .systemFont(ofSize: 7.5), color: .gray, align: .right)
|
|
}
|
|
private func ensure(_ height: CGFloat) { if y + height > page.height - 58 { newPage(); y = 55 } }
|
|
func heading(_ value: String) { ensure(44); y += 10; text(value, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 17)); y += 30 }
|
|
func paragraph(_ value: String) { let h = measured(value, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5)) + 8; ensure(h); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5), color: .darkGray); y += h }
|
|
func smallText(_ value: String) { ensure(28); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 8.5), color: .gray); y += 25 }
|
|
func metricGrid(_ values: [(String,String)]) {
|
|
let w = (page.width - margin * 2 - 12) / 2; let h: CGFloat = 56
|
|
for i in stride(from: 0, to: values.count, by: 2) {
|
|
ensure(h + 10)
|
|
metricBox(values[i], x: margin, width: w, height: h)
|
|
if i + 1 < values.count { metricBox(values[i+1], x: margin + w + 12, width: w, height: h) }
|
|
y += h + 10
|
|
}
|
|
}
|
|
private func metricBox(_ value: (String,String), x: CGFloat, width: CGFloat, height: CGFloat) {
|
|
NSColor(calibratedWhite: 0.96, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: x, y: y, width: width, height: height), xRadius: 7, yRadius: 7).fill()
|
|
text(value.0.uppercased(), x: x + 12, y: y + 10, width: width - 24, font: .boldSystemFont(ofSize: 7.5), color: .gray)
|
|
text(value.1, x: x + 12, y: y + 27, width: width - 24, font: .boldSystemFont(ofSize: 16))
|
|
}
|
|
func barChart(_ values: [(String,Int)]) {
|
|
ensure(CGFloat(values.count) * 30 + 15); let maxValue = max(1, values.map(\.1).max() ?? 1); let chartW = page.width - margin * 2 - 120
|
|
for (label, value) in values {
|
|
text(label, x: margin, y: y + 3, width: 75, font: .systemFont(ofSize: 9))
|
|
NSColor(calibratedWhite: 0.92, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW, height: 12), xRadius: 3, yRadius: 3).fill()
|
|
if value > 0 { accent.setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW * CGFloat(value) / CGFloat(maxValue), height: 12), xRadius: 3, yRadius: 3).fill() }
|
|
text("\(value)", x: page.width - margin - 30, y: y + 2, width: 30, font: .boldSystemFont(ofSize: 9), align: .right); y += 28
|
|
}; y += 4
|
|
}
|
|
func keyValues(_ values: [(String,String)]) {
|
|
for value in values { ensure(25); text(value.0, x: margin, y: y, width: 110, font: .boldSystemFont(ofSize: 9), color: .gray); text(value.1, x: margin + 120, y: y, width: page.width - margin * 2 - 120, font: .systemFont(ofSize: 9)); y += 22 }
|
|
}
|
|
func issue(index: Int, issue: Issue) {
|
|
let message = issue.message ?? "Issue"; let details = "\(issue.type ?? "Unknown") • \(issue.severity ?? "Unknown") • \(issue.status ?? "Unknown")"
|
|
let location = [issue.component, issue.line.map { "line \($0)" }].compactMap { $0 }.joined(separator: " • ")
|
|
let needed = max(88, measured(message, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11)) + 68); ensure(needed)
|
|
NSColor(calibratedWhite: 0.975, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin, y: y, width: page.width - margin * 2, height: needed - 8), xRadius: 6, yRadius: 6).fill()
|
|
text("\(index). \(message)", x: margin + 12, y: y + 10, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11))
|
|
text(details, x: margin + 12, y: y + 34, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 8), color: accent)
|
|
text(location.isEmpty ? "Location unavailable" : location, x: margin + 12, y: y + 50, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8.5), color: .darkGray)
|
|
text("Rule: \(issue.rule ?? "Unknown") • Effort: \(issue.effort ?? "Unknown")", x: margin + 12, y: y + 66, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8), color: .gray)
|
|
y += needed
|
|
}
|
|
private func rule(at value: CGFloat? = nil) { NSColor(calibratedWhite: 0.85, alpha: 1).setStroke(); let p = NSBezierPath(); p.move(to: CGPoint(x: margin, y: value ?? y)); p.line(to: CGPoint(x: page.width - margin, y: value ?? y)); p.lineWidth = 0.6; p.stroke() }
|
|
private func measured(_ value: String, width: CGFloat, font: NSFont) -> CGFloat { (value as NSString).boundingRect(with: CGSize(width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font]).height }
|
|
private func text(_ value: String, x: CGFloat, y: CGFloat, width: CGFloat, font: NSFont, color: NSColor = .black, align: NSTextAlignment = .left) {
|
|
let style = NSMutableParagraphStyle(); style.alignment = align; style.lineBreakMode = .byWordWrapping
|
|
(value as NSString).draw(with: CGRect(x: x, y: y, width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style])
|
|
}
|
|
}
|
|
}
|