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
9 changes: 9 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,11 @@ public final class AppState {
/// Called to mark a session's ticket as "In Review" on the GitHub Project board.
public var onMarkInReview: ((UUID) -> Void)?

/// Called to move a session's linked issue to its done/closed state on the
/// provider (GitHub close, GitLab close, Jira/Corveil transition to the
/// mapped completed status). On success also flips the session to `.completed`.
public var onMarkIssueDone: ((UUID) -> Void)?

/// Called to add the `crow:merge` auto-merge label to a session's PR.
public var onAddMergeLabel: ((UUID) -> Void)?

Expand All @@ -361,6 +366,10 @@ public final class AppState {
/// Must be cleaned up when a session is deleted (see `SessionService.deleteSession`).
public var isMarkingInReview: [UUID: Bool] = [:]

/// Whether a session's linked issue is currently being closed/transitioned to
/// done (loading state). Cleaned up when a session is deleted.
public var isMarkingIssueDone: [UUID: Bool] = [:]

/// Whether a session's PR is currently being labeled with `crow:merge` (loading state).
/// Must be cleaned up when a session is deleted (see `SessionService.deleteSession`).
public var isAddingMergeLabel: [UUID: Bool] = [:]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ public struct CorveilTaskBackend: TaskBackend {
])
}

public func closeTask(url: String) async throws {
// Corveil's terminal state is the `closed` status. Reuse
// `setTaskStatus(.done)`, which maps `.done` → `"closed"` via
// `corveilStatusName`.
try await setTaskStatus(url: url, status: .done)
}

public func assign(url: String, to login: String) async throws {
guard let parsed = CorveilTaskID.parse(url) else { throw ProviderError.invalidURL(url) }
_ = try await run([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ public struct GitHubTaskBackend: TaskBackend {
}
}

public func closeTask(url: String) async throws {
guard ProviderManager.parseTicketURLComponents(url) != nil else {
throw ProviderError.invalidURL(url)
}
// `gh issue close` is idempotent — closing an already-closed issue exits 0.
do {
_ = try await shellRunner.run("gh", "issue", "close", url)
} catch ShellRunnerError.nonZeroExit(_, let output) {
throw ProviderError.commandFailed(output)
}
}

public func assign(url: String, to login: String) async throws {
_ = try await shellRunner.run(
"gh", "issue", "edit", url, "--add-assignee", login
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ public struct GitLabTaskBackend: TaskBackend {
throw ProviderError.unimplemented("GitLabTaskBackend.setTaskStatus: no projectBoardStatus capability")
}

public func closeTask(url: String) async throws {
guard let parsed = ProviderManager.parseTicketURLComponents(url) else {
throw ProviderError.invalidURL(url)
}
let repoSlug = "\(parsed.org)/\(parsed.repo)"
// `glab issue close` is idempotent — closing an already-closed issue exits 0.
_ = try await shellRunner.run(
args: ["glab", "issue", "close", "\(parsed.number)", "--repo", repoSlug],
env: env(),
cwd: NSHomeDirectory()
)
}

public func assign(url: String, to login: String) async throws {
guard let parsed = ProviderManager.parseTicketURLComponents(url) else {
throw ProviderError.invalidURL(url)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ public struct JiraTaskBackend: TaskBackend {
])
}

public func closeTask(url: String) async throws {
// Jira has no "close" verb — the terminal state is a workflow transition
// to the mapped completed status. Reuse `setTaskStatus(.done)`, which
// resolves `jiraStatusName(for: .done)` honoring `JiraConfig.statusMap`
// (#523) with the "Done" default.
try await setTaskStatus(url: url, status: .done)
}

public func assign(url: String, to login: String) async throws {
guard let parsed = JiraKey.parse(url) else { throw ProviderError.invalidURL(url) }
_ = try await run([
Expand Down
10 changes: 10 additions & 0 deletions Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ public protocol TaskBackend: Sendable {
/// mutation here — no more legacy escape-hatch through IssueTracker.
func setTaskStatus(url: String, status: TicketStatus) async throws

/// Move a task to its terminal done/closed state.
/// Distinct from `setTaskStatus(.done)`: for GitHub that sets a Projects-v2
/// board column and does **not** close the issue — this actually closes it
/// (`gh issue close`). GitLab closes the issue (`glab issue close`); Jira
/// transitions to the mapped completed status (#523's `jiraStatusMap`, "Done"
/// fallback); Corveil sets `closed`. Required across all backends (every
/// provider can close), so it is **not** capability-gated on
/// `.projectBoardStatus`. See ADR 0005.
func closeTask(url: String) async throws

/// Assign an issue to `login` (e.g. `@me` for the authenticated user).
/// Used by session setup to claim a ticket and by skill flows.
func assign(url: String, to login: String) async throws
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,40 @@ final class BackendsTests: XCTestCase {
XCTAssertTrue(fake.calls[0].args.contains("@me"))
}

func testGitHubTaskBackendCloseTaskRunsGhIssueClose() async throws {
let fake = FakeShellRunner()
let backend = GitHubTaskBackend(shellRunner: fake)
try await backend.closeTask(url: "https://github.com/acme/api/issues/42")
XCTAssertEqual(fake.calls.count, 1)
XCTAssertEqual(fake.calls[0].args, ["gh", "issue", "close", "https://github.com/acme/api/issues/42"])
}

func testGitHubTaskBackendCloseTaskRejectsInvalidURL() async {
let backend = GitHubTaskBackend(shellRunner: FakeShellRunner())
do {
try await backend.closeTask(url: "not-a-url")
XCTFail("expected throw")
} catch ProviderError.invalidURL {
// expected
} catch {
XCTFail("unexpected error \(error)")
}
}

func testGitHubTaskBackendCloseTaskSurfacesCommandFailure() async {
let fake = FakeShellRunner()
fake.responses = [.failure(ShellRunnerError.nonZeroExit(exitCode: 1, output: "gh: not authenticated"))]
let backend = GitHubTaskBackend(shellRunner: fake)
do {
try await backend.closeTask(url: "https://github.com/acme/api/issues/42")
XCTFail("expected throw")
} catch ProviderError.commandFailed(let msg) {
XCTAssertTrue(msg.contains("not authenticated"))
} catch {
XCTFail("unexpected error \(error)")
}
}

func testGitHubTaskBackendCreateTaskReturnsParsedURL() async throws {
let fake = FakeShellRunner()
fake.responses = [.success("Creating issue in acme/api\n\nhttps://github.com/acme/api/issues/99\n")]
Expand Down Expand Up @@ -547,6 +581,15 @@ final class BackendsTests: XCTestCase {
XCTAssertTrue(fake.calls[0].args.contains("alice"))
}

func testGitLabTaskBackendCloseTaskRunsGlabIssueClose() async throws {
let fake = FakeShellRunner()
let backend = GitLabTaskBackend(shellRunner: fake, host: "gitlab.example.com")
try await backend.closeTask(url: "https://gitlab.example.com/g/p/-/issues/7")
XCTAssertEqual(fake.calls.count, 1)
XCTAssertEqual(fake.calls[0].args, ["glab", "issue", "close", "7", "--repo", "g/p"])
XCTAssertEqual(fake.calls[0].env["GITLAB_HOST"], "gitlab.example.com")
}

func testGitLabTaskBackendSetTaskStatusThrowsUnimplemented() async {
let backend = GitLabTaskBackend(shellRunner: FakeShellRunner(), host: nil)
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ final class CorveilTaskBackendTests: XCTestCase {
XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "in_progress")
}

func testCloseTaskSetsClosedStatus() async throws {
let fake = FakeShellRunner()
try await backend(fake).closeTask(url: "https://corveil.io/dashboard/tasks/42")
let args = fake.calls.first?.args ?? []
XCTAssertEqual(Array(args.prefix(4)), ["corveil", "task", "update", "42"])
XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "closed")
}

func testStatusNameMappingCoversAllCases() {
XCTAssertEqual(CorveilTaskBackend.corveilStatusName(for: .backlog), "open")
XCTAssertEqual(CorveilTaskBackend.corveilStatusName(for: .ready), "open")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,25 @@ final class JiraTaskBackendTests: XCTestCase {
XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "Code Review")
}

// MARK: - closeTask

func testCloseTaskTransitionsToDefaultDone() async throws {
let fake = FakeShellRunner()
try await backend(fake).closeTask(url: "https://acme.atlassian.net/browse/PROJ-5")
let args = fake.calls.first?.args ?? []
XCTAssertEqual(Array(args.prefix(4)), ["acli", "jira", "workitem", "transition"])
XCTAssertEqual(args[args.firstIndex(of: "--key")! + 1], "PROJ-5")
XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "Done")
}

func testCloseTaskUsesMappedDoneName() async throws {
let fake = FakeShellRunner()
let cfg = JiraConfig(statusMap: ["Done": "Resolved"])
try await backend(fake, config: cfg).closeTask(url: "https://acme.atlassian.net/browse/PROJ-5")
let args = fake.calls.first?.args ?? []
XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "Resolved")
}

// MARK: - assign

func testAssignInvokesAcliAssign() async throws {
Expand Down
20 changes: 20 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,16 @@ public struct SessionListView: View {
.disabled(appState.isMarkingInReview[session.id] == true || deleting)
}

if (session.status == .active || session.status == .inReview),
session.ticketURL != nil {
Button {
appState.onMarkIssueDone?(session.id)
} label: {
Label(Self.markIssueDoneTitle(for: session.provider), systemImage: "checkmark.seal")
}
.disabled(appState.isMarkingIssueDone[session.id] == true || deleting)
}

if session.status == .active || session.status == .inReview {
Button {
appState.onCompleteSession?(session.id)
Expand All @@ -329,6 +339,16 @@ public struct SessionListView: View {
.disabled(deleting)
}

/// Provider-flavored title for the "mark issue done" item: GitHub/GitLab
/// *close* the issue, while Jira/Corveil *transition* it to a done status.
private static func markIssueDoneTitle(for provider: Provider?) -> String {
switch provider {
case .github, .gitlab: return "Close Issue"
case .jira, .corveil: return "Mark Issue Done"
case .none: return "Mark Issue Done"
}
}

/// "Add label crow:merge to PR" — shown only when the session has a PR link
/// and its code backend supports the auto-merge label (capability-gated via
/// `canAddMergeLabel`). Self-gating so callers can include it unconditionally.
Expand Down
4 changes: 4 additions & 0 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await tracker?.markInReview(sessionID: id) }
}

appState.onMarkIssueDone = { [weak tracker] id in
Task { await tracker?.markIssueDone(sessionID: id) }
}

appState.canAddMergeLabelResolver = { [providerManager] session in
guard let provider = session.provider else { return false }
return providerManager
Expand Down
48 changes: 48 additions & 0 deletions Sources/Crow/App/IssueTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2481,6 +2481,54 @@ final class IssueTracker {
appState.onSetSessionInReview?(sessionID)
}

// MARK: - Mark Issue Done

/// Move a session's linked issue to its done/closed state on the provider
/// (GitHub/GitLab close the issue; Jira/Corveil transition to the mapped
/// completed status), then flip the Crow session to `.completed`. Best-effort:
/// auth / transition-not-allowed / already-closed failures are logged and
/// swallowed (no crash). Mirrors `markInReview`'s in-flight/error handling.
func markIssueDone(sessionID: UUID) async {
guard let session = appState.sessions.first(where: { $0.id == sessionID }),
let ticketURL = session.ticketURL,
let taskProvider = session.provider else { return }

// For Jira, thread the matching workspace's per-project status-name map
// (#523) so the transition targets a renamed "Done" workflow status. For
// every other provider, resolve provider + host straight from the URL so
// GitLab/Corveil self-hosted instances are targeted correctly.
let backend: TaskBackend
if taskProvider == .jira {
let cfg = JiraConfig(statusMap: Self.jiraStatusMap(forTicket: ticketURL))
backend = providerManager.taskBackend(for: .jira, jira: cfg)
} else {
backend = providerManager.taskBackend(forURL: ticketURL)
}

appState.isMarkingIssueDone[sessionID] = true
defer { appState.isMarkingIssueDone[sessionID] = false }

do {
try await backend.closeTask(url: ticketURL)
} catch ProviderError.unimplemented(let msg) {
print("[IssueTracker] markIssueDone: \(msg)")
return
} catch {
print("[IssueTracker] markIssueDone failed for \(ticketURL): \(error.localizedDescription.prefix(200))")
return
}

// Reflect locally — match by URL so it works regardless of provider.
if let idx = appState.assignedIssues.firstIndex(where: { $0.url == ticketURL }) {
appState.assignedIssues[idx].projectStatus = .done
}

print("[IssueTracker] Marked issue done: \(ticketURL)")

// Flip the Crow session to .completed so the row reflects the closed issue.
appState.onCompleteSession?(sessionID)
}

/// Add the `crow:merge` auto-merge label to a session's PR, ensuring the
/// label exists in the repo first. Capability-gated on `.autoMergeLabel`
/// (GitHub only today). Mirrors `markInReview`'s in-flight/error handling.
Expand Down
1 change: 1 addition & 0 deletions Sources/Crow/App/SessionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,7 @@ final class SessionService {
appState.removeHookState(for: id)
appState.prStatus.removeValue(forKey: id)
appState.isMarkingInReview.removeValue(forKey: id)
appState.isMarkingIssueDone.removeValue(forKey: id)
appState.isAddingMergeLabel.removeValue(forKey: id)
appState.isDeletingSession.removeValue(forKey: id)

Expand Down
Loading