|
import Foundation |
|
|
|
// MARK: - Models |
|
|
|
struct TokenDiff { |
|
let oldValue: String |
|
let newValue: String |
|
|
|
enum Kind: String { |
|
case addition = "βοΈ" |
|
case removal = "β" |
|
case update = "π" |
|
} |
|
|
|
var kind: Kind { |
|
if oldValue == Self.emptyValue { return .addition } |
|
if newValue == Self.emptyValue { return .removal } |
|
return .update |
|
} |
|
|
|
static let emptyValue = "-" |
|
|
|
init(old: String?, new: String?) { |
|
self.oldValue = old ?? Self.emptyValue |
|
self.newValue = new ?? Self.emptyValue |
|
} |
|
} |
|
|
|
// MARK: - Shell Commands |
|
|
|
struct ShellCommand { |
|
struct Result { |
|
let output: String |
|
let error: String? |
|
let exitCode: Int32 |
|
|
|
var succeeded: Bool { exitCode == 0 } |
|
var trimmedOutput: String { output.trimmingCharacters(in: .whitespacesAndNewlines) } |
|
} |
|
|
|
static func execute(_ launchPath: String, _ arguments: [String]) -> Result { |
|
let task = Process() |
|
task.executableURL = URL(fileURLWithPath: launchPath) |
|
task.arguments = arguments |
|
|
|
let outputPipe = Pipe() |
|
let errorPipe = Pipe() |
|
task.standardOutput = outputPipe |
|
task.standardError = errorPipe |
|
|
|
do { |
|
try task.run() |
|
} catch { |
|
return Result(output: "", error: "\(error)", exitCode: -1) |
|
} |
|
|
|
task.waitUntilExit() |
|
|
|
let outData = outputPipe.fileHandleForReading.readDataToEndOfFile() |
|
let errData = errorPipe.fileHandleForReading.readDataToEndOfFile() |
|
|
|
let output = String(data: outData, encoding: .utf8) ?? "" |
|
let errorOutput = String(data: errData, encoding: .utf8) |
|
|
|
return Result(output: output, error: errorOutput, exitCode: task.terminationStatus) |
|
} |
|
} |
|
|
|
// MARK: - Git Operations |
|
|
|
struct GitOperations { |
|
static func getFileContent(at path: String, with ref: String) -> String? { |
|
let result = ShellCommand.execute("/usr/bin/git", ["show", "\(ref):\(path)"]) |
|
guard !result.output.isEmpty else { return nil } |
|
return result.output |
|
} |
|
|
|
static func listFiles(in directory: String, ref: String) -> [URL] { |
|
let result = ShellCommand.execute("/usr/bin/git", ["ls-tree", "-r", "--name-only", ref, directory]) |
|
guard result.succeeded else { return [] } |
|
|
|
return result.output |
|
.components(separatedBy: .newlines) |
|
.filter { !$0.isEmpty } |
|
.filter { $0.hasSuffix(".json") && !($0.split(separator: "/").last?.hasPrefix("$") ?? false) } |
|
.map { URL(fileURLWithPath: $0, relativeTo: FileOperations.tokensDirectory) } |
|
} |
|
} |
|
|
|
// MARK: - JSON Processing |
|
|
|
struct JSONProcessor { |
|
static func decodeJSON(_ string: String?) -> [String: Any]? { |
|
guard let string = string, |
|
let data = string.data(using: .utf8), |
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { |
|
return nil |
|
} |
|
return json |
|
} |
|
|
|
static func encodeJSON(_ object: Any) -> Data? { |
|
try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) |
|
} |
|
|
|
/// Flattens a JSON object recursively following the jq logic: |
|
/// - If the value is not a dictionary, return an empty dictionary. |
|
/// - If the dictionary has a key "value", then return [prefix: (if value is a dictionary, then its JSON string, else its string)]. |
|
/// - Otherwise, iterate over each key and flatten recursively. |
|
static func flattenTokens(prefix: String = "", json: Any) -> [String: String] { |
|
guard let dict = json as? [String: Any] else { |
|
print("Error: JSON is not a dictionary: \(json)") |
|
return [:] |
|
} |
|
|
|
// If the dictionary has "value", return that mapping. |
|
if let value = dict["value"] { |
|
let key = prefix |
|
if let subDict = value as? [String: Any], |
|
let jsonData = JSONProcessor.encodeJSON(subDict), |
|
let jsonString = String(data: jsonData, encoding: .utf8) { |
|
return [key: jsonString] |
|
} else { |
|
return [key: "\(value)"] |
|
} |
|
} |
|
|
|
// Otherwise, iterate keys |
|
var result: [String: String] = [:] |
|
for (k, v) in dict { |
|
let newPrefix = prefix.isEmpty ? k : "\(prefix).\(k)" |
|
let partial = flattenTokens(prefix: newPrefix, json: v) |
|
for (subKey, subValue) in partial { |
|
result[subKey] = subValue |
|
} |
|
} |
|
return result |
|
} |
|
} |
|
|
|
// MARK: - File Operations |
|
|
|
struct FileOperations { |
|
static let fileManager = FileManager.default |
|
|
|
static var currentDirectory: URL { |
|
URL(fileURLWithPath: fileManager.currentDirectoryPath) |
|
} |
|
|
|
static var tokensDirectory: URL { |
|
currentDirectory.appendingPathComponent("tokens") |
|
} |
|
|
|
static var diffsDirectory: URL { |
|
currentDirectory.appendingPathComponent("diffs") |
|
} |
|
|
|
static func removeIfExisits(_ url: URL) -> Bool { |
|
if fileManager.fileExists(atPath: url.path) { |
|
// Remove the existing directory (and its contents) first. |
|
do { |
|
try fileManager.removeItem(at: url) |
|
} catch { |
|
print("Error deleting directory at \(url.path): \(error)") |
|
return false |
|
} |
|
} |
|
return true |
|
} |
|
|
|
static func writeJSON(_ object: Any, to url: URL) -> Bool { |
|
guard let data = JSONProcessor.encodeJSON(object) else { return false } |
|
|
|
do { |
|
try? fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) |
|
try data.write(to: url) |
|
return true |
|
} catch { |
|
print("Error writing JSON: \(error)") |
|
return false |
|
} |
|
} |
|
|
|
static func writeText(_ text: String, to url: URL) -> Bool { |
|
do { |
|
try? fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) |
|
try text.write(to: url, atomically: true, encoding: .utf8) |
|
return true |
|
} catch { |
|
print("Error writing text: \(error)") |
|
return false |
|
} |
|
} |
|
} |
|
|
|
// MARK: - Report Generation |
|
|
|
struct ReportGenerator { |
|
static func generateCommentText(from changes: [URL: [String: TokenDiff]]) -> String { |
|
var deletedTokens = [String: [String]]() |
|
var changesText = "### Token Changes\n\n" |
|
|
|
let sortedFiles = changes.keys.sorted { $0.relativePath < $1.relativePath } |
|
|
|
for fileURL in sortedFiles { |
|
let fileDiffs = changes[fileURL]! |
|
let fileName = fileURL.lastPathComponent.replacingOccurrences(of: " ", with: "/"); |
|
let fileRemovals = -(fileDiffs.values.filter { $0.kind == .removal }.count) |
|
let fileAdditions = fileDiffs.values.filter { $0.kind == .addition }.count |
|
let fileChanges = [fileAdditions, fileRemovals] |
|
.filter { $0 != 0 } |
|
.map { $0 > 0 ? "+\($0)" : String($0) } |
|
.joined(separator: ", ") |
|
|
|
changesText += "#### \(fileName)\(fileChanges.isEmpty ? "" : " (\(fileChanges))")\n\n" |
|
changesText += "| | Token Path | Old Value | New Value |\n" |
|
changesText += "|-|------------|-----------|-----------|\n" |
|
|
|
for key in fileDiffs.keys.sorted() { |
|
let diff = fileDiffs[key]! |
|
let tokenName = "`\(key)`" |
|
|
|
if diff.kind == .removal { |
|
var array = deletedTokens[fileName] ?? [] |
|
array.append(tokenName) |
|
deletedTokens[fileName] = array |
|
} |
|
|
|
changesText += "| \(diff.kind.rawValue) | \(tokenName) | \(diff.oldValue) | \(diff.newValue) |\n" |
|
} |
|
|
|
changesText += "\n" |
|
} |
|
|
|
var commentText = "" |
|
|
|
if !deletedTokens.isEmpty { |
|
commentText += "## π¨ Breaking changes!\n\n" |
|
|
|
// Report deleted tokens |
|
if !deletedTokens.isEmpty { |
|
commentText += "### π¨ Deleted Tokens\n\n" |
|
for fileName in deletedTokens.keys.sorted() { |
|
let tokens = deletedTokens[fileName]! |
|
commentText += "- **\(fileName)**\n" |
|
for token in tokens { |
|
commentText += " - \(token)\n" |
|
} |
|
} |
|
commentText += "\n---\n\n" |
|
} |
|
} |
|
|
|
commentText += changesText |
|
|
|
return commentText |
|
} |
|
} |
|
|
|
// MARK: - Token Diff Calculator |
|
|
|
class TokenDiffCalculator { |
|
let baseRef: String |
|
let headRef: String |
|
let tokensFolder: URL |
|
let diffsFolder: URL |
|
|
|
init?(baseRef: String, headRef: String?) { |
|
guard let headRef = headRef else { |
|
print("Unable to determine HEAD commit hash") |
|
return nil |
|
} |
|
self.baseRef = baseRef |
|
self.headRef = headRef |
|
|
|
print("Comparing to base ref: \(baseRef)") |
|
print("Comparing to head ref: \(headRef)") |
|
|
|
// Setup folders |
|
self.tokensFolder = FileOperations.tokensDirectory |
|
self.diffsFolder = FileOperations.diffsDirectory |
|
|
|
// Ensure diffs folder exists |
|
guard FileOperations.removeIfExisits(diffsFolder) else { |
|
print("Failed to delete diffs folder") |
|
return nil |
|
} |
|
} |
|
|
|
func findTokenFiles() -> Set<URL> { |
|
let baseFiles = Set(GitOperations.listFiles(in: "tokens", ref: baseRef)) |
|
let headFiles = Set(GitOperations.listFiles(in: "tokens", ref: headRef)) |
|
return baseFiles.union(headFiles) |
|
} |
|
|
|
func processTokenDiffs() -> [URL: [String: TokenDiff]] { |
|
var allDiffs: [URL: [String: TokenDiff]] = [:] |
|
|
|
let tokenFiles = findTokenFiles() |
|
|
|
for fileURL in tokenFiles { |
|
let fileRelativePath = fileURL.relativePath |
|
|
|
guard let diff = processTokenFile(at: fileRelativePath) else { |
|
print("π΄ \(fileRelativePath) β Error processing token file. Skipping diff generation.") |
|
continue |
|
} |
|
|
|
guard !diff.isEmpty else { |
|
print("π΅ \(fileRelativePath) β No differences found.") |
|
continue |
|
} |
|
|
|
let diffFileURL = diffsFolder.appendingPathComponent(fileRelativePath) |
|
|
|
// Store as TokenDiff objects |
|
var tokenDiffs: [String: TokenDiff] = [:] |
|
for (key, values) in diff { |
|
tokenDiffs[key] = TokenDiff(old: values["old"], new: values["new"]) |
|
} |
|
|
|
allDiffs[diffFileURL] = tokenDiffs |
|
|
|
// Write diffs mapping |
|
let success = FileOperations.writeJSON(diff, to: diffFileURL) |
|
if success { |
|
print("β
\(fileRelativePath) β Found \(diff.count) differences.") |
|
} |
|
} |
|
|
|
return allDiffs |
|
} |
|
|
|
private func processTokenFile(at relativePath: String) -> [String: [String: String]]? { |
|
// Get file content from both refs |
|
let newContent = GitOperations.getFileContent(at: relativePath, with: headRef) |
|
let oldContent = GitOperations.getFileContent(at: relativePath, with: baseRef) |
|
|
|
// Parse JSON |
|
let newJSON = JSONProcessor.decodeJSON(newContent) ?? [:] |
|
let oldJSON = JSONProcessor.decodeJSON(oldContent) ?? [:] |
|
|
|
// Flatten token structures |
|
let flattenedOld = JSONProcessor.flattenTokens(json: oldJSON) |
|
let flattenedNew = JSONProcessor.flattenTokens(json: newJSON) |
|
|
|
// Create combined diff mapping |
|
let allKeys = Set(flattenedOld.keys).union(flattenedNew.keys) |
|
var diffMapping: [String: [String: String]] = [:] |
|
|
|
for key in allKeys { |
|
let oldVal = flattenedOld[key] ?? TokenDiff.emptyValue |
|
let newVal = flattenedNew[key] ?? TokenDiff.emptyValue |
|
|
|
if oldVal != newVal { |
|
diffMapping[key] = ["old": oldVal, "new": newVal] |
|
} |
|
} |
|
|
|
return diffMapping |
|
} |
|
} |
|
|
|
// MARK: - Main |
|
|
|
func main() { |
|
// Accept the base ref as a command-line argument, default to "master" branch. |
|
let base = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "master" |
|
let head = CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : "HEAD" |
|
|
|
// Initialize calculator |
|
guard let calculator = TokenDiffCalculator(baseRef: base, headRef: head) else { |
|
print("Failed to initialize token diff calculator") |
|
exit(1) |
|
} |
|
|
|
// Process all token files present in the base and head refs |
|
let diffs = calculator.processTokenDiffs() |
|
|
|
if diffs.isEmpty { |
|
print("No differences found.") |
|
exit(0) |
|
} else { |
|
print("Diffs generated in: \(calculator.diffsFolder.path())") |
|
} |
|
|
|
// Generate report |
|
let commentText = ReportGenerator.generateCommentText(from: diffs) |
|
let commentURL = calculator.diffsFolder.appendingPathComponent("comment.md") |
|
|
|
if FileOperations.writeText(commentText, to: commentURL) { |
|
print("Comment file written to: \(commentURL.path())") |
|
} else { |
|
print("Failed to write comment file.") |
|
} |
|
} |
|
|
|
// Run the script |
|
main() |