From f3920157d9ae3d5fa844ef1a29b389595c605905 Mon Sep 17 00:00:00 2001 From: csjones Date: Sat, 28 Feb 2026 20:01:43 -0800 Subject: [PATCH] feat: add PR metadata fields to update report JSON output (#12) - Added latestCommit, compareURL, branchName, extractions, prTitle, prBody to ReportEntry - Implemented URLParser.compareURL() for GitHub compare URL generation - Added formatExtractions() to convert ExtractionMapping to human-readable strings - Added formatPRBody() to generate standardized PR descriptions with extraction details - Enriched "behind" entries with PR metadata for both tag-based and branch-based subtrees --- .../SubtreeLib/Commands/UpdateCommand.swift | 163 +++++++- Sources/SubtreeLib/Utilities/URLParser.swift | 53 +++ .../Commands/UpdateCommandTests.swift | 366 ++++++++++++++++++ .../Utilities/URLParserTests.swift | 66 ++++ 4 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 Tests/SubtreeLibTests/Commands/UpdateCommandTests.swift diff --git a/Sources/SubtreeLib/Commands/UpdateCommand.swift b/Sources/SubtreeLib/Commands/UpdateCommand.swift index 4ba9030..4e193d9 100644 --- a/Sources/SubtreeLib/Commands/UpdateCommand.swift +++ b/Sources/SubtreeLib/Commands/UpdateCommand.swift @@ -18,12 +18,56 @@ public struct ReportEntry: Codable, Sendable { public let branch: String? public let remote: String public let error: String? + public let latestCommit: String? + public let compareURL: String? + public let branchName: String? + public let extractions: [String]? + public let prTitle: String? + public let prBody: String? private enum CodingKeys: String, CodingKey { - case name, status, remote, branch, error + case name, status, remote, branch, error, extractions case currentTag = "current_tag" case latestTag = "latest_tag" case currentCommit = "current_commit" + case latestCommit = "latest_commit" + case compareURL = "compare_url" + case branchName = "branch_name" + case prTitle = "pr_title" + case prBody = "pr_body" + } + + /// Initialize with all fields (used for "behind" entries with enriched data) + public init( + name: String, + status: ReportStatus, + currentTag: String?, + latestTag: String?, + currentCommit: String?, + branch: String?, + remote: String, + error: String?, + latestCommit: String? = nil, + compareURL: String? = nil, + branchName: String? = nil, + extractions: [String]? = nil, + prTitle: String? = nil, + prBody: String? = nil + ) { + self.name = name + self.status = status + self.currentTag = currentTag + self.latestTag = latestTag + self.currentCommit = currentCommit + self.branch = branch + self.remote = remote + self.error = error + self.latestCommit = latestCommit + self.compareURL = compareURL + self.branchName = branchName + self.extractions = extractions + self.prTitle = prTitle + self.prBody = prBody } } @@ -544,6 +588,20 @@ public struct UpdateCommand: AsyncParsableCommand { if entry.tag != latestTag.tag { hasUpdates = true + let shortLatestCommit = CommitMessageFormatter.shortHash(from: latestTag.commit) + let compareURL = URLParser.compareURL( + remote: entry.remote, + oldRef: entry.tag!, + newRef: latestTag.tag + ) + let extractionDescriptions = formatExtractions(entry.extractions) + let prBody = formatPRBody( + name: entry.name, + oldVersion: entry.tag!, + newVersion: latestTag.tag, + compareURL: compareURL, + extractionDescriptions: extractionDescriptions + ) let reportEntry = ReportEntry( name: entry.name, status: .behind, @@ -552,7 +610,13 @@ public struct UpdateCommand: AsyncParsableCommand { currentCommit: nil, branch: nil, remote: entry.remote, - error: nil + error: nil, + latestCommit: shortLatestCommit, + compareURL: compareURL, + branchName: "subtree/\(entry.name)-\(latestTag.tag)", + extractions: extractionDescriptions, + prTitle: "chore(deps): update subtree \(entry.name) to \(latestTag.tag)", + prBody: prBody ) reportEntries.append(reportEntry) if !asJSON { @@ -583,6 +647,19 @@ public struct UpdateCommand: AsyncParsableCommand { hasUpdates = true let shortCurrent = CommitMessageFormatter.shortHash(from: entry.commit) let shortNew = CommitMessageFormatter.shortHash(from: remoteCommit) + let compareURL = URLParser.compareURL( + remote: entry.remote, + oldRef: entry.commit, + newRef: remoteCommit + ) + let extractionDescriptions = formatExtractions(entry.extractions) + let prBody = formatPRBody( + name: entry.name, + oldVersion: shortCurrent, + newVersion: shortNew, + compareURL: compareURL, + extractionDescriptions: extractionDescriptions + ) let reportEntry = ReportEntry( name: entry.name, status: .behind, @@ -591,7 +668,13 @@ public struct UpdateCommand: AsyncParsableCommand { currentCommit: shortCurrent, branch: branchRef, remote: entry.remote, - error: nil + error: nil, + latestCommit: shortNew, + compareURL: compareURL, + branchName: "subtree/\(entry.name)-\(shortNew)", + extractions: extractionDescriptions, + prTitle: "chore(deps): update subtree \(entry.name) to \(shortNew)", + prBody: prBody ) reportEntries.append(reportEntry) if !asJSON { @@ -698,4 +781,78 @@ public struct UpdateCommand: AsyncParsableCommand { """ } } + + /// Format extraction mappings as human-readable strings for report output. + /// + /// Each `ExtractionMapping` is formatted as: + /// ` (excluding )` + /// + /// - Parameter extractions: The extraction mappings to format, or `nil` + /// - Returns: Array of formatted strings, or `nil` if input is `nil` or empty + private func formatExtractions(_ extractions: [ExtractionMapping]?) -> [String]? { + guard let extractions, !extractions.isEmpty else { + return nil + } + + return extractions.map { mapping in + let from = mapping.from.joined(separator: ", ") + let to = mapping.to.joined(separator: ", ") + var result = "\(from) → \(to)" + + if let exclude = mapping.exclude, !exclude.isEmpty { + let excludeStr = exclude.joined(separator: ", ") + result += " (excluding \(excludeStr))" + } + + return result + } + } + + /// Format a PR body for a subtree update. + /// + /// - Parameters: + /// - name: Subtree name + /// - oldVersion: Old version string (tag or short commit hash) + /// - newVersion: New version string (tag or short commit hash) + /// - compareURL: GitHub compare URL, or `nil` for non-GitHub remotes + /// - extractionDescriptions: Formatted extraction strings, or `nil` + /// - Returns: Complete PR body markdown + private func formatPRBody( + name: String, + oldVersion: String, + newVersion: String, + compareURL: String?, + extractionDescriptions: [String]? + ) -> String { + let compareLine: String + if let compareURL { + compareLine = "- **Compare**: [\(oldVersion)...\(newVersion)](\(compareURL))" + } else { + compareLine = "- **Compare**: \(oldVersion)...\(newVersion)" + } + + let extractionsSection: String + if let descriptions = extractionDescriptions, !descriptions.isEmpty { + let bullets = descriptions.map { "- `\($0)`" }.joined(separator: "\n") + extractionsSection = bullets + } else { + extractionsSection = "None configured" + } + + return """ + ## Update subtree `\(name)` (\(oldVersion) → \(newVersion)) + + ### Changes + - **Previous**: \(oldVersion) + - **Updated**: \(newVersion) + \(compareLine) + + ### Extractions Applied + \(extractionsSection) + + --- + > 🤖 This PR was automatically created by the subtree update workflow. + > Review the changes and merge when ready. + """ + } } diff --git a/Sources/SubtreeLib/Utilities/URLParser.swift b/Sources/SubtreeLib/Utilities/URLParser.swift index bc1da9d..a8b7d3d 100644 --- a/Sources/SubtreeLib/Utilities/URLParser.swift +++ b/Sources/SubtreeLib/Utilities/URLParser.swift @@ -50,6 +50,59 @@ public enum URLParser { return name } + + /// Construct a GitHub compare URL from a remote URL and two refs. + /// + /// Supports both HTTPS and SSH (`git@`) GitHub URLs. Non-GitHub URLs return `nil`. + /// + /// - Parameters: + /// - remote: Git remote URL (e.g., "https://github.com/bitcoin-core/secp256k1" or "git@github.com:user/repo.git") + /// - oldRef: Old tag, branch, or commit + /// - newRef: New tag, branch, or commit + /// - Returns: Compare URL string, or `nil` if remote is not a GitHub URL + public static func compareURL(remote: String, oldRef: String, newRef: String) -> String? { + var processedURL = remote + + // Handle git@ format: git@github.com:user/repo.git → github.com/user/repo.git + if processedURL.hasPrefix("git@") { + if let colonRange = processedURL.range(of: ":") { + processedURL.replaceSubrange(colonRange, with: "/") + } + } + + // Remove scheme prefixes + let schemes = ["https://", "http://", "git@"] + for scheme in schemes { + if processedURL.hasPrefix(scheme) { + processedURL = String(processedURL.dropFirst(scheme.count)) + } + } + + // Check that the host is github.com + guard processedURL.hasPrefix("github.com/") else { + return nil + } + + // Extract the path after github.com/ + let path = String(processedURL.dropFirst("github.com/".count)) + + // Remove .git suffix if present + var cleanPath = path + if cleanPath.hasSuffix(".git") { + cleanPath = String(cleanPath.dropLast(4)) + } + + // Remove trailing slash if present + if cleanPath.hasSuffix("/") { + cleanPath = String(cleanPath.dropLast()) + } + + guard !cleanPath.isEmpty else { + return nil + } + + return "https://github.com/\(cleanPath)/compare/\(oldRef)...\(newRef)" + } } public enum URLParseError: Error { diff --git a/Tests/SubtreeLibTests/Commands/UpdateCommandTests.swift b/Tests/SubtreeLibTests/Commands/UpdateCommandTests.swift new file mode 100644 index 0000000..a376494 --- /dev/null +++ b/Tests/SubtreeLibTests/Commands/UpdateCommandTests.swift @@ -0,0 +1,366 @@ +import Testing +import Foundation +@testable import SubtreeLib + +/// Tests for ReportEntry JSON encoding and enriched fields +@Suite("ReportEntry Tests") +struct UpdateCommandTests { + + // MARK: - Helpers + + /// Encode a ReportEntry to a JSON dictionary for easy assertion + private func encodeToDict(_ entry: ReportEntry) throws -> [String: Any] { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(entry) + return try JSONSerialization.jsonObject(with: data) as! [String: Any] + } + + // MARK: - Tag-based Behind Entry + + @Test("Behind tag-based entry has all enriched fields populated") + func testBehindTagBasedEntry() throws { + let entry = ReportEntry( + name: "secp256k1", + status: .behind, + currentTag: "v0.3.1", + latestTag: "v0.4.0", + currentCommit: nil, + branch: nil, + remote: "https://github.com/bitcoin-core/secp256k1", + error: nil, + latestCommit: "a1b2c3d4", + compareURL: "https://github.com/bitcoin-core/secp256k1/compare/v0.3.1...v0.4.0", + branchName: "subtree/secp256k1-v0.4.0", + extractions: ["{include,src}/**/*.{c,h} → Sources/libsecp256k1/ (excluding **/{bench,test}*)"], + prTitle: "chore(deps): update subtree secp256k1 to v0.4.0", + prBody: "## Update subtree `secp256k1`" + ) + + let dict = try encodeToDict(entry) + + #expect(dict["latest_commit"] as? String == "a1b2c3d4") + #expect(dict["compare_url"] as? String == "https://github.com/bitcoin-core/secp256k1/compare/v0.3.1...v0.4.0") + #expect(dict["branch_name"] as? String == "subtree/secp256k1-v0.4.0") + #expect(dict["pr_title"] as? String == "chore(deps): update subtree secp256k1 to v0.4.0") + #expect((dict["pr_body"] as? String)?.contains("## Update subtree `secp256k1`") == true) + + let extractions = dict["extractions"] as? [String] + #expect(extractions?.count == 1) + #expect(extractions?.first?.contains("→") == true) + } + + // MARK: - Branch-based Behind Entry + + @Test("Behind branch-based entry has latestCommit and branchName with short SHA") + func testBehindBranchBasedEntry() throws { + let entry = ReportEntry( + name: "swift-crypto", + status: .behind, + currentTag: nil, + latestTag: nil, + currentCommit: "aabbccdd", + branch: "main", + remote: "https://github.com/apple/swift-crypto", + error: nil, + latestCommit: "11223344", + compareURL: "https://github.com/apple/swift-crypto/compare/aabbccddaabbccddaabbccddaabbccddaabbccdd...1122334411223344112233441122334411223344", + branchName: "subtree/swift-crypto-11223344", + extractions: nil, + prTitle: "chore(deps): update subtree swift-crypto to 11223344", + prBody: "## Update subtree `swift-crypto`" + ) + + let dict = try encodeToDict(entry) + + #expect(dict["latest_commit"] as? String == "11223344") + #expect(dict["branch_name"] as? String == "subtree/swift-crypto-11223344") + #expect(dict["pr_title"] as? String == "chore(deps): update subtree swift-crypto to 11223344") + + // current_commit should also be present for branch-based + #expect(dict["current_commit"] as? String == "aabbccdd") + + // Tag fields should be absent (nil) + #expect(dict["current_tag"] == nil) + #expect(dict["latest_tag"] == nil) + } + + // MARK: - Up-to-date Entry + + @Test("Up-to-date entry omits all enriched fields") + func testUpToDateEntry() throws { + let entry = ReportEntry( + name: "mylib", + status: .upToDate, + currentTag: "v1.0.0", + latestTag: "v1.0.0", + currentCommit: nil, + branch: nil, + remote: "https://github.com/user/mylib", + error: nil + ) + + let dict = try encodeToDict(entry) + + // New fields are absent when nil (not encoded) + #expect(dict["latest_commit"] == nil) + #expect(dict["compare_url"] == nil) + #expect(dict["branch_name"] == nil) + #expect(dict["extractions"] == nil) + #expect(dict["pr_title"] == nil) + #expect(dict["pr_body"] == nil) + } + + // MARK: - Error Entry + + @Test("Error entry omits all enriched fields") + func testErrorEntry() throws { + let entry = ReportEntry( + name: "badlib", + status: .error, + currentTag: "v1.0.0", + latestTag: nil, + currentCommit: nil, + branch: nil, + remote: "https://github.com/user/badlib", + error: "No tags found on remote" + ) + + let dict = try encodeToDict(entry) + + // New fields are absent when nil + #expect(dict["latest_commit"] == nil) + #expect(dict["compare_url"] == nil) + #expect(dict["branch_name"] == nil) + #expect(dict["extractions"] == nil) + #expect(dict["pr_title"] == nil) + #expect(dict["pr_body"] == nil) + #expect(dict["error"] as? String == "No tags found on remote") + } + + // MARK: - Branch Name Format + + @Test("branchName format for tag-based: subtree/-") + func testBranchNameTagBased() throws { + let entry = ReportEntry( + name: "secp256k1", + status: .behind, + currentTag: "v0.3.1", + latestTag: "v0.4.0", + currentCommit: nil, + branch: nil, + remote: "https://github.com/bitcoin-core/secp256k1", + error: nil, + latestCommit: "a1b2c3d4", + compareURL: nil, + branchName: "subtree/secp256k1-v0.4.0", + extractions: nil, + prTitle: nil, + prBody: nil + ) + + let dict = try encodeToDict(entry) + #expect(dict["branch_name"] as? String == "subtree/secp256k1-v0.4.0") + } + + @Test("branchName format for branch-based: subtree/-") + func testBranchNameBranchBased() throws { + let entry = ReportEntry( + name: "swift-crypto", + status: .behind, + currentTag: nil, + latestTag: nil, + currentCommit: "aabbccdd", + branch: "main", + remote: "https://github.com/apple/swift-crypto", + error: nil, + latestCommit: "11223344", + compareURL: nil, + branchName: "subtree/swift-crypto-11223344", + extractions: nil, + prTitle: nil, + prBody: nil + ) + + let dict = try encodeToDict(entry) + #expect(dict["branch_name"] as? String == "subtree/swift-crypto-11223344") + } + + // MARK: - PR Title Format + + @Test("prTitle format: chore(deps): update subtree to ") + func testPRTitleFormat() throws { + let tagEntry = ReportEntry( + name: "secp256k1", + status: .behind, + currentTag: "v0.3.1", + latestTag: "v0.4.0", + currentCommit: nil, + branch: nil, + remote: "https://github.com/bitcoin-core/secp256k1", + error: nil, + latestCommit: "a1b2c3d4", + compareURL: nil, + branchName: nil, + extractions: nil, + prTitle: "chore(deps): update subtree secp256k1 to v0.4.0", + prBody: nil + ) + + let dict = try encodeToDict(tagEntry) + #expect(dict["pr_title"] as? String == "chore(deps): update subtree secp256k1 to v0.4.0") + } + + // MARK: - PR Body Content + + @Test("prBody contains expected markdown sections") + func testPRBodyContent() throws { + let prBody = """ + ## Update subtree `secp256k1` (v0.3.1 → v0.4.0) + + ### Changes + - **Previous**: v0.3.1 + - **Updated**: v0.4.0 + - **Compare**: [v0.3.1...v0.4.0](https://github.com/bitcoin-core/secp256k1/compare/v0.3.1...v0.4.0) + + ### Extractions Applied + - `{include,src}/**/*.{c,h}` → `Sources/libsecp256k1/` (excluding `**/{bench,test}*`) + + --- + > 🤖 This PR was automatically created by the subtree update workflow. + > Review the changes and merge when ready. + """ + + let entry = ReportEntry( + name: "secp256k1", + status: .behind, + currentTag: "v0.3.1", + latestTag: "v0.4.0", + currentCommit: nil, + branch: nil, + remote: "https://github.com/bitcoin-core/secp256k1", + error: nil, + latestCommit: "a1b2c3d4", + compareURL: "https://github.com/bitcoin-core/secp256k1/compare/v0.3.1...v0.4.0", + branchName: "subtree/secp256k1-v0.4.0", + extractions: ["{include,src}/**/*.{c,h} → Sources/libsecp256k1/ (excluding **/{bench,test}*)"], + prTitle: "chore(deps): update subtree secp256k1 to v0.4.0", + prBody: prBody + ) + + let dict = try encodeToDict(entry) + let body = dict["pr_body"] as? String + #expect(body?.contains("## Update subtree `secp256k1`") == true) + #expect(body?.contains("### Changes") == true) + #expect(body?.contains("### Extractions Applied") == true) + #expect(body?.contains("v0.3.1 → v0.4.0") == true) + #expect(body?.contains("automatically created") == true) + } + + // MARK: - Extractions Formatting + + @Test("extractions formatting with from/to/exclude") + func testExtractionsFormatting() throws { + let entry = ReportEntry( + name: "mylib", + status: .behind, + currentTag: "v1.0.0", + latestTag: "v2.0.0", + currentCommit: nil, + branch: nil, + remote: "https://github.com/user/mylib", + error: nil, + latestCommit: "a1b2c3d4", + compareURL: nil, + branchName: nil, + extractions: [ + "{include,src}/**/*.{c,h} → Sources/libsecp256k1/ (excluding **/{bench,test}*, **/precomputed_*)", + "docs/**/*.md → Documentation/" + ], + prTitle: nil, + prBody: nil + ) + + let dict = try encodeToDict(entry) + let extractions = dict["extractions"] as? [String] + #expect(extractions?.count == 2) + #expect(extractions?[0].contains("→") == true) + #expect(extractions?[0].contains("excluding") == true) + #expect(extractions?[1] == "docs/**/*.md → Documentation/") + } + + // MARK: - Backward Compatibility + + @Test("Existing fields preserved when new fields are nil") + func testBackwardCompatibility() throws { + let entry = ReportEntry( + name: "mylib", + status: .behind, + currentTag: "v1.0.0", + latestTag: "v2.0.0", + currentCommit: nil, + branch: nil, + remote: "https://github.com/user/mylib", + error: nil + ) + + let dict = try encodeToDict(entry) + + // Existing fields preserved + #expect(dict["name"] as? String == "mylib") + #expect(dict["status"] as? String == "behind") + #expect(dict["current_tag"] as? String == "v1.0.0") + #expect(dict["latest_tag"] as? String == "v2.0.0") + #expect(dict["remote"] as? String == "https://github.com/user/mylib") + + // New fields absent when nil + #expect(dict["latest_commit"] == nil) + #expect(dict["compare_url"] == nil) + #expect(dict["branch_name"] == nil) + #expect(dict["extractions"] == nil) + #expect(dict["pr_title"] == nil) + #expect(dict["pr_body"] == nil) + } + + // MARK: - JSON Roundtrip + + @Test("ReportEntry JSON keys use snake_case") + func testJSONKeysSnakeCase() throws { + let entry = ReportEntry( + name: "test", + status: .behind, + currentTag: "v1.0.0", + latestTag: "v2.0.0", + currentCommit: nil, + branch: nil, + remote: "https://github.com/user/test", + error: nil, + latestCommit: "abcd1234", + compareURL: "https://github.com/user/test/compare/v1.0.0...v2.0.0", + branchName: "subtree/test-v2.0.0", + extractions: ["src/**/*.swift → Sources/"], + prTitle: "chore(deps): update subtree test to v2.0.0", + prBody: "## Update" + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(entry) + let jsonString = String(data: data, encoding: .utf8)! + + // Verify snake_case keys + #expect(jsonString.contains("\"latest_commit\"")) + #expect(jsonString.contains("\"compare_url\"")) + #expect(jsonString.contains("\"branch_name\"")) + #expect(jsonString.contains("\"extractions\"")) + #expect(jsonString.contains("\"pr_title\"")) + #expect(jsonString.contains("\"pr_body\"")) + + // Verify no camelCase leaks + #expect(!jsonString.contains("\"latestCommit\"")) + #expect(!jsonString.contains("\"compareURL\"")) + #expect(!jsonString.contains("\"branchName\"")) + #expect(!jsonString.contains("\"prTitle\"")) + #expect(!jsonString.contains("\"prBody\"")) + } +} diff --git a/Tests/SubtreeLibTests/Utilities/URLParserTests.swift b/Tests/SubtreeLibTests/Utilities/URLParserTests.swift index 3c420d5..b72fdb3 100644 --- a/Tests/SubtreeLibTests/Utilities/URLParserTests.swift +++ b/Tests/SubtreeLibTests/Utilities/URLParserTests.swift @@ -54,6 +54,72 @@ struct URLParserTests { #expect(name == "myrepo") } + // MARK: - compareURL Tests + + @Test("compareURL with GitHub HTTPS URL") + func testCompareURLGitHubHTTPS() { + let url = URLParser.compareURL( + remote: "https://github.com/bitcoin-core/secp256k1", + oldRef: "v0.3.1", + newRef: "v0.4.0" + ) + #expect(url == "https://github.com/bitcoin-core/secp256k1/compare/v0.3.1...v0.4.0") + } + + @Test("compareURL with GitHub HTTPS URL with .git suffix") + func testCompareURLGitHubHTTPSWithGitSuffix() { + let url = URLParser.compareURL( + remote: "https://github.com/apple/swift-crypto.git", + oldRef: "3.10.0", + newRef: "4.0.0" + ) + #expect(url == "https://github.com/apple/swift-crypto/compare/3.10.0...4.0.0") + } + + @Test("compareURL with GitHub SSH URL") + func testCompareURLGitHubSSH() { + let url = URLParser.compareURL( + remote: "git@github.com:user/repo.git", + oldRef: "v1.0.0", + newRef: "v2.0.0" + ) + #expect(url == "https://github.com/user/repo/compare/v1.0.0...v2.0.0") + } + + @Test("compareURL returns nil for non-GitHub URLs") + func testCompareURLNonGitHub() { + // GitLab + #expect(URLParser.compareURL( + remote: "https://gitlab.com/user/repo.git", + oldRef: "v1.0.0", + newRef: "v2.0.0" + ) == nil) + + // Bitbucket + #expect(URLParser.compareURL( + remote: "https://bitbucket.org/user/repo.git", + oldRef: "v1.0.0", + newRef: "v2.0.0" + ) == nil) + + // file:// + #expect(URLParser.compareURL( + remote: "file:///local/path/repo.git", + oldRef: "v1.0.0", + newRef: "v2.0.0" + ) == nil) + } + + @Test("compareURL with full commit hashes for branch-based entries") + func testCompareURLWithCommitHashes() { + let url = URLParser.compareURL( + remote: "https://github.com/user/repo", + oldRef: "abc123def456abc123def456abc123def456abc1", + newRef: "def456abc123def456abc123def456abc123def4" + ) + #expect(url == "https://github.com/user/repo/compare/abc123def456abc123def456abc123def456abc1...def456abc123def456abc123def456abc123def4") + } + // T014 - Verify parsing works for standard formats @Test("Parse standard formats (GitHub, GitLab, Bitbucket)") func testStandardFormats() throws {