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
33 changes: 30 additions & 3 deletions Sources/Crow/App/IssueTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1337,8 +1337,8 @@ final class IssueTracker {
var url = output.trimmingCharacters(in: .whitespacesAndNewlines)
if url.hasSuffix(".git") { url = String(url.dropLast(4)) }
let host = Self.extractHost(fromRemote: url)
if let match = url.range(of: #"[:/]([^/:]+/[^/:]+)$"#, options: .regularExpression) {
let slug = String(url[match]).trimmingCharacters(in: CharacterSet(charactersIn: "/:"))
let slug = Self.extractSlug(fromRemote: url)
if !slug.isEmpty {
return RepoInfo(slug: slug, host: host)
}
}
Expand Down Expand Up @@ -1369,6 +1369,30 @@ final class IssueTracker {
return ""
}

/// Extract the project slug ("org/repo", "group/sub/repo", ...) from a git
/// remote URL. Handles both SSH (`git@host:path`) and HTTPS
/// (`https://host/path`), and preserves nested-group paths so that GitLab
/// projects under nested groups (e.g.
/// `big-bang/product/packages/elasticsearch-kibana`) keep their full path.
/// Strips a trailing `.git` if present. Returns "" when the URL can't be
/// parsed.
nonisolated static func extractSlug(fromRemote url: String) -> String {
var trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasSuffix(".git") { trimmed = String(trimmed.dropLast(4)) }

// SSH: git@host:org/repo or user@host:group/sub/repo
if let range = trimmed.range(of: #"^[^@/\s]+@[^:/\s]+:"#, options: .regularExpression) {
let path = String(trimmed[range.upperBound...])
return path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
// HTTPS: https://host/org/repo
if let range = trimmed.range(of: #"^https?://[^/]+/"#, options: .regularExpression) {
let path = String(trimmed[range.upperBound...])
return path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
return ""
}

private func shellSync(_ args: String...) throws -> String {
let process = Process()
let outPipe = Pipe()
Expand Down Expand Up @@ -1706,10 +1730,13 @@ final class IssueTracker {
private func fetchGitLabIssues(host: String) async -> [AssignedIssue] {
let output: String
do {
// Use `glab api` rather than `glab issue list`: the latter shells
// out to `git` even when no repo is involved and aborts with
// "fatal: not a git repository" when cwd isn't a git working tree.
output = try await shell(
env: ["GITLAB_HOST": host],
cwd: NSHomeDirectory(),
"glab", "issue", "list", "-a", "@me", "--output-format", "json"
"glab", "api", "issues?scope=assigned_to_me&state=opened&per_page=100"
)
} catch {
print("[IssueTracker] fetchGitLabIssues(host: \(host)) failed: \(error)")
Expand Down
48 changes: 48 additions & 0 deletions Tests/CrowTests/IssueTrackerReconcileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,51 @@ struct IssueTrackerRemoteHostTests {
#expect(IssueTracker.extractHost(fromRemote: "") == "")
}
}

@Suite("IssueTracker remote slug extraction")
struct IssueTrackerRemoteSlugTests {
@Test func parsesGitHubSSH() {
#expect(IssueTracker.extractSlug(fromRemote: "git@github.com:radiusmethod/corveil") == "radiusmethod/corveil")
}

@Test func parsesGitHubHTTPS() {
#expect(IssueTracker.extractSlug(fromRemote: "https://github.com/radiusmethod/corveil") == "radiusmethod/corveil")
}

@Test func parsesGitLabTwoLevel() {
#expect(IssueTracker.extractSlug(fromRemote: "https://gitlab.internal.example/acme/api") == "acme/api")
#expect(IssueTracker.extractSlug(fromRemote: "git@gitlab.corp.example:acme/api") == "acme/api")
}

@Test func parsesGitLabNestedGroupsHTTPS() {
// Regression test for #232: nested-group paths must not be truncated.
#expect(
IssueTracker.extractSlug(
fromRemote: "https://gitlab.example.com/big-bang/product/packages/elasticsearch-kibana"
) == "big-bang/product/packages/elasticsearch-kibana"
)
}

@Test func parsesGitLabNestedGroupsSSH() {
#expect(
IssueTracker.extractSlug(
fromRemote: "git@gitlab.example.com:big-bang/product/packages/elasticsearch-kibana"
) == "big-bang/product/packages/elasticsearch-kibana"
)
}

@Test func stripsTrailingDotGit() {
#expect(IssueTracker.extractSlug(fromRemote: "https://github.com/radiusmethod/corveil.git") == "radiusmethod/corveil")
#expect(IssueTracker.extractSlug(fromRemote: "git@github.com:radiusmethod/corveil.git") == "radiusmethod/corveil")
#expect(
IssueTracker.extractSlug(
fromRemote: "https://gitlab.example.com/big-bang/product/packages/elasticsearch-kibana.git"
) == "big-bang/product/packages/elasticsearch-kibana"
)
}

@Test func returnsEmptyForUnrecognized() {
#expect(IssueTracker.extractSlug(fromRemote: "/tmp/local/repo") == "")
#expect(IssueTracker.extractSlug(fromRemote: "") == "")
}
}
Loading