From bd36a144bbede5c4f9cc03a3d5fa73e76076ca09 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 1 May 2026 16:43:48 -0500 Subject: [PATCH] Fix glab fetch failures from non-repo cwd and nested-group slug truncation `fetchGitLabIssues` shelled out to `glab issue list -a @me --output-format json` with `cwd: NSHomeDirectory()`. `glab issue list` invokes `git` internally and aborts with "fatal: not a git repository" whenever the home directory is not a git working tree, so users got zero GitLab issues even though `glab` was authenticated. Switch to `glab api "issues?scope=assigned_to_me&state=opened&per_page=100"`, which has no git-repo dependency and returns the same JSON shape (iid, title, web_url, state, labels, references.full). `resolveRepoInfo` extracted the repo slug via the regex `[:/]([^/:]+/[^/:]+)$`, which only captures the last two path segments. For nested GitLab groups like `big-bang/product/packages/elasticsearch-kibana` this collapsed to `packages/elasticsearch-kibana`, so the reconcile path hit a non-existent project at `glab api projects//merge_requests` and got 404. Replace the regex with a new `extractSlug(fromRemote:)` helper, parallel to `extractHost`, that returns the full path after the host for both SSH and HTTPS remotes and strips a trailing `.git`. Add a `IssueTrackerRemoteSlugTests` Swift Testing suite covering GitHub SSH/HTTPS, two-level GitLab, nested-group GitLab (HTTPS and SSH), trailing-`.git` stripping, and unrecognized inputs. Closes #232 Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Crow/App/IssueTracker.swift | 33 +++++++++++-- .../IssueTrackerReconcileTests.swift | 48 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 344474e..9084b1b 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -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) } } @@ -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() @@ -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)") diff --git a/Tests/CrowTests/IssueTrackerReconcileTests.swift b/Tests/CrowTests/IssueTrackerReconcileTests.swift index 15cff63..9e76e85 100644 --- a/Tests/CrowTests/IssueTrackerReconcileTests.swift +++ b/Tests/CrowTests/IssueTrackerReconcileTests.swift @@ -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: "") == "") + } +}