native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #96 from supabitapp/command-palette-close-pr

Add Close PR command palette action

authored by

khoi and committed by
GitHub
a291a80c 50c43408

+215 -19
+21
supacode/Clients/Github/GithubCLIClient.swift
··· 45 45 var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? 46 46 var batchPullRequests: @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] 47 47 var mergePullRequest: @Sendable (URL, Int, PullRequestMergeStrategy) async throws -> Void 48 + var closePullRequest: @Sendable (URL, Int) async throws -> Void 48 49 var markPullRequestReady: @Sendable (URL, Int) async throws -> Void 49 50 var rerunFailedJobs: @Sendable (URL, Int) async throws -> Void 50 51 var failedRunLogs: @Sendable (URL, Int) async throws -> String ··· 63 64 latestRun: latestRunFetcher(shell: shell, resolver: resolver), 64 65 batchPullRequests: batchPullRequestsFetcher(shell: shell, resolver: resolver), 65 66 mergePullRequest: mergePullRequestFetcher(shell: shell, resolver: resolver), 67 + closePullRequest: closePullRequestFetcher(shell: shell, resolver: resolver), 66 68 markPullRequestReady: markPullRequestReadyFetcher(shell: shell, resolver: resolver), 67 69 rerunFailedJobs: rerunFailedJobsFetcher(shell: shell, resolver: resolver), 68 70 failedRunLogs: failedRunLogsFetcher(shell: shell, resolver: resolver), ··· 77 79 latestRun: { _, _ in nil }, 78 80 batchPullRequests: { _, _, _, _ in [:] }, 79 81 mergePullRequest: { _, _, _ in }, 82 + closePullRequest: { _, _ in }, 80 83 markPullRequestReady: { _, _ in }, 81 84 rerunFailedJobs: { _, _ in }, 82 85 failedRunLogs: { _, _ in "" }, ··· 265 268 "merge", 266 269 "\(pullRequestNumber)", 267 270 "--\(strategy.ghArgument)", 271 + ], 272 + repoRoot: repoRoot 273 + ) 274 + } 275 + } 276 + 277 + nonisolated private func closePullRequestFetcher( 278 + shell: ShellClient, 279 + resolver: GithubCLIExecutableResolver 280 + ) -> @Sendable (URL, Int) async throws -> Void { 281 + { repoRoot, pullRequestNumber in 282 + _ = try await runGh( 283 + shell: shell, 284 + resolver: resolver, 285 + arguments: [ 286 + "pr", 287 + "close", 288 + "\(pullRequestNumber)", 268 289 ], 269 290 repoRoot: repoRoot 270 291 )
+3
supacode/Features/App/Reducer/AppFeature.swift
··· 581 581 case .commandPalette(.delegate(.mergePullRequest(let worktreeID))): 582 582 return .send(.repositories(.pullRequestAction(worktreeID, .merge))) 583 583 584 + case .commandPalette(.delegate(.closePullRequest(let worktreeID))): 585 + return .send(.repositories(.pullRequestAction(worktreeID, .close))) 586 + 584 587 case .commandPalette(.delegate(.copyFailingJobURL(let worktreeID))): 585 588 return .send(.repositories(.pullRequestAction(worktreeID, .copyFailingJobURL))) 586 589
+4
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 33 33 case openPullRequest(Worktree.ID) 34 34 case markPullRequestReady(Worktree.ID) 35 35 case mergePullRequest(Worktree.ID) 36 + case closePullRequest(Worktree.ID) 36 37 case copyFailingJobURL(Worktree.ID) 37 38 case copyCiFailureLogs(Worktree.ID) 38 39 case rerunFailedJobs(Worktree.ID) ··· 49 50 case .openPullRequest, 50 51 .markPullRequestReady, 51 52 .mergePullRequest, 53 + .closePullRequest, 52 54 .copyFailingJobURL, 53 55 .copyCiFailureLogs, 54 56 .rerunFailedJobs, ··· 70 72 case .openPullRequest, 71 73 .markPullRequestReady, 72 74 .mergePullRequest, 75 + .closePullRequest, 73 76 .copyFailingJobURL, 74 77 .copyCiFailureLogs, 75 78 .rerunFailedJobs, ··· 101 104 return AppShortcuts.openPullRequest 102 105 case .markPullRequestReady, 103 106 .mergePullRequest, 107 + .closePullRequest, 104 108 .copyFailingJobURL, 105 109 .copyCiFailureLogs, 106 110 .rerunFailedJobs,
+61 -17
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 42 42 case openPullRequest(Worktree.ID) 43 43 case markPullRequestReady(Worktree.ID) 44 44 case mergePullRequest(Worktree.ID) 45 + case closePullRequest(Worktree.ID) 45 46 case copyFailingJobURL(Worktree.ID) 46 47 case copyCiFailureLogs(Worktree.ID) 47 48 case rerunFailedJobs(Worktree.ID) ··· 313 314 return failingItems 314 315 } 315 316 316 - func makeMergeItem() -> CommandPaletteItem? { 317 - guard canMerge else { return nil } 318 - let successfulChecks = breakdown.passed 319 - let successfulChecksLabel = 320 - successfulChecks == 1 321 - ? "1 successful check" 322 - : "\(successfulChecks) successful checks" 323 - return CommandPaletteItem( 324 - id: CommandPaletteItemID.pullRequestMerge(repositoryID), 325 - title: "Merge PR", 326 - subtitle: "Merge Ready - \(successfulChecksLabel)", 327 - kind: .mergePullRequest(worktreeID), 328 - priorityTier: 0 329 - ) 330 - } 331 - 332 317 var items: [CommandPaletteItem] = [ 333 318 CommandPaletteItem( 334 319 id: CommandPaletteItemID.pullRequestOpen(repositoryID), ··· 345 330 346 331 items.append(contentsOf: makeFailingItems()) 347 332 348 - if let mergeItem = makeMergeItem() { 333 + if let mergeItem = makeMergePullRequestItem( 334 + canMerge: canMerge, 335 + breakdown: breakdown, 336 + repositoryID: repositoryID, 337 + worktreeID: worktreeID 338 + ) { 349 339 items.append(mergeItem) 350 340 } 351 341 342 + if let closeItem = makeClosePullRequestItem( 343 + isOpen: isOpen, 344 + repositoryID: repositoryID, 345 + worktreeID: worktreeID, 346 + pullRequestTitle: pullRequest.title 347 + ) { 348 + items.append(closeItem) 349 + } 350 + 352 351 return items 353 352 } 354 353 354 + private func makeMergePullRequestItem( 355 + canMerge: Bool, 356 + breakdown: PullRequestCheckBreakdown, 357 + repositoryID: Repository.ID, 358 + worktreeID: Worktree.ID 359 + ) -> CommandPaletteItem? { 360 + guard canMerge else { return nil } 361 + let successfulChecks = breakdown.passed 362 + let successfulChecksLabel = 363 + successfulChecks == 1 364 + ? "1 successful check" 365 + : "\(successfulChecks) successful checks" 366 + return CommandPaletteItem( 367 + id: CommandPaletteItemID.pullRequestMerge(repositoryID), 368 + title: "Merge PR", 369 + subtitle: "Merge Ready - \(successfulChecksLabel)", 370 + kind: .mergePullRequest(worktreeID), 371 + priorityTier: 0 372 + ) 373 + } 374 + 375 + private func makeClosePullRequestItem( 376 + isOpen: Bool, 377 + repositoryID: Repository.ID, 378 + worktreeID: Worktree.ID, 379 + pullRequestTitle: String 380 + ) -> CommandPaletteItem? { 381 + guard isOpen else { return nil } 382 + return CommandPaletteItem( 383 + id: CommandPaletteItemID.pullRequestClose(repositoryID), 384 + title: "Close PR", 385 + subtitle: pullRequestTitle, 386 + kind: .closePullRequest(worktreeID), 387 + priorityTier: 1 388 + ) 389 + } 390 + 355 391 #if DEBUG 356 392 private func debugToastItems() -> [CommandPaletteItem] { 357 393 [ ··· 401 437 pullRequestRerunFailedJobs(repositoryID), 402 438 pullRequestOpenFailingCheck(repositoryID), 403 439 pullRequestMerge(repositoryID), 440 + pullRequestClose(repositoryID), 404 441 ] 405 442 } 406 443 ··· 431 468 static func pullRequestMerge(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { 432 469 "pr.\(repositoryID).merge" 433 470 } 471 + 472 + static func pullRequestClose(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { 473 + "pr.\(repositoryID).close" 474 + } 434 475 } 435 476 436 477 private func prioritizeItems( ··· 486 527 case .openPullRequest, 487 528 .markPullRequestReady, 488 529 .mergePullRequest, 530 + .closePullRequest, 489 531 .copyFailingJobURL, 490 532 .copyCiFailureLogs, 491 533 .rerunFailedJobs, ··· 508 550 return .markPullRequestReady(worktreeID) 509 551 case .mergePullRequest(let worktreeID): 510 552 return .mergePullRequest(worktreeID) 553 + case .closePullRequest(let worktreeID): 554 + return .closePullRequest(worktreeID) 511 555 case .copyFailingJobURL(let worktreeID): 512 556 return .copyFailingJobURL(worktreeID) 513 557 case .copyCiFailureLogs(let worktreeID):
+6 -2
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 341 341 private var badge: String? { 342 342 switch row.kind { 343 343 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 344 - .openPullRequest, .markPullRequestReady, .mergePullRequest, .copyFailingJobURL, 344 + .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 345 345 .copyCiFailureLogs, 346 346 .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect: 347 347 return nil ··· 374 374 return "checkmark.seal" 375 375 case .mergePullRequest: 376 376 return "arrow.merge" 377 + case .closePullRequest: 378 + return "xmark.circle" 377 379 case .copyFailingJobURL: 378 380 return "link" 379 381 case .copyCiFailureLogs: ··· 398 400 private var emphasis: Bool { 399 401 switch row.kind { 400 402 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 401 - .openPullRequest, .markPullRequestReady, .mergePullRequest, .copyFailingJobURL, 403 + .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 402 404 .copyCiFailureLogs, 403 405 .rerunFailedJobs, .openFailingCheckDetails: 404 406 return true ··· 500 502 base = "Mark pull request ready for review" 501 503 case .mergePullRequest: 502 504 base = "Merge pull request" 505 + case .closePullRequest: 506 + base = "Close pull request" 503 507 case .copyFailingJobURL: 504 508 base = "Copy failing job URL" 505 509 case .copyCiFailureLogs:
+31
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 264 264 case openOnGithub 265 265 case markReadyForReview 266 266 case merge 267 + case close 267 268 case copyFailingJobURL 268 269 case copyCiFailureLogs 269 270 case rerunFailedJobs ··· 2160 2161 await send( 2161 2162 .presentAlert( 2162 2163 title: "Failed to merge pull request", 2164 + message: error.localizedDescription 2165 + ) 2166 + ) 2167 + } 2168 + } 2169 + 2170 + case .close: 2171 + let githubCLI = githubCLI 2172 + let githubIntegration = githubIntegration 2173 + return .run { send in 2174 + guard await githubIntegration.isAvailable() else { 2175 + await send( 2176 + .presentAlert( 2177 + title: "GitHub integration unavailable", 2178 + message: "Enable GitHub integration to close a pull request." 2179 + ) 2180 + ) 2181 + return 2182 + } 2183 + await send(.showToast(.inProgress("Closing pull request…"))) 2184 + do { 2185 + try await githubCLI.closePullRequest(worktreeRoot, pullRequest.number) 2186 + await send(.showToast(.success("Pull request closed"))) 2187 + await send(.worktreeInfoEvent(pullRequestRefresh)) 2188 + await send(.delayedPullRequestRefresh(worktreeID)) 2189 + } catch { 2190 + await send(.dismissToast) 2191 + await send( 2192 + .presentAlert( 2193 + title: "Failed to close pull request", 2163 2194 message: error.localizedDescription 2164 2195 ) 2165 2196 )
+10
supacodeTests/AppFeatureCommandPaletteTests.swift
··· 79 79 await store.receive(\.updates.checkForUpdates) 80 80 } 81 81 82 + @Test(.dependencies) func closePullRequestDispatchesAction() async { 83 + let store = TestStore(initialState: AppFeature.State()) { 84 + AppFeature() 85 + } 86 + store.exhaustivity = .off 87 + 88 + await store.send(.commandPalette(.delegate(.closePullRequest("/tmp/repo/wt-close")))) 89 + await store.receive(\.repositories.pullRequestAction) 90 + } 91 + 82 92 @Test(.dependencies) func removeWorktreeDispatchesRequest() async { 83 93 let worktree = makeWorktree( 84 94 id: "/tmp/repo-run/wt-1",
+39
supacodeTests/CommandPaletteFeatureTests.swift
··· 457 457 #expect(ordered.first?.title == "Merge PR") 458 458 } 459 459 460 + @Test func commandPaletteShowsCloseActionForOpenPullRequest() { 461 + let rootPath = "/tmp/repo" 462 + let worktree = makeWorktree(id: "\(rootPath)/wt-close", name: "close", repoRoot: rootPath) 463 + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) 464 + var state = RepositoriesFeature.State(repositories: [repository]) 465 + state.selection = .worktree(worktree.id) 466 + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 467 + addedLines: nil, 468 + removedLines: nil, 469 + pullRequest: makePullRequest(state: "OPEN") 470 + ) 471 + 472 + let items = CommandPaletteFeature.commandPaletteItems(from: state) 473 + let closeItem = items.first(where: { $0.title == "Close PR" }) 474 + #expect(closeItem != nil) 475 + #expect(closeItem?.subtitle == "PR") 476 + if case .some(.closePullRequest(let closeWorktreeID)) = closeItem?.kind { 477 + #expect(closeWorktreeID == worktree.id) 478 + } else { 479 + Issue.record("Expected close pull request command palette action") 480 + } 481 + } 482 + 483 + @Test func commandPaletteDoesNotShowCloseActionForMergedPullRequest() { 484 + let rootPath = "/tmp/repo" 485 + let worktree = makeWorktree(id: "\(rootPath)/wt-merged", name: "merged", repoRoot: rootPath) 486 + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) 487 + var state = RepositoriesFeature.State(repositories: [repository]) 488 + state.selection = .worktree(worktree.id) 489 + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 490 + addedLines: nil, 491 + removedLines: nil, 492 + pullRequest: makePullRequest(state: "MERGED") 493 + ) 494 + 495 + let items = CommandPaletteFeature.commandPaletteItems(from: state) 496 + #expect(!items.contains(where: { $0.title == "Close PR" })) 497 + } 498 + 460 499 @Test func commandPaletteDoesNotShowMergeActionWhenBlocked() { 461 500 let rootPath = "/tmp/repo" 462 501 let worktree = makeWorktree(id: "\(rootPath)/wt-blocked", name: "blocked", repoRoot: rootPath)
+40
supacodeTests/RepositoriesFeatureTests.swift
··· 1425 1425 await store.finish() 1426 1426 } 1427 1427 1428 + @Test func pullRequestActionCloseRefreshesImmediately() async { 1429 + let repoRoot = "/tmp/repo" 1430 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1431 + let featureWorktree = makeWorktree( 1432 + id: "\(repoRoot)/feature", 1433 + name: "feature", 1434 + repoRoot: repoRoot 1435 + ) 1436 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1437 + let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) 1438 + var state = makeState(repositories: [repository]) 1439 + state.githubIntegrationAvailability = .disabled 1440 + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( 1441 + addedLines: nil, 1442 + removedLines: nil, 1443 + pullRequest: openPullRequest 1444 + ) 1445 + let closedNumbers = LockIsolated<[Int]>([]) 1446 + let store = TestStore(initialState: state) { 1447 + RepositoriesFeature() 1448 + } withDependencies: { 1449 + $0.githubIntegration.isAvailable = { true } 1450 + $0.githubCLI.closePullRequest = { _, number in 1451 + closedNumbers.withValue { $0.append(number) } 1452 + } 1453 + } 1454 + store.exhaustivity = .off 1455 + 1456 + await store.send(.pullRequestAction(featureWorktree.id, .close)) 1457 + await store.receive(\.showToast) { 1458 + $0.statusToast = .inProgress("Closing pull request…") 1459 + } 1460 + await store.receive(\.showToast) { 1461 + $0.statusToast = .success("Pull request closed") 1462 + } 1463 + await store.receive(\.worktreeInfoEvent) 1464 + #expect(closedNumbers.value == [12]) 1465 + await store.finish() 1466 + } 1467 + 1428 1468 @Test func worktreeInfoEventRepositoryPullRequestRefreshMarksInFlightThenCompletes() async { 1429 1469 let repoRoot = "/tmp/repo" 1430 1470 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot)