diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift b/Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift index f473e7e..e3a47e8 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift @@ -10,9 +10,12 @@ public struct AssignedIssue: Identifiable, Codable, Sendable { public var repo: String // "org/repo" public var labels: [String] public var provider: Provider + /// PR number linked via closing issue references, if any. public var prNumber: Int? + /// URL of the linked pull request, if any. public var prURL: String? public var updatedAt: Date? + /// Pipeline status from the GitHub/GitLab project board. public var projectStatus: TicketStatus public init( @@ -21,9 +24,17 @@ public struct AssignedIssue: Identifiable, Codable, Sendable { provider: Provider, prNumber: Int? = nil, prURL: String? = nil, updatedAt: Date? = nil, projectStatus: TicketStatus = .unknown ) { - self.id = id; self.number = number; self.title = title; self.state = state - self.url = url; self.repo = repo; self.labels = labels - self.provider = provider; self.prNumber = prNumber; self.prURL = prURL - self.updatedAt = updatedAt; self.projectStatus = projectStatus + self.id = id + self.number = number + self.title = title + self.state = state + self.url = url + self.repo = repo + self.labels = labels + self.provider = provider + self.prNumber = prNumber + self.prURL = prURL + self.updatedAt = updatedAt + self.projectStatus = projectStatus } } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift index 174fc84..a49686e 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift @@ -63,6 +63,18 @@ public enum TicketStatus: String, Codable, Sendable, CaseIterable { /// The pipeline stages shown in the UI (including Done). public static let pipelineStatuses: [TicketStatus] = [.backlog, .ready, .inProgress, .inReview, .done] + + /// Initialize from a GitHub/GitLab project board status name (case-insensitive). + public init(projectBoardName name: String) { + switch name.lowercased().trimmingCharacters(in: .whitespaces) { + case "backlog": self = .backlog + case "ready", "todo", "to do": self = .ready + case "in progress", "doing", "active": self = .inProgress + case "in review", "review": self = .inReview + case "done", "closed", "complete", "completed": self = .done + default: self = .unknown + } + } } /// Sort order options for the ticket board. diff --git a/Packages/CrowCore/Sources/CrowCore/Models/PRStatus.swift b/Packages/CrowCore/Sources/CrowCore/Models/PRStatus.swift index a74c006..a21b059 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/PRStatus.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/PRStatus.swift @@ -20,23 +20,35 @@ public struct PRStatus: Codable, Sendable { } public enum CheckStatus: String, Codable, Sendable { + /// All CI/CD checks have passed. case passing + /// One or more CI/CD checks have failed. case failing + /// Checks are still running. case pending + /// Check status could not be determined (e.g. no checks configured). case unknown } public enum ReviewStatus: String, Codable, Sendable { + /// PR has been approved by required reviewers. case approved + /// A reviewer has requested changes. case changesRequested + /// Review is required but not yet submitted. case reviewRequired + /// Review status could not be determined. case unknown } public enum MergeStatus: String, Codable, Sendable { + /// PR can be merged (no conflicts, requirements met). case mergeable + /// PR has merge conflicts that must be resolved. case conflicting + /// PR has already been merged. case merged + /// Merge status could not be determined. case unknown } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/PRStatusTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/PRStatusTests.swift new file mode 100644 index 0000000..029d51c --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/PRStatusTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import CrowCore + +@Suite("PRStatus") +struct PRStatusTests { + + @Test func defaultInitAllUnknown() { + let status = PRStatus() + #expect(status.isMerged == false) + #expect(status.isReadyToMerge == false) + #expect(status.hasBlockers == false) + } + + @Test func isReadyToMerge() { + let status = PRStatus(checksPass: .passing, reviewStatus: .approved, mergeable: .mergeable) + #expect(status.isReadyToMerge == true) + #expect(status.hasBlockers == false) + } + + @Test func isMerged() { + let status = PRStatus(mergeable: .merged) + #expect(status.isMerged == true) + #expect(status.isReadyToMerge == false) + #expect(status.hasBlockers == false) + } + + @Test func hasBlockersFailingChecks() { + let status = PRStatus(checksPass: .failing, reviewStatus: .approved, mergeable: .mergeable) + #expect(status.hasBlockers == true) + #expect(status.isReadyToMerge == false) + } + + @Test func hasBlockersChangesRequested() { + let status = PRStatus(checksPass: .passing, reviewStatus: .changesRequested, mergeable: .mergeable) + #expect(status.hasBlockers == true) + } + + @Test func hasBlockersConflicting() { + let status = PRStatus(checksPass: .passing, reviewStatus: .approved, mergeable: .conflicting) + #expect(status.hasBlockers == true) + } + + @Test func notReadyWhenPending() { + let status = PRStatus(checksPass: .pending, reviewStatus: .approved, mergeable: .mergeable) + #expect(status.isReadyToMerge == false) + #expect(status.hasBlockers == false) + } +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/TicketStatusTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/TicketStatusTests.swift new file mode 100644 index 0000000..6bf4175 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/TicketStatusTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import CrowCore + +@Suite("TicketStatus") +struct TicketStatusTests { + + @Test func canonicalNames() { + #expect(TicketStatus(projectBoardName: "Backlog") == .backlog) + #expect(TicketStatus(projectBoardName: "Ready") == .ready) + #expect(TicketStatus(projectBoardName: "In Progress") == .inProgress) + #expect(TicketStatus(projectBoardName: "In Review") == .inReview) + #expect(TicketStatus(projectBoardName: "Done") == .done) + } + + @Test func caseInsensitive() { + #expect(TicketStatus(projectBoardName: "BACKLOG") == .backlog) + #expect(TicketStatus(projectBoardName: "in progress") == .inProgress) + #expect(TicketStatus(projectBoardName: "IN REVIEW") == .inReview) + } + + @Test func aliases() { + #expect(TicketStatus(projectBoardName: "Todo") == .ready) + #expect(TicketStatus(projectBoardName: "To Do") == .ready) + #expect(TicketStatus(projectBoardName: "Doing") == .inProgress) + #expect(TicketStatus(projectBoardName: "Active") == .inProgress) + #expect(TicketStatus(projectBoardName: "Review") == .inReview) + #expect(TicketStatus(projectBoardName: "Closed") == .done) + #expect(TicketStatus(projectBoardName: "Complete") == .done) + #expect(TicketStatus(projectBoardName: "Completed") == .done) + } + + @Test func whitspaceTrimming() { + #expect(TicketStatus(projectBoardName: " in review ") == .inReview) + #expect(TicketStatus(projectBoardName: " backlog ") == .backlog) + } + + @Test func unknownStrings() { + #expect(TicketStatus(projectBoardName: "Custom Status") == .unknown) + #expect(TicketStatus(projectBoardName: "") == .unknown) + #expect(TicketStatus(projectBoardName: "Blocked") == .unknown) + } +} diff --git a/Packages/CrowProvider/Package.swift b/Packages/CrowProvider/Package.swift index 40ea421..28c0666 100644 --- a/Packages/CrowProvider/Package.swift +++ b/Packages/CrowProvider/Package.swift @@ -12,5 +12,6 @@ let package = Package( ], targets: [ .target(name: "CrowProvider", dependencies: ["CrowCore"]), + .testTarget(name: "CrowProviderTests", dependencies: ["CrowProvider"]), ] ) diff --git a/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift b/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift index cb454cc..ff92a6d 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift @@ -11,6 +11,9 @@ public actor ProviderManager { } /// Detect provider from a URL string. + /// + /// Falls back to `.github` for unrecognized hosts — the `gh` CLI call will fail clearly + /// if the URL is actually a self-hosted GitLab instance, which is an acceptable failure mode. public func detectProvider(from url: String) -> (provider: Provider, cli: String, host: String?) { if url.contains("github.com") { return (.github, "gh", nil) @@ -26,29 +29,41 @@ public actor ProviderManager { return (.github, "gh", nil) } - /// Parse issue/PR number and repo from a URL. + /// Parse issue/PR number and repo from a ticket URL. + /// + /// Supported formats: + /// - GitHub issue: `https://github.com/{org}/{repo}/issues/{number}` + /// - GitHub PR: `https://github.com/{org}/{repo}/pull/{number}` + /// - GitLab issue: `https://{host}/{org}/{repo}/-/issues/{number}` + /// - GitLab MR: `https://{host}/{org}/{repo}/-/merge_requests/{number}` + /// + /// - Returns: A tuple of `(org, repo, number, isMR)` where `isMR` is true for pull requests + /// and merge requests, or `nil` if the URL doesn't match a supported format. public func parseTicketURL(_ url: String) -> (org: String, repo: String, number: Int, isMR: Bool)? { - // GitHub: https://github.com/{org}/{repo}/issues/{number} - // GitHub: https://github.com/{org}/{repo}/pull/{number} - // GitLab: https://host/{org}/{repo}/-/issues/{number} - // GitLab: https://host/{org}/{repo}/-/merge_requests/{number} + Self.parseTicketURLComponents(url) + } + + /// Static variant of ``parseTicketURL(_:)`` usable without an actor instance. + public static func parseTicketURLComponents(_ url: String) -> (org: String, repo: String, number: Int, isMR: Bool)? { + // split(separator:) omits empty subsequences, so "https://host/..." becomes: + // ["https:", "host", "org", "repo", ...] let parts = url.split(separator: "/").map(String.init) - guard parts.count >= 5 else { return nil } + guard parts.count >= 4 else { return nil } if url.contains("github.com") { - // parts: ["https:", "", "github.com", org, repo, "issues"|"pull", number] - guard parts.count >= 7, + // ["https:", "github.com", org, repo, "issues"|"pull", number] + guard parts.count >= 6, let number = Int(parts[parts.count - 1]) else { return nil } - let org = parts[3] - let repo = parts[4] + let org = parts[2] + let repo = parts[3] let isMR = parts[parts.count - 2] == "pull" return (org, repo, number, isMR) } else { - // GitLab: ["https:", "", host, org, repo, "-", "issues"|"merge_requests", number] - guard parts.count >= 8, + // GitLab: ["https:", host, org, repo, "-", "issues"|"merge_requests", number] + guard parts.count >= 7, let number = Int(parts[parts.count - 1]) else { return nil } - let org = parts[3] - let repo = parts[4] + let org = parts[2] + let repo = parts[3] let isMR = parts[parts.count - 2] == "merge_requests" return (org, repo, number, isMR) } @@ -129,6 +144,7 @@ public actor ProviderManager { } } +/// Details about a ticket (issue or PR/MR) fetched from a provider. public struct TicketInfo: Sendable { public let number: Int public let title: String diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/ProviderManagerTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/ProviderManagerTests.swift new file mode 100644 index 0000000..631df33 --- /dev/null +++ b/Packages/CrowProvider/Tests/CrowProviderTests/ProviderManagerTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import CrowProvider + +@Suite("ProviderManager") +struct ProviderManagerTests { + + let manager = ProviderManager() + + // MARK: - detectProvider + + @Test func detectProviderGitHub() async { + let result = await manager.detectProvider(from: "https://github.com/org/repo/issues/1") + #expect(result.provider == .github) + #expect(result.cli == "gh") + #expect(result.host == nil) + } + + @Test func detectProviderGitLab() async { + let result = await manager.detectProvider(from: "https://gitlab.com/org/repo/-/issues/1") + #expect(result.provider == .gitlab) + #expect(result.cli == "glab") + #expect(result.host == "gitlab.com") + } + + @Test func detectProviderCustomGitLabHost() async { + let mgr = ProviderManager(additionalGitLabHosts: ["gitlab.internal.io"]) + let result = await mgr.detectProvider(from: "https://gitlab.internal.io/org/repo/-/issues/5") + #expect(result.provider == .gitlab) + #expect(result.cli == "glab") + #expect(result.host == "gitlab.internal.io") + } + + @Test func detectProviderFallsBackToGitHub() async { + let result = await manager.detectProvider(from: "https://unknown.host/org/repo") + #expect(result.provider == .github) + #expect(result.cli == "gh") + } + + // MARK: - parseTicketURLComponents (static) + + @Test func parseGitHubIssueURL() { + let result = ProviderManager.parseTicketURLComponents("https://github.com/radiusmethod/crow/issues/74") + #expect(result?.org == "radiusmethod") + #expect(result?.repo == "crow") + #expect(result?.number == 74) + #expect(result?.isMR == false) + } + + @Test func parseGitHubPullURL() { + let result = ProviderManager.parseTicketURLComponents("https://github.com/org/repo/pull/123") + #expect(result?.org == "org") + #expect(result?.repo == "repo") + #expect(result?.number == 123) + #expect(result?.isMR == true) + } + + @Test func parseGitLabIssueURL() { + let result = ProviderManager.parseTicketURLComponents("https://gitlab.com/org/repo/-/issues/42") + #expect(result?.org == "org") + #expect(result?.repo == "repo") + #expect(result?.number == 42) + #expect(result?.isMR == false) + } + + @Test func parseGitLabMergeRequestURL() { + let result = ProviderManager.parseTicketURLComponents("https://gitlab.internal.io/team/project/-/merge_requests/99") + #expect(result?.org == "team") + #expect(result?.repo == "project") + #expect(result?.number == 99) + #expect(result?.isMR == true) + } + + @Test func parseURLTooShort() { + let result = ProviderManager.parseTicketURLComponents("https://github.com/org") + #expect(result == nil) + } + + @Test func parseURLNonNumericNumber() { + let result = ProviderManager.parseTicketURLComponents("https://github.com/org/repo/issues/abc") + #expect(result == nil) + } +} diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 5fe121e..5a1f1a7 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -1,6 +1,7 @@ import Foundation import CrowCore import CrowPersistence +import CrowProvider /// Polls GitHub/GitLab for issues assigned to the current user. @MainActor @@ -8,6 +9,7 @@ final class IssueTracker { private let appState: AppState private var timer: Timer? private let pollInterval: TimeInterval = 60 // 1 minute + private var isRefreshing = false init(appState: AppState) { self.appState = appState @@ -17,7 +19,7 @@ final class IssueTracker { // Initial fetch Task { await refresh() } - // Poll every 5 minutes + // Poll on interval timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in Task { @MainActor in await self?.refresh() @@ -31,6 +33,10 @@ final class IssueTracker { } func refresh() async { + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + appState.isLoadingIssues = true defer { appState.isLoadingIssues = false } @@ -100,33 +106,24 @@ final class IssueTracker { private func fetchGitHubIssues() async -> [AssignedIssue] { // Use gh search issues to find ALL issues assigned to me across all repos - guard let output = try? await shell( - "gh", "search", "issues", - "--assignee", "@me", - "--state", "open", - "--json", "number,title,state,labels,url,repository,updatedAt", - "--limit", "100" - ) else { return [] } + let output: String + do { + output = try await shell( + "gh", "search", "issues", + "--assignee", "@me", + "--state", "open", + "--json", "number,title,state,labels,url,repository,updatedAt", + "--limit", "100" + ) + } catch { + print("[IssueTracker] fetchGitHubIssues failed: \(error)") + return [] + } guard let data = output.data(using: .utf8), let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } - return items.compactMap { item -> AssignedIssue? in - guard let number = item["number"] as? Int, - let title = item["title"] as? String, - let url = item["url"] as? String else { return nil } - - let state = item["state"] as? String ?? "open" - let labels = (item["labels"] as? [[String: Any]])?.compactMap { $0["name"] as? String } ?? [] - let repoDict = item["repository"] as? [String: Any] - let repoName = repoDict?["nameWithOwner"] as? String ?? "" - - return AssignedIssue( - id: "github:\(repoName)#\(number)", - number: number, title: title, state: state.lowercased(), - url: url, repo: repoName, labels: labels, provider: .github - ) - } + return items.compactMap { parseGitHubIssueJSON($0) } } private struct PRInfo { @@ -136,12 +133,47 @@ final class IssueTracker { let linkedIssueNumbers: [Int] } + /// Parse a GitHub issue JSON dictionary (from `gh search issues`) into an AssignedIssue. + private func parseGitHubIssueJSON( + _ item: [String: Any], + defaultState: String = "open", + projectStatus: TicketStatus = .unknown, + dateFormatter: ISO8601DateFormatter? = nil + ) -> AssignedIssue? { + guard let number = item["number"] as? Int, + let title = item["title"] as? String, + let url = item["url"] as? String else { return nil } + + let state = item["state"] as? String ?? defaultState + let labels = (item["labels"] as? [[String: Any]])?.compactMap { $0["name"] as? String } ?? [] + let repoDict = item["repository"] as? [String: Any] + let repoName = repoDict?["nameWithOwner"] as? String ?? "" + + var updatedAt: Date? + if let dateFormatter, let dateStr = item["updatedAt"] as? String { + updatedAt = dateFormatter.date(from: dateStr) + } + + return AssignedIssue( + id: "github:\(repoName)#\(number)", + number: number, title: title, state: state.lowercased(), + url: url, repo: repoName, labels: labels, provider: .github, + updatedAt: updatedAt, projectStatus: projectStatus + ) + } + private func fetchGitHubPRs() async -> [PRInfo] { - guard let output = try? await shell( - "gh", "pr", "list", "--author", "@me", "--state", "open", - "--json", "number,url,headRefName,closingIssuesReferences", - "--limit", "20" - ) else { return [] } + let output: String + do { + output = try await shell( + "gh", "pr", "list", "--author", "@me", "--state", "open", + "--json", "number,url,headRefName,closingIssuesReferences", + "--limit", "20" + ) + } catch { + print("[IssueTracker] fetchGitHubPRs failed: \(error)") + return [] + } guard let data = output.data(using: .utf8), let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } @@ -161,10 +193,16 @@ final class IssueTracker { // MARK: - GitLab private func fetchGitLabIssues(host: String) async -> [AssignedIssue] { - guard let output = try? await shell( - env: ["GITLAB_HOST": host], - "glab", "issue", "list", "-a", "@me", "--output-format", "json" - ) else { return [] } + let output: String + do { + output = try await shell( + env: ["GITLAB_HOST": host], + "glab", "issue", "list", "-a", "@me", "--output-format", "json" + ) + } catch { + print("[IssueTracker] fetchGitLabIssues(host: \(host)) failed: \(error)") + return [] + } guard let data = output.data(using: .utf8), let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } @@ -210,15 +248,17 @@ final class IssueTracker { let repoSlug = resolveRepoSlug(worktree: primaryWt) guard !repoSlug.isEmpty else { continue } - if let output = try? await shell( - "gh", "pr", "list", "--repo", repoSlug, "--head", branch, - "--state", "all", - "--json", "number,url,state", "--limit", "1" - ), let data = output.data(using: .utf8), - let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], - let pr = items.first, - let prNum = pr["number"] as? Int, - let prURL = pr["url"] as? String { + do { + let output = try await shell( + "gh", "pr", "list", "--repo", repoSlug, "--head", branch, + "--state", "all", + "--json", "number,url,state", "--limit", "1" + ) + if let data = output.data(using: .utf8), + let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let pr = items.first, + let prNum = pr["number"] as? Int, + let prURL = pr["url"] as? String { let link = SessionLink( sessionID: session.id, @@ -232,6 +272,9 @@ final class IssueTracker { store.mutate { data in data.links.append(link) } + } + } catch { + print("[IssueTracker] checkSessionPRs: PR lookup for branch '\(branch)' failed: \(error)") } } } @@ -280,10 +323,16 @@ final class IssueTracker { let links = appState.links(for: session.id) guard let prLink = links.first(where: { $0.linkType == .pr }) else { continue } - guard let output = try? await shell( - "gh", "pr", "view", prLink.url, - "--json", "state,mergeable,reviewDecision,statusCheckRollup" - ) else { continue } + let output: String + do { + output = try await shell( + "gh", "pr", "view", prLink.url, + "--json", "state,mergeable,reviewDecision,statusCheckRollup" + ) + } catch { + print("[IssueTracker] fetchPRStatuses: failed for \(prLink.url): \(error)") + continue + } guard let data = output.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } @@ -347,40 +396,25 @@ final class IssueTracker { let formatter = ISO8601DateFormatter() let since = formatter.string(from: Date().addingTimeInterval(-86400)) - guard let output = try? await shell( - "gh", "search", "issues", - "--assignee", "@me", - "--state", "closed", - "--json", "number,title,state,labels,url,repository,updatedAt", - "--limit", "50", - "--", "closed:>\(since)" - ) else { return [] } + let output: String + do { + output = try await shell( + "gh", "search", "issues", + "--assignee", "@me", + "--state", "closed", + "--json", "number,title,state,labels,url,repository,updatedAt", + "--limit", "50", + "--", "closed:>\(since)" + ) + } catch { + print("[IssueTracker] fetchDoneIssuesLast24h failed: \(error)") + return [] + } guard let data = output.data(using: .utf8), let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } - return items.compactMap { item -> AssignedIssue? in - guard let number = item["number"] as? Int, - let title = item["title"] as? String, - let url = item["url"] as? String else { return nil } - - let state = item["state"] as? String ?? "closed" - let labels = (item["labels"] as? [[String: Any]])?.compactMap { $0["name"] as? String } ?? [] - let repoDict = item["repository"] as? [String: Any] - let repoName = repoDict?["nameWithOwner"] as? String ?? "" - - var updatedAt: Date? - if let dateStr = item["updatedAt"] as? String { - updatedAt = formatter.date(from: dateStr) - } - - return AssignedIssue( - id: "github:\(repoName)#\(number)", - number: number, title: title, state: state.lowercased(), - url: url, repo: repoName, labels: labels, provider: .github, - updatedAt: updatedAt, projectStatus: .done - ) - } + return items.compactMap { parseGitHubIssueJSON($0, defaultState: "closed", projectStatus: .done, dateFormatter: formatter) } } // MARK: - Auto-Complete Finished Sessions @@ -438,9 +472,13 @@ final class IssueTracker { /// Check if a GitHub PR was merged. private func checkPRMerged(url: String) async -> Bool { - guard let output = try? await shell( - "gh", "pr", "view", url, "--json", "state" - ) else { return false } + let output: String + do { + output = try await shell("gh", "pr", "view", url, "--json", "state") + } catch { + print("[IssueTracker] checkPRMerged failed for \(url): \(error)") + return false + } guard let data = output.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -451,11 +489,18 @@ final class IssueTracker { /// Check if an issue is closed. private func checkIssueClosed(url: String, provider: Provider) async -> Bool { - guard provider == .github else { return false } // GitLab TBD + guard provider == .github else { + print("[IssueTracker] checkIssueClosed: GitLab not yet supported, skipping \(url)") + return false + } - guard let output = try? await shell( - "gh", "issue", "view", url, "--json", "state" - ) else { return false } + let output: String + do { + output = try await shell("gh", "issue", "view", url, "--json", "state") + } catch { + print("[IssueTracker] checkIssueClosed failed for \(url): \(error)") + return false + } guard let data = output.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -470,6 +515,8 @@ final class IssueTracker { let githubIssues = issues.enumerated().filter { $0.element.provider == .github } guard !githubIssues.isEmpty else { return } + // Query the "Status" single-select field from GitHub Projects V2 for each issue. + // Returns the project board pipeline status (e.g. "Backlog", "In Progress", "Done"). let query = """ query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { @@ -487,7 +534,7 @@ final class IssueTracker { """ // Test with the first issue to detect scope errors early - let (firstIndex, firstIssue) = githubIssues[0] + let (_, firstIssue) = githubIssues[0] let firstParts = firstIssue.repo.split(separator: "/") if firstParts.count == 2 { let testResult = await shellWithStatus( @@ -513,13 +560,19 @@ final class IssueTracker { let owner = String(parts[0]) let repoName = String(parts[1]) - guard let output = try? await shell( - "gh", "api", "graphql", - "-f", "query=\(query)", - "-F", "owner=\(owner)", - "-F", "repo=\(repoName)", - "-F", "number=\(issue.number)" - ) else { continue } + let output: String + do { + output = try await shell( + "gh", "api", "graphql", + "-f", "query=\(query)", + "-F", "owner=\(owner)", + "-F", "repo=\(repoName)", + "-F", "number=\(issue.number)" + ) + } catch { + print("[IssueTracker] fetchGitHubProjectStatuses: GraphQL query failed for \(owner)/\(repoName)#\(issue.number): \(error)") + continue + } guard let data = output.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -533,30 +586,13 @@ final class IssueTracker { for node in nodes { if let fieldValue = node["fieldValueByName"] as? [String: Any], let statusName = fieldValue["name"] as? String { - issues[index].projectStatus = mapProjectStatus(statusName) + issues[index].projectStatus = TicketStatus(projectBoardName: statusName) break } } } } - private func mapProjectStatus(_ name: String) -> TicketStatus { - switch name.lowercased().trimmingCharacters(in: .whitespaces) { - case "backlog": - return .backlog - case "ready", "todo", "to do": - return .ready - case "in progress", "doing", "active": - return .inProgress - case "in review", "review": - return .inReview - case "done", "closed", "complete", "completed": - return .done - default: - return .unknown - } - } - // MARK: - Mark In Review func markInReview(sessionID: UUID) async { @@ -564,20 +600,19 @@ final class IssueTracker { let ticketURL = session.ticketURL, session.provider == .github else { return } - // Parse owner/repo/number from URL like "https://github.com/org/repo/issues/123" - let components = ticketURL.split(separator: "/") - guard components.count >= 5, - let number = Int(components.last ?? "") else { + guard let parsed = ProviderManager.parseTicketURLComponents(ticketURL) else { print("[IssueTracker] Could not parse ticket URL: \(ticketURL)") return } - let owner = String(components[components.count - 4]) - let repoName = String(components[components.count - 3]) + let owner = parsed.org + let repoName = parsed.repo + let number = parsed.number appState.isMarkingInReview[sessionID] = true defer { appState.isMarkingInReview[sessionID] = false } - // Step 1: Query for project item ID, project ID, field ID, and "In Review" option ID + // Step 1: Query the project item ID, project ID, Status field ID, and available options + // so we can find the "In Review" option to set. let query = """ query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { @@ -670,7 +705,7 @@ final class IssueTracker { return } - // Step 2: Mutation to update the status + // Step 2: Mutation to update the Status field to the "In Review" option let mutation = """ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { updateProjectV2ItemFieldValue(input: {