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
4 changes: 2 additions & 2 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public actor ClaudeLauncher {
lines.append("```bash")
lines.append("glab issue view \(url) --comments")
lines.append("```")
case nil:
case .corveil, nil:
lines.append("URL: \(url)")
}
}
Expand Down Expand Up @@ -108,7 +108,7 @@ public actor ClaudeLauncher {
lines.append("glab mr create --fill --target-branch main")
}
lines.append("```")
case nil:
case .corveil, nil:
lines.append("6. Open a pull request\(suffix)")
}
}
Expand Down
2 changes: 1 addition & 1 deletion Packages/CrowCodex/Sources/CrowCodex/CodexLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public actor CodexLauncher {
lines.append("```bash")
lines.append("glab issue view \(url) --comments")
lines.append("```")
case nil:
case .corveil, nil:
lines.append("URL: \(url)")
}
}
Expand Down
1 change: 1 addition & 0 deletions Packages/CrowCore/Sources/CrowCore/Models/Enums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public enum SessionStatus: String, Codable, Sendable {
public enum Provider: String, Codable, Sendable {
case github
case gitlab
case corveil
}

/// Type of link associated with a session.
Expand Down
80 changes: 80 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/ShellRunner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation

/// Runs subprocesses on behalf of provider backends.
///
/// Provider backends (`GitHubTaskBackend`, `GitLabCodeBackend`, …) take a `ShellRunner`
/// at init so unit tests can inject a fake that returns canned JSON and asserts the
/// command vector. The production implementation is `ProcessShellRunner`, which uses
/// `Process()` with the resolved PATH from `ShellEnvironment.shared`.
///
/// See ADR 0005 — TaskBackend and CodeBackend protocols.
public protocol ShellRunner: Sendable {
/// Run a subprocess and return its stdout (and stderr — they're merged).
///
/// - Parameters:
/// - args: Command and arguments, e.g. `["gh", "issue", "view", url]`.
/// - env: Additional environment variables merged on top of `ShellEnvironment.shared.env`.
/// - cwd: Working directory. Pass `nil` to inherit the current process's cwd.
/// - Returns: stdout+stderr as a UTF-8 string.
/// - Throws: `ShellRunnerError.nonZeroExit` if the process returns non-zero.
func run(args: [String], env: [String: String], cwd: String?) async throws -> String
}

extension ShellRunner {
/// Variadic convenience: `try await runner.run("gh", "issue", "view", url)`.
public func run(_ args: String...) async throws -> String {
try await run(args: args, env: [:], cwd: nil)
}

/// Convenience for the common case of `env` only.
public func run(env: [String: String], _ args: String...) async throws -> String {
try await run(args: args, env: env, cwd: nil)
}
}

public enum ShellRunnerError: Error, Sendable {
/// Process exited non-zero. `output` is the merged stdout+stderr.
case nonZeroExit(exitCode: Int32, output: String)
}

/// Default production implementation: spawns `/usr/bin/env <args>` with
/// `ShellEnvironment.shared` merged in, captures stdout+stderr.
public struct ProcessShellRunner: ShellRunner {
public init() {}

public func run(args: [String], env: [String: String], cwd: String?) async throws -> String {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = args
process.environment = env.isEmpty
? ShellEnvironment.shared.env
: ShellEnvironment.shared.merging(env)
if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) }
process.standardOutput = pipe
process.standardError = pipe

// Drain the pipe on a background task so a >64KB output can't deadlock waitUntilExit.
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
Task.detached {
do {
try process.run()
} catch {
cont.resume(throwing: error)
return
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let output = String(data: data, encoding: .utf8) ?? ""
if process.terminationStatus == 0 {
cont.resume(returning: output)
} else {
cont.resume(throwing: ShellRunnerError.nonZeroExit(
exitCode: process.terminationStatus,
output: output
))
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation
import CrowCore

/// `CodeBackend` implementation for GitHub. Wraps the `gh` CLI for PR/label operations.
///
/// Capabilities:
/// - `.autoMergeLabel` — supports `gh label create crow:merge`.
/// - `.batchedPRStates` — could fetch multiple PR states in one GraphQL call.
///
/// See ADR 0005 for the protocol contract.
public struct GitHubCodeBackend: CodeBackend {
public let provider: Provider = .github
public let capabilities: Set<CodeCapability> = [.autoMergeLabel, .batchedPRStates]

private let shellRunner: ShellRunner

public init(shellRunner: ShellRunner) {
self.shellRunner = shellRunner
}

public func linkedPR(repo: String, branch: String) async throws -> LinkedPR? {
let output = try await shellRunner.run(
"gh", "pr", "list",
"--repo", repo,
"--head", branch,
"--state", "all",
"--json", "number,url,state",
"--limit", "1"
)
guard let data = output.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let first = arr.first,
let number = first["number"] as? Int,
let url = first["url"] as? String,
let state = first["state"] as? String else {
return nil
}
return LinkedPR(number: number, url: url, state: state)
}

public func ensureMergeLabel(repo: String) async throws {
// `gh label create` is idempotent-ish: it errors if the label already
// exists. Swallow that one error; surface anything else with the
// original exit code intact.
do {
_ = try await shellRunner.run(
"gh", "label", "create", "crow:merge",
"--repo", repo,
"--color", "1D76DB",
"--description", "Auto-merge when checks pass"
)
} catch ShellRunnerError.nonZeroExit(_, let output) where output.localizedCaseInsensitiveContains("already exists") {
return
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation
import CrowCore

/// `TaskBackend` implementation for GitHub. Wraps the `gh` CLI.
///
/// Capabilities declared:
/// - `.batchedQuery` — `listAssigned`-style fetches use one GraphQL call.
///
/// Note: `.projectBoardStatus` is intentionally *not* declared until the
/// `markInReview` GraphQL migration lands (see `setTaskStatus` below and
/// IssueTracker.swift:2539). The flag is contract: declaring it means a
/// capability-gated caller can call `setTaskStatus` and expect success.
/// We add the flag when the implementation arrives, not before.
///
/// See ADR 0005 for the protocol contract and ADR 0005's Context section for why
/// task ops are separate from code ops.
public struct GitHubTaskBackend: TaskBackend {
public let provider: Provider = .github
public let capabilities: Set<TaskCapability> = [.batchedQuery]

private let shellRunner: ShellRunner

public init(shellRunner: ShellRunner) {
self.shellRunner = shellRunner
}

// MARK: - TaskBackend

public func fetchTask(url: String) async throws -> TicketInfo {
guard let parsed = ProviderManager.parseTicketURLComponents(url) else {
throw ProviderError.invalidURL(url)
}
// fetchTask is issue-only by contract; if the URL is a PR the caller
// should be using CodeBackend. Reject here rather than silently fetching
// a PR — the asymmetry would invite latent bugs.
if parsed.isMR {
throw ProviderError.invalidURL("fetchTask received a pull request URL: \(url)")
}
let output = try await shellRunner.run("gh", "issue", "view", url, "--json", "title,body,labels")
let title = Self.extractTitle(from: output) ?? "Ticket #\(parsed.number)"
return TicketInfo(
number: parsed.number,
title: title,
repo: parsed.repo,
org: parsed.org,
url: url,
provider: .github,
isMR: false
)
}

public func setLabels(url: String, add: [String], remove: [String]) async throws {
guard !add.isEmpty || !remove.isEmpty else { return }
var args: [String] = ["gh", "issue", "edit", url]
for label in add {
args.append("--add-label")
args.append(label)
}
for label in remove {
args.append("--remove-label")
args.append(label)
}
_ = try await shellRunner.run(args: args, env: [:], cwd: nil)
}

public func setTaskStatus(url: String, status: TicketStatus) async throws {
// Real implementation requires the GraphQL project-item lookup + mutation
// sequence currently inlined at IssueTracker.swift:2539. That migration is
// deferred to a follow-up PR — see ADR 0005 references.
// The capability is declared so callers don't fall into the throw path
// before the migration lands.
throw ProviderError.unimplemented(
"GitHubTaskBackend.setTaskStatus: migration of markInReview project-board mutation pending"
)
}

// MARK: - Helpers

private static func extractTitle(from output: String) -> String? {
if let data = output.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let title = json["title"] as? String {
return title
}
return output.components(separatedBy: .newlines).first
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
import CrowCore

/// `CodeBackend` implementation for GitLab. Wraps the `glab` CLI.
///
/// Capabilities: none in v1. The merge-label flow at IssueTracker.swift:2037 is
/// GitHub-specific today; once GitLab gets equivalent CI gating, declare `.autoMergeLabel`
/// and implement `ensureMergeLabel`.
///
/// See ADR 0005.
public struct GitLabCodeBackend: CodeBackend {
public let provider: Provider = .gitlab
public let capabilities: Set<CodeCapability> = []

private let shellRunner: ShellRunner
private let host: String?

public init(shellRunner: ShellRunner, host: String?) {
self.shellRunner = shellRunner
self.host = host
}

public func linkedPR(repo: String, branch: String) async throws -> LinkedPR? {
let env = self.env()
// `glab mr list` returns plain-text by default; ask for JSON via API.
// Pattern: list MRs whose source branch matches; pick first.
let output = try await shellRunner.run(
args: [
"glab", "mr", "list",
"--repo", repo,
"--source-branch", branch,
"--all",
"-F", "json"
],
env: env,
cwd: NSHomeDirectory()
)
guard let data = output.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let first = arr.first,
let iid = first["iid"] as? Int else {
return nil
}
let webURL = (first["web_url"] as? String) ?? ""
let state = (first["state"] as? String) ?? ""
return LinkedPR(number: iid, url: webURL, state: state)
}

public func ensureMergeLabel(repo: String) async throws {
// No `.autoMergeLabel` capability declared. Capability-gated callers
// skip this; a direct call is a programming error.
throw ProviderError.unimplemented("GitLabCodeBackend.ensureMergeLabel: no autoMergeLabel capability")
}

private func env() -> [String: String] {
guard let host else { return [:] }
return ["GITLAB_HOST": host]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation
import CrowCore

/// `TaskBackend` implementation for GitLab. Wraps the `glab` CLI.
///
/// Capabilities: none in v1 — GitLab issue boards aren't surfaced through Crow yet,
/// and `glab` doesn't batch issue queries the same way `gh` does.
///
/// See ADR 0005.
public struct GitLabTaskBackend: TaskBackend {
public let provider: Provider = .gitlab
public let capabilities: Set<TaskCapability> = []

private let shellRunner: ShellRunner
private let host: String?

public init(shellRunner: ShellRunner, host: String?) {
self.shellRunner = shellRunner
self.host = host
}

public func fetchTask(url: String) async throws -> TicketInfo {
guard let parsed = ProviderManager.parseTicketURLComponents(url) else {
throw ProviderError.invalidURL(url)
}
if parsed.isMR {
throw ProviderError.invalidURL("fetchTask received a merge request URL: \(url)")
}
let env = self.env()
let repoSlug = "\(parsed.org)/\(parsed.repo)"
let output = try await shellRunner.run(
args: ["glab", "issue", "view", "\(parsed.number)", "--repo", repoSlug],
env: env,
cwd: NSHomeDirectory()
)
let title = output.components(separatedBy: .newlines).first ?? "Ticket #\(parsed.number)"
return TicketInfo(
number: parsed.number,
title: title,
repo: parsed.repo,
org: parsed.org,
url: url,
provider: .gitlab,
isMR: false
)
}

public func setLabels(url: String, add: [String], remove: [String]) async throws {
guard !add.isEmpty || !remove.isEmpty else { return }
guard let parsed = ProviderManager.parseTicketURLComponents(url) else {
throw ProviderError.invalidURL(url)
}
let repoSlug = "\(parsed.org)/\(parsed.repo)"
let env = self.env()
var args: [String] = ["glab", "issue", "update", "\(parsed.number)", "--repo", repoSlug]
if !add.isEmpty {
args.append("--label")
args.append(add.joined(separator: ","))
}
if !remove.isEmpty {
args.append("--unlabel")
args.append(remove.joined(separator: ","))
}
_ = try await shellRunner.run(args: args, env: env, cwd: NSHomeDirectory())
}

public func setTaskStatus(url: String, status: TicketStatus) async throws {
// No project board support declared. Capability-gated callers won't hit this;
// anything that does is a programming error.
throw ProviderError.unimplemented("GitLabTaskBackend.setTaskStatus: no projectBoardStatus capability")
}

private func env() -> [String: String] {
guard let host else { return [:] }
return ["GITLAB_HOST": host]
}
}
Loading
Loading