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
19 changes: 15 additions & 4 deletions Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
}
}
12 changes: 12 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Models/Enums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Models/PRStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
49 changes: 49 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/PRStatusTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 43 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/TicketStatusTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions Packages/CrowProvider/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ let package = Package(
],
targets: [
.target(name: "CrowProvider", dependencies: ["CrowCore"]),
.testTarget(name: "CrowProviderTests", dependencies: ["CrowProvider"]),
]
)
44 changes: 30 additions & 14 deletions Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading