Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 160 additions & 3 deletions Sources/SubtreeLib/Commands/UpdateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -698,4 +781,78 @@ public struct UpdateCommand: AsyncParsableCommand {
"""
}
}

/// Format extraction mappings as human-readable strings for report output.
///
/// Each `ExtractionMapping` is formatted as:
/// `<from_patterns> → <to_destinations> (excluding <exclude_patterns>)`
///
/// - 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.
"""
}
}
53 changes: 53 additions & 0 deletions Sources/SubtreeLib/Utilities/URLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading