diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index 8889366..e179aa9 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -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)? @@ -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] = [:] diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift index 004373c..582e714 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift @@ -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([ diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/GitHubTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/GitHubTaskBackend.swift index 52df938..9a90d1f 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/GitHubTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/GitHubTaskBackend.swift @@ -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 diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/GitLabTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/GitLabTaskBackend.swift index 996e302..2097d59 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/GitLabTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/GitLabTaskBackend.swift @@ -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) diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift index 671b80b..94301b7 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift @@ -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([ diff --git a/Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift index 4571453..be7920c 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift @@ -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 diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/BackendsTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/BackendsTests.swift index 5dfcaa1..e22f2e8 100644 --- a/Packages/CrowProvider/Tests/CrowProviderTests/BackendsTests.swift +++ b/Packages/CrowProvider/Tests/CrowProviderTests/BackendsTests.swift @@ -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")] @@ -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 { diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift index 45f7258..1b80ac2 100644 --- a/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift +++ b/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift @@ -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") diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift index e426b86..8b84d63 100644 --- a/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift +++ b/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift @@ -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 { diff --git a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift index 3fa9922..c256481 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -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) @@ -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. diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index fef7a38..5e2d512 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -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 diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index fee998b..e58f854 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -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. diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index e855f82..1fffe55 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -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)