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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import CrowCore
/// See ADR 0005 for the protocol contract.
public struct GitHubCodeBackend: CodeBackend {
public let provider: Provider = .github
public let cliName: String = "gh"
public let capabilities: Set<CodeCapability> = [.autoMergeLabel, .batchedPRStates]

private let shellRunner: ShellRunner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import CrowCore
/// See ADR 0005.
public struct GitLabCodeBackend: CodeBackend {
public let provider: Provider = .gitlab
public let cliName: String = "glab"
public let capabilities: Set<CodeCapability> = []

private let shellRunner: ShellRunner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ public protocol CodeBackend: Sendable {
/// Which provider this backend serves.
var provider: Provider { get }

/// CLI binary name this backend shells to (e.g. "gh", "glab"). Used by
/// prompt-builders that render copy-paste hints — see `AutoRespondPrompts`.
var cliName: String { get }

/// What this backend can do, beyond the protocol's required methods.
/// Replaces `guard session.provider == .github` style guards in call sites.
var capabilities: Set<CodeCapability> { get }
Expand Down
1 change: 1 addition & 0 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// `self.appConfig` so toggles take effect on the next transition.
self.autoRespondCoordinator = AutoRespondCoordinator(
appState: appState,
providerManager: providerManager,
settingsProvider: { [weak self] in
self?.appConfig?.autoRespond ?? AutoRespondSettings()
}
Expand Down
61 changes: 38 additions & 23 deletions Sources/Crow/App/AutoRespondCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import CrowCore
import CrowProvider
import CrowTerminal

/// Handles auto-respond to PR status transitions: when CrowConfig opts in
Expand All @@ -16,13 +17,15 @@ import CrowTerminal
@MainActor
final class AutoRespondCoordinator {
private let appState: AppState
private let providerManager: ProviderManager
/// Closure that returns the current `AutoRespondSettings`. Closure rather
/// than a stored value so updates from Settings UI take effect on the
/// next transition without explicit wiring.
private let settingsProvider: () -> AutoRespondSettings

init(appState: AppState, settingsProvider: @escaping () -> AutoRespondSettings) {
init(appState: AppState, providerManager: ProviderManager, settingsProvider: @escaping () -> AutoRespondSettings) {
self.appState = appState
self.providerManager = providerManager
self.settingsProvider = settingsProvider
}

Expand Down Expand Up @@ -66,8 +69,7 @@ final class AutoRespondCoordinator {
return
}

let provider = appState.sessions.first(where: { $0.id == transition.sessionID })?.provider ?? .github
let prompt = AutoRespondPrompts.build(for: transition, provider: provider)
let prompt = AutoRespondPrompts.build(for: transition, codeBackend: resolveCodeBackend(forSessionID: transition.sessionID))
NSLog("[AutoRespond] Sending %@ prompt to terminal %@ (%d chars)",
transition.kind.rawValue, terminal.id.uuidString, prompt.count)
TerminalRouter.send(terminal, text: prompt)
Expand Down Expand Up @@ -95,19 +97,32 @@ final class AutoRespondCoordinator {
return
}

let session = appState.sessions.first(where: { $0.id == sessionID })
let provider = session?.provider ?? .github
let prNumber = QuickActionPrompts.parsePRNumber(from: prLink.url)
let prompt = QuickActionPrompts.build(
action: action,
provider: provider,
codeBackend: resolveCodeBackend(forSessionID: sessionID),
prURL: prLink.url,
prNumber: prNumber
)
NSLog("[QuickAction] Sending %@ prompt to terminal %@ (%d chars)",
action.rawValue, terminal.id.uuidString, prompt.count)
TerminalRouter.send(terminal, text: prompt)
}

/// Resolve the `CodeBackend` to use for a session's prompt rendering.
/// Uses `session.codeProvider ?? session.provider` per the convention in
/// `Session.swift` (ADR 0005, #420). Falls back to GitHub when no session
/// is found or when the resolved provider has no code surface (Corveil);
/// `.github` always yields a non-nil backend.
private func resolveCodeBackend(forSessionID sessionID: UUID) -> CodeBackend {
let session = appState.sessions.first(where: { $0.id == sessionID })
let codeProvider = session?.codeProvider ?? session?.provider ?? .github
if let backend = providerManager.codeBackend(for: codeProvider) {
return backend
}
// Corveil (or unknown) has no code surface — fall back to gh tooling.
return providerManager.codeBackend(for: .github)!
}
}

/// Builds the deterministic prompt text injected into a session's managed
Expand All @@ -122,21 +137,21 @@ final class AutoRespondCoordinator {
/// matching the proven pattern used by `crow send "/crow-workspace ...\n"`
/// (AppDelegate.swift:203).
enum AutoRespondPrompts {
static func build(for transition: PRStatusTransition, provider: Provider) -> String {
static func build(for transition: PRStatusTransition, codeBackend: CodeBackend) -> String {
let prRef = transition.prNumber.map { "PR #\($0)" } ?? "the PR"
let cli = provider == .gitlab ? "glab" : "gh"
let cli = codeBackend.cliName

switch transition.kind {
case .changesRequested:
let fetchHint: String
let reRequestHint: String
if provider == .gitlab {
fetchHint = "Run `glab mr view \(transition.prURL) --comments` to read the review feedback."
reRequestHint = "After pushing, re-request review from each reviewer who requested changes by running `glab mr update \(transition.prURL) --reviewer <login>` for each one (the reviewer logins are in the review data you already fetched)."
if codeBackend.provider == .gitlab {
fetchHint = "Run `\(cli) mr view \(transition.prURL) --comments` to read the review feedback."
reRequestHint = "After pushing, re-request review from each reviewer who requested changes by running `\(cli) mr update \(transition.prURL) --reviewer <login>` for each one (the reviewer logins are in the review data you already fetched)."
} else {
let prNumStr = transition.prNumber.map(String.init) ?? "<number>"
fetchHint = "Run `gh pr view \(transition.prURL) --json reviews,comments` (and `gh api repos/{owner}/{repo}/pulls/\(prNumStr)/comments` for inline comments) to read the full review feedback."
reRequestHint = "After pushing, re-request review from each reviewer who requested changes by running `gh pr edit \(transition.prURL) --add-reviewer <login>` for each one (the reviewer logins are in the review data you already fetched)."
fetchHint = "Run `\(cli) pr view \(transition.prURL) --json reviews,comments` (and `\(cli) api repos/{owner}/{repo}/pulls/\(prNumStr)/comments` for inline comments) to read the full review feedback."
reRequestHint = "After pushing, re-request review from each reviewer who requested changes by running `\(cli) pr edit \(transition.prURL) --add-reviewer <login>` for each one (the reviewer logins are in the review data you already fetched)."
}
return "Crow detected a 'changes requested' review on \(prRef) (\(transition.prURL)). \(fetchHint) Address every reviewer comment in code, commit the fix, and push so the PR updates. If a comment is unclear or you disagree, leave a reply explaining your reasoning instead of changing the code. \(reRequestHint)\n"

Expand All @@ -150,8 +165,8 @@ enum AutoRespondPrompts {
failedSummary = " Failing checks: \(names)\(extra)."
}
let logHint: String
if provider == .gitlab {
logHint = "Run `glab ci view` / `glab ci trace` on the failing pipeline to read the logs."
if codeBackend.provider == .gitlab {
logHint = "Run `\(cli) ci view` / `\(cli) ci trace` on the failing pipeline to read the logs."
} else {
logHint = "Run `\(cli) pr checks \(transition.prURL)` to list the failing checks, then `\(cli) run view --log-failed <run-id>` to read the failure output."
}
Expand All @@ -165,9 +180,9 @@ enum AutoRespondPrompts {
/// `addressChanges` and `fixChecks` cases delegate to `AutoRespondPrompts`
/// so the auto and manual paths share a single source of truth.
enum QuickActionPrompts {
static func build(action: QuickAction, provider: Provider, prURL: String, prNumber: Int?) -> String {
static func build(action: QuickAction, codeBackend: CodeBackend, prURL: String, prNumber: Int?) -> String {
let prRef = prNumber.map { "PR #\($0)" } ?? "the PR"
let cli = provider == .gitlab ? "glab" : "gh"
let cli = codeBackend.cliName

switch action {
case .addressChanges:
Expand All @@ -178,7 +193,7 @@ enum QuickActionPrompts {
prURL: prURL,
prNumber: prNumber
)
return AutoRespondPrompts.build(for: synthetic, provider: provider)
return AutoRespondPrompts.build(for: synthetic, codeBackend: codeBackend)

case .fixChecks:
// Reuse the existing checks-failing prompt verbatim. We don't
Expand All @@ -190,21 +205,21 @@ enum QuickActionPrompts {
prURL: prURL,
prNumber: prNumber
)
return AutoRespondPrompts.build(for: synthetic, provider: provider)
return AutoRespondPrompts.build(for: synthetic, codeBackend: codeBackend)

case .fixConflicts:
let rebaseHint: String
if provider == .gitlab {
rebaseHint = "Rebase your branch onto the latest target branch (`git fetch origin && git rebase origin/<target>` or `glab mr rebase`), resolve the conflicts in the affected files, run the relevant tests, then force-push with `--force-with-lease` to update the MR."
if codeBackend.provider == .gitlab {
rebaseHint = "Rebase your branch onto the latest target branch (`git fetch origin && git rebase origin/<target>` or `\(cli) mr rebase`), resolve the conflicts in the affected files, run the relevant tests, then force-push with `--force-with-lease` to update the MR."
} else {
rebaseHint = "Rebase your branch onto the latest base branch (`git fetch origin && git rebase origin/<base>`), resolve the conflicts in the affected files, run the relevant tests, then force-push with `--force-with-lease` to update the PR."
}
return "Crow detected merge conflicts on \(prRef) (\(prURL)). \(rebaseHint)\n"

case .mergePR:
let mergeHint: String
if provider == .gitlab {
mergeHint = "Run `glab mr view \(prURL)` to verify the MR is in the expected state, then `glab mr merge \(prURL)` to merge. If the project uses a different merge strategy or extra steps, adjust accordingly."
if codeBackend.provider == .gitlab {
mergeHint = "Run `\(cli) mr view \(prURL)` to verify the MR is in the expected state, then `\(cli) mr merge \(prURL)` to merge. If the project uses a different merge strategy or extra steps, adjust accordingly."
} else {
mergeHint = "Run `\(cli) pr view \(prURL)` to verify the PR is in the expected state, then `cd \"$TMPDIR\" && \(cli) pr merge \(prURL) --squash --delete-branch` to merge. The `cd` keeps `gh`'s post-merge git cleanup (which runs in the CWD) from tripping when `main` is checked out in another worktree. If the repo uses a different merge strategy, adjust accordingly."
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import Testing
import CrowCore
import CrowProvider
@testable import Crow

/// Verifies the policy gate added for #311: review sessions must never
Expand All @@ -16,6 +17,7 @@ struct AutoRespondCoordinatorReviewSessionTests {
state.sessions = sessions
return AutoRespondCoordinator(
appState: state,
providerManager: ProviderManager(),
settingsProvider: { AutoRespondSettings(respondToChangesRequested: true, respondToFailedChecks: true) }
)
}
Expand Down
16 changes: 14 additions & 2 deletions Tests/CrowTests/QuickActionPromptsTests.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import Foundation
import Testing
import CrowCore
import CrowProvider
@testable import Crow

/// Minimal `CodeBackend` for prompt-rendering tests — only `provider` and
/// `cliName` are read by the builders. The other protocol methods are
/// unused, so trivial stubs are fine.
private struct FakeCodeBackend: CodeBackend {
let provider: Provider
let cliName: String
let capabilities: Set<CodeCapability> = []
func linkedPR(repo: String, branch: String) async throws -> LinkedPR? { nil }
func ensureMergeLabel(repo: String) async throws {}
}

@Suite("QuickActionPrompts.mergePR")
struct QuickActionPromptsTests {

@Test func githubMergeHintRunsFromTmpdirToAvoidWorktreeConflict() {
let prompt = QuickActionPrompts.build(
action: .mergePR,
provider: .github,
codeBackend: FakeCodeBackend(provider: .github, cliName: "gh"),
prURL: "https://github.com/radiusmethod/crow/pull/123",
prNumber: 123
)
Expand All @@ -21,7 +33,7 @@ struct QuickActionPromptsTests {
@Test func gitlabMergeHintIsUnaffected() {
let prompt = QuickActionPrompts.build(
action: .mergePR,
provider: .gitlab,
codeBackend: FakeCodeBackend(provider: .gitlab, cliName: "glab"),
prURL: "https://gitlab.example.com/org/repo/-/merge_requests/45",
prNumber: 45
)
Expand Down
Loading