From d7f4979588397c6b1ac9fd322e9eda9d97f149af Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 5 Mar 2026 16:34:36 -0500 Subject: [PATCH 01/54] Add Linear OAuth sync integration and macOS UX --- .../DashboardFeature/CommandPalette.swift | 88 +- .../DashboardFeature/DashboardModel.swift | 145 + .../DashboardFeature/ProjectDetailView.swift | 634 ++- .../SettingsOverlayView.swift | 798 ++++ .../DashboardFeature/TicketsView.swift | 920 +++- .../HackCLIService/HackCLIClient.swift | 344 ++ .../Sources/HackDesktopModels/Models.swift | 480 +++ .../LinearSyncUXModelTests.swift | 102 + bun.lock | 13 +- docs/cli.md | 8 + docs/env.md | 2 + docs/extensions.md | 58 +- .../guides/linear-integration-architecture.md | 71 + .../plans/2026-03-05-linear-sync-ux-design.md | 45 + docs/plans/2026-03-05-linear-sync-ux-plan.md | 95 + scripts/install-cli.ts | 9 +- services/auth-broker/.env.example | 20 + services/auth-broker/README.md | 23 +- services/auth-broker/package.json | 1 + services/auth-broker/src/app.ts | 25 + services/auth-broker/src/better-auth-link.ts | 6 +- services/auth-broker/src/config.ts | 290 +- services/auth-broker/src/flow-store.ts | 94 +- services/auth-broker/src/linear.ts | 262 ++ .../src/modules/github-oauth/service.ts | 13 +- .../src/modules/linear-agent/plugin.ts | 288 ++ .../linear-oauth/callback-routes-plugin.ts | 29 + .../linear-oauth/flow-status-routes-plugin.ts | 42 + .../src/modules/linear-oauth/model.ts | 31 + .../src/modules/linear-oauth/plugin.ts | 44 + .../linear-oauth/refresh-routes-plugin.ts | 46 + .../src/modules/linear-oauth/service.ts | 353 ++ .../linear-oauth/start-routes-plugin.ts | 64 + .../src/modules/providers/plugin.ts | 15 + services/auth-broker/src/types.ts | 27 +- services/auth-broker/tests/config.test.ts | 74 + services/auth-broker/tests/flow-store.test.ts | 13 + services/auth-broker/tests/index.test.ts | 387 ++ .../tests/package-manifest.test.ts | 19 + src/cli/run.ts | 6 +- src/cli/spec.ts | 2 + src/commands/env.ts | 60 +- src/commands/linear.ts | 183 + src/control-plane/extensions/builtins.ts | 2 + src/control-plane/extensions/linear/auth.ts | 925 ++++ src/control-plane/extensions/linear/client.ts | 925 ++++ .../extensions/linear/commands.ts | 3834 +++++++++++++++++ .../extensions/linear/extension.ts | 13 + .../extensions/tickets/commands.ts | 324 +- src/control-plane/extensions/tickets/store.ts | 321 ++ src/control-plane/sdk/config.ts | 2 + src/lib/config.ts | 44 +- src/lib/nodes-registry.ts | 9 +- src/lib/secret-store.ts | 216 +- src/mcp/server.ts | 409 ++ tests/env-backend-command.test.ts | 44 + tests/github-auth.test.ts | 1 + tests/linear-auth.test.ts | 158 + tests/linear-command-alias.test.ts | 33 + tests/linear-commands.test.ts | 222 + tests/mcp.test.ts | 34 + tests/nodes-registry.test.ts | 7 +- tests/secret-store.test.ts | 36 + 63 files changed, 13645 insertions(+), 143 deletions(-) create mode 100644 apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift create mode 100644 docs/guides/linear-integration-architecture.md create mode 100644 docs/plans/2026-03-05-linear-sync-ux-design.md create mode 100644 docs/plans/2026-03-05-linear-sync-ux-plan.md create mode 100644 services/auth-broker/src/linear.ts create mode 100644 services/auth-broker/src/modules/linear-agent/plugin.ts create mode 100644 services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts create mode 100644 services/auth-broker/src/modules/linear-oauth/flow-status-routes-plugin.ts create mode 100644 services/auth-broker/src/modules/linear-oauth/model.ts create mode 100644 services/auth-broker/src/modules/linear-oauth/plugin.ts create mode 100644 services/auth-broker/src/modules/linear-oauth/refresh-routes-plugin.ts create mode 100644 services/auth-broker/src/modules/linear-oauth/service.ts create mode 100644 services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts create mode 100644 services/auth-broker/tests/package-manifest.test.ts create mode 100644 src/commands/linear.ts create mode 100644 src/control-plane/extensions/linear/auth.ts create mode 100644 src/control-plane/extensions/linear/client.ts create mode 100644 src/control-plane/extensions/linear/commands.ts create mode 100644 src/control-plane/extensions/linear/extension.ts create mode 100644 tests/linear-auth.test.ts create mode 100644 tests/linear-command-alias.test.ts create mode 100644 tests/linear-commands.test.ts diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift index 2b8c15163..f12d4472c 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift @@ -6,6 +6,13 @@ import HackDesktopModels extension Notification.Name { public static let hackCommandPaletteRequested = Notification.Name("hack.commandPalette.requested") public static let hackRefreshRequested = Notification.Name("hack.refresh.requested") + public static let hackProjectNavigationRequested = Notification.Name("hack.projectNavigation.requested") +} + +enum ProjectNavigationRequest { + static let projectIdKey = "projectId" + static let tabKey = "tab" + static let sidebarKey = "sidebar" } struct CommandPaletteAction: Identifiable { @@ -74,6 +81,16 @@ struct CommandPaletteView: View { } ) + actions.append( + CommandPaletteAction(title: "Go to Linear Settings", subtitle: "Connected accounts + sync policy") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.linear.rawValue] + ) + } + ) + actions.append( CommandPaletteAction(title: "Go to Runtime Settings", subtitle: "System status + daemon") { NotificationCenter.default.post( @@ -134,6 +151,24 @@ struct CommandPaletteView: View { model.selectedItem = .project(project.id) } ) + if project.supportsTickets { + actions.append( + CommandPaletteAction( + title: "Open Tickets: \(project.name)", + subtitle: "Ticket board + manual sync tools" + ) { + openProjectTickets(project) + } + ) + actions.append( + CommandPaletteAction( + title: "Open Linear routing: \(project.name)", + subtitle: "Project account + bound Linear project" + ) { + openProjectRouting(project) + } + ) + } } if let project = model.selectedProject { @@ -149,7 +184,12 @@ struct CommandPaletteView: View { } actions.append( CommandPaletteAction(title: "Project: Overview", subtitle: project.name) { - model.selectedProjectTab = .overview + openProjectOverview(project) + } + ) + actions.append( + CommandPaletteAction(title: "Project: Linear routing", subtitle: project.name) { + openProjectRouting(project) } ) if project.isRuntimeConfigured { @@ -181,7 +221,7 @@ struct CommandPaletteView: View { if project.supportsTickets { actions.append( CommandPaletteAction(title: "Project: Tickets", subtitle: project.name) { - model.selectedProjectTab = .tickets + openProjectTickets(project) } ) } @@ -216,6 +256,27 @@ struct CommandPaletteView: View { Task { _ = await model.syncTickets(for: project) } } ) + actions.append( + CommandPaletteAction(title: "Tickets: Pull from Linear", subtitle: project.name) { + Task { + _ = await model.syncLinearProject( + for: project, + from: "linear" + ) + } + } + ) + actions.append( + CommandPaletteAction(title: "Tickets: Push Hack to Linear", subtitle: project.name) { + Task { + _ = await model.syncLinearProject( + for: project, + from: "hack", + ownerMode: "hack" + ) + } + } + ) actions.append( CommandPaletteAction(title: "Tickets: Setup", subtitle: project.name) { Task { _ = await model.setupTickets(for: project) } @@ -244,6 +305,29 @@ struct CommandPaletteView: View { return nil } + private func openProjectOverview(_ project: ProjectSummary) { + model.selectedItem = .project(project.id) + model.selectedProjectTab = .overview + } + + private func openProjectTickets(_ project: ProjectSummary) { + model.selectedItem = .project(project.id) + model.selectedProjectTab = .tickets + } + + private func openProjectRouting(_ project: ProjectSummary) { + openProjectOverview(project) + NotificationCenter.default.post( + name: .hackProjectNavigationRequested, + object: nil, + userInfo: [ + ProjectNavigationRequest.projectIdKey: project.id, + ProjectNavigationRequest.tabKey: ProjectTab.overview.rawValue, + ProjectNavigationRequest.sidebarKey: "remoteExecution", + ] + ) + } + private func openPreferredCodingAgent(for project: ProjectSummary, path: String) { let command = CodingAgentIntegration.launchCommand( projectPath: path, diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 4ecdf5a10..059afb571 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -627,6 +627,151 @@ public final class DashboardModel { } } + public func inspectLinearProfiles() async -> LinearProfilesResponse? { + do { + return try await client.inspectLinearProfiles() + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + public func inspectLinearStatus(profileId: String? = nil) async -> LinearStatusResponse? { + do { + return try await client.inspectLinearStatus(profileId: profileId) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + public func disconnectLinear(profileId: String) async -> Bool { + do { + try await client.disconnectLinear(profileId: profileId) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + public func listLinearProjects(profileId: String? = nil) async -> LinearProjectsResponse? { + do { + return try await client.listLinearProjects(profileId: profileId) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + public func bindLinearProject( + for project: ProjectSummary, + profileId: String?, + projectId: String?, + projectName: String?, + teamId: String?, + clear: Bool + ) async -> LinearProjectBindingResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + return await runActionResult(message: clear ? "Clearing Linear project binding…" : "Saving Linear project binding…") { + try await self.client.bindLinearProject( + path: path, + profileId: profileId, + projectId: projectId, + projectName: projectName, + teamId: teamId, + clear: clear + ) + } + } + + public func syncLinearProject( + for project: ProjectSummary, + from direction: String, + ownerMode: String? = nil, + projectId: String? = nil, + teamId: String? = nil, + limit: Int? = nil, + syncLabels: Bool? = nil + ) async -> LinearProjectSyncResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + let message = direction == "linear" + ? "Syncing Linear issues into tickets…" + : "Syncing tickets into Linear…" + return await runActionResult(message: message) { + try await self.client.syncLinearProject( + path: path, + from: direction, + ownerMode: ownerMode, + projectId: projectId, + teamId: teamId, + limit: limit, + syncLabels: syncLabels + ) + } + } + + public func syncLinearIssue( + for project: ProjectSummary, + from direction: String, + issueIdentifier: String? = nil, + ticketId: String? = nil, + projectId: String? = nil, + teamId: String? = nil, + syncLabels: Bool? = nil + ) async -> LinearIssueSyncResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + let message = direction == "linear" + ? "Refreshing ticket from Linear…" + : "Syncing ticket to Linear…" + return await runActionResult(message: message) { + try await self.client.syncLinearIssue( + path: path, + from: direction, + issueIdentifier: issueIdentifier, + ticketId: ticketId, + projectId: projectId, + teamId: teamId, + syncLabels: syncLabels + ) + } + } + + public func startLinearOAuthFlow( + profileId: String, + setDefault: Bool + ) async -> LinearOAuthFlowStartResponse? { + do { + return try await client.startLinearOAuthFlow( + profileId: profileId, + setDefault: setDefault + ) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + public func fetchLinearOAuthFlowStatus( + statusURL: String + ) async -> LinearOAuthFlowStatusResponse? { + do { + return try await client.fetchLinearOAuthFlowStatus(statusURL: statusURL) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + public func bootstrapRailwayNode( request: RailwayBootstrapRequest ) async -> RailwayBootstrapResponse? { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 0adbbf8df..1f535c3c6 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -54,7 +54,7 @@ private enum ProjectSidebarItem: String, CaseIterable, Identifiable { var title: String { switch self { case .remoteExecution: - return "Remote execution" + return "Project routing" case .services: return "Services" case .lifecycle: @@ -86,6 +86,49 @@ private enum ProjectSidebarItem: String, CaseIterable, Identifiable { } } +private enum ProjectLinearSyncAction: String, Identifiable { + case pullFromLinear + case pushHackToLinear + + var id: String { rawValue } + + var title: String { + switch self { + case .pullFromLinear: + return "Pull from Linear?" + case .pushHackToLinear: + return "Push Hack tickets?" + } + } + + var buttonLabel: String { + switch self { + case .pullFromLinear: + return "Pull Linear" + case .pushHackToLinear: + return "Push Hack" + } + } + + var message: String { + switch self { + case .pullFromLinear: + return "Linear-origin tickets stay authoritative for title, body, status, and project binding. Comments remain append-only. Assignees, labels, and dependencies merge best-effort when mappings are clear." + case .pushHackToLinear: + return "Hack-origin tickets stay authoritative for title, body, status, and project binding. Comments remain append-only. Assignees, labels, and dependencies merge best-effort when mappings are clear." + } + } + + var direction: String { + switch self { + case .pullFromLinear: + return "linear" + case .pushHackToLinear: + return "hack" + } + } +} + struct ProjectDetailView: View { @Environment(DashboardModel.self) private var model @Environment(\.openURL) private var openURL @@ -122,6 +165,20 @@ struct ProjectDetailView: View { @State private var githubResolvedProfile = "" @State private var githubResolvedStatus: GitHubStatusResponse? = nil @State private var githubProfileMessage = "" + @State private var linearProjectProfile = "" + @State private var linearDefaultProfile = "" + @State private var linearProfileOptions: [String] = [] + @State private var linearProfilesById: [String: LinearProfileSummary] = [:] + @State private var linearProfileStatusById: [String: LinearStatusResponse] = [:] + @State private var linearResolvedProfile = "" + @State private var linearResolvedStatus: LinearStatusResponse? = nil + @State private var linearBoundProjectId = "" + @State private var linearBoundProjectName = "" + @State private var linearBoundTeamId = "" + @State private var linearProjectOptions: [LinearProjectSummary] = [] + @State private var selectedLinearProjectId = "" + @State private var linearProjectMessage = "" + @State private var pendingLinearSyncAction: ProjectLinearSyncAction? = nil @State private var projectSystemGitIdentity: GitSystemIdentity? = nil @State private var executionTargetReloadTask: Task? = nil @@ -160,6 +217,24 @@ struct ProjectDetailView: View { .onChange(of: project.kind) { _, _ in ensureSidebarSelection() } + .onReceive(NotificationCenter.default.publisher(for: .hackProjectNavigationRequested)) { notification in + guard + let userInfo = notification.userInfo, + let requestedProjectId = userInfo[ProjectNavigationRequest.projectIdKey] as? String, + requestedProjectId == project.id + else { + return + } + if let requestedTab = userInfo[ProjectNavigationRequest.tabKey] as? String, + let tab = ProjectTab(rawValue: requestedTab) { + model.selectedProjectTab = tab + } + if let requestedSidebar = userInfo[ProjectNavigationRequest.sidebarKey] as? String, + let item = ProjectSidebarItem(rawValue: requestedSidebar) { + selectedSidebarItem = item + } + ensureSidebarSelection() + } .onDisappear { executionTargetReloadTask?.cancel() executionTargetReloadTask = nil @@ -167,6 +242,16 @@ struct ProjectDetailView: View { .sheet(isPresented: $showAddBranchSheet) { addBranchSheet } + .alert(item: $pendingLinearSyncAction) { action in + Alert( + title: Text(action.title), + message: Text(action.message), + primaryButton: .default(Text(action.buttonLabel)) { + Task { await syncBoundLinearProject(from: action.direction) } + }, + secondaryButton: .cancel() + ) + } } private var projectPageHeader: some View { @@ -644,7 +729,7 @@ struct ProjectDetailView: View { HStack(spacing: 10) { Image(systemName: "point.3.connected.trianglepath.dotted") .foregroundStyle(.secondary) - Text("Remote execution") + Text("Project routing") .font(.mono(.headline, weight: .semibold)) Spacer() if executionTargetLoading || executionTargetSaving { @@ -658,15 +743,32 @@ struct ProjectDetailView: View { userInfo: ["pane": "github"] ) } label: { - Label("Manage accounts", systemImage: "gearshape") + Label("GitHub", systemImage: "person.crop.circle.badge.checkmark") + } + .buttonStyle(PressableIconButtonStyle()) + Button { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + } label: { + Label("Linear", systemImage: "point.3.connected.trianglepath.dotted") } .buttonStyle(PressableIconButtonStyle()) } - Text("Select the default node and Git credentials for remote runs. Local uses this machine's inherited Git setup.") + Text("Choose runtime placement, remote Git identity, and the default Linear account/project used for ticket sync in this repo.") .font(.mono(.caption)) .foregroundStyle(.secondary) + InlineCallout( + tone: linearRoutingContractTone, + title: "Linear sync contract", + message: linearRoutingContractMessage, + actions: linearRoutingContractActions + ) + VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 12) { Text("Execution mode") @@ -745,6 +847,98 @@ struct ProjectDetailView: View { .font(.mono(.caption2)) .foregroundStyle(.tertiary) } + + HStack(alignment: .center, spacing: 12) { + Text("Linear acct") + .font(.mono(.subheadline, weight: .semibold)) + .frame(width: 120, alignment: .leading) + + Picker("Linear account", selection: $linearProjectProfile) { + Text("Inherited").tag("") + ForEach(linearProfileOptions, id: \.self) { profile in + Text(linearProfileLabel(profileId: profile)).tag(profile) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 360, alignment: .leading) + .onChange(of: linearProjectProfile) { _, _ in + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } + } + + Spacer() + Text(selectedLinearProfileSummary) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + HStack(alignment: .center, spacing: 12) { + Text("Linear proj") + .font(.mono(.subheadline, weight: .semibold)) + .frame(width: 120, alignment: .leading) + + Picker("Linear project", selection: $selectedLinearProjectId) { + Text("Unbound").tag("") + ForEach(linearProjectOptions) { linearProject in + Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 360, alignment: .leading) + .disabled(linearProjectOptions.isEmpty) + .onChange(of: selectedLinearProjectId) { _, newValue in + guard !executionTargetLoading else { + return + } + guard newValue != linearBoundProjectId else { + return + } + Task { await persistLinearProjectBindingSelection() } + } + + if !linearBoundProjectId.isEmpty { + Button("Clear") { + Task { await clearLinearProjectBinding() } + } + .adaptiveToolbarButton() + } + + Spacer() + Text(selectedLinearProjectSummary) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + HStack(alignment: .center, spacing: 12) { + Text("Ticket sync") + .font(.mono(.subheadline, weight: .semibold)) + .frame(width: 120, alignment: .leading) + + HStack(spacing: 8) { + Button { + pendingLinearSyncAction = .pullFromLinear + } label: { + Label("Pull Linear", systemImage: "arrow.down.left") + } + .adaptiveToolbarButtonProminent() + .disabled(!canSyncFromLinear) + + Button { + pendingLinearSyncAction = .pushHackToLinear + } label: { + Label("Push Hack", systemImage: "arrow.up.right") + } + .adaptiveToolbarButton() + .disabled(!canSyncToLinear) + } + + Spacer() + Text(ticketSyncSummary) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } } if let projectSystemGitIdentity { @@ -754,10 +948,20 @@ struct ProjectDetailView: View { .foregroundStyle(.secondary) } - if !remoteConfigMessage.isEmpty { - Text(remoteConfigMessage) + if let accountName = linearResolvedAccountName, !accountName.isEmpty { + let teamSuffix = linearBoundTeamId.isEmpty ? "" : " • team \(linearBoundTeamId)" + Text("Linear route: \(accountName)\(linearBoundProjectName.isEmpty ? "" : " • \(linearBoundProjectName)")\(teamSuffix)") .font(.mono(.caption2)) - .foregroundStyle(remoteConfigMessage.hasPrefix("Failed") ? Color.orange : Color.green) + .foregroundStyle(.secondary) + } + + if !remoteConfigMessage.isEmpty { + InlineCallout( + tone: remoteConfigTone, + title: remoteConfigTone == .warn ? "Routing issue" : "Routing updated", + message: remoteConfigMessage, + actions: remoteConfigActions + ) } } } @@ -1260,6 +1464,69 @@ struct ProjectDetailView: View { return githubProfileLabel(profileId: selectedProfileId) } + private var selectedLinearProfileSummary: String { + let selectedProfileId = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) + guard !selectedProfileId.isEmpty else { + if linearDefaultProfile.isEmpty { + return "Inherited" + } + return linearProfileLabel(profileId: linearDefaultProfile) + } + return linearProfileLabel(profileId: selectedProfileId) + } + + private var selectedLinearProjectSummary: String { + let selectedProjectId = linearBoundProjectId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !selectedProjectId.isEmpty else { + return "Unbound" + } + if let linearProject = linearProjectOptions.first(where: { $0.id == selectedProjectId }) { + return linearProjectMenuLabel(linearProject) + } + if !linearBoundProjectName.isEmpty { + return linearBoundProjectName + } + return selectedProjectId + } + + private var linearResolvedAccountName: String? { + let trimmed = linearResolvedProfile.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return linearAccountLabel(profileId: trimmed) + } + + private var canSyncFromLinear: Bool { + !resolvedLinearProjectId.isEmpty && linearResolvedStatus?.tokenResolved == true + } + + private var canSyncToLinear: Bool { + linearResolvedStatus?.tokenResolved == true + } + + private var resolvedLinearProjectId: String { + let selected = selectedLinearProjectId.trimmingCharacters(in: .whitespacesAndNewlines) + if !selected.isEmpty { + return selected + } + return linearBoundProjectId.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var ticketSyncSummary: String { + if linearResolvedStatus?.tokenResolved != true { + return "Connect Linear first" + } + let projectId = resolvedLinearProjectId + if projectId.isEmpty { + return "Pick a Linear project" + } + if linearBoundProjectName.isEmpty { + return "\(projectId) • origin decides authority" + } + return "\(linearBoundProjectName) • origin decides authority" + } + private var remoteConfigMessage: String { if !executionTargetMessage.isEmpty { return executionTargetMessage @@ -1267,9 +1534,98 @@ struct ProjectDetailView: View { if !githubProfileMessage.isEmpty { return githubProfileMessage } + if !linearProjectMessage.isEmpty { + return linearProjectMessage + } return "" } + private var remoteConfigTone: StatusTone { + if remoteConfigMessage.hasPrefix("Failed") { + return .warn + } + return .good + } + + private var remoteConfigActions: [InlineCalloutAction] { + if remoteConfigTone == .warn { + return [ + InlineCalloutAction( + label: "Linear settings", + systemImage: "point.3.connected.trianglepath.dotted" + ) { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + } + ] + } + return [ + InlineCalloutAction(label: "Open tickets", systemImage: "ticket") { + NotificationCenter.default.post( + name: .hackProjectNavigationRequested, + object: nil, + userInfo: [ + ProjectNavigationRequest.projectIdKey: project.id, + ProjectNavigationRequest.tabKey: ProjectTab.tickets.rawValue, + ProjectNavigationRequest.sidebarKey: ProjectSidebarItem.tickets.rawValue, + ] + ) + } + ] + } + + private var linearRoutingContractTone: StatusTone { + if linearResolvedStatus?.tokenResolved != true || resolvedLinearProjectId.isEmpty { + return .warn + } + return .neutral + } + + private var linearRoutingContractMessage: String { + let routeSummary: String + if let accountName = linearResolvedAccountName, !accountName.isEmpty { + if linearBoundProjectName.isEmpty { + routeSummary = "Current route uses \(accountName)." + } else { + routeSummary = "Current route uses \(accountName) and \(linearBoundProjectName)." + } + } else { + routeSummary = "Linear is not fully routed for this project yet." + } + return "\(routeSummary) Origin decides authority for title, body, status, and project binding. Comments copy append-only. Assignees, labels, and dependencies merge best-effort and should be reviewed when mappings are ambiguous." + } + + private var linearRoutingContractActions: [InlineCalloutAction] { + var actions: [InlineCalloutAction] = [ + InlineCalloutAction(label: "Linear settings", systemImage: "point.3.connected.trianglepath.dotted") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + } + ] + if project.supportsTickets { + actions.append( + InlineCalloutAction(label: "Open tickets", systemImage: "ticket") { + NotificationCenter.default.post( + name: .hackProjectNavigationRequested, + object: nil, + userInfo: [ + ProjectNavigationRequest.projectIdKey: project.id, + ProjectNavigationRequest.tabKey: ProjectTab.tickets.rawValue, + ProjectNavigationRequest.sidebarKey: ProjectSidebarItem.tickets.rawValue, + ] + ) + } + ) + } + return actions + } + private func githubAccountLogin(profileId: String) -> String? { let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -1317,6 +1673,67 @@ struct ProjectDetailView: View { return "@\(account) (\(trimmed))" } + private func linearAccountName(profileId: String) -> String? { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + let summaryName = linearProfilesById[trimmed]?.accountName? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let summaryName, !summaryName.isEmpty { + return summaryName + } + let statusName = linearProfileStatusById[trimmed]?.accountName? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let statusName, !statusName.isEmpty { + return statusName + } + return nil + } + + private func linearAccountEmail(profileId: String) -> String? { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + let summaryEmail = linearProfilesById[trimmed]?.accountEmail? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let summaryEmail, !summaryEmail.isEmpty { + return summaryEmail + } + let statusEmail = linearProfileStatusById[trimmed]?.accountEmail? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let statusEmail, !statusEmail.isEmpty { + return statusEmail + } + return nil + } + + private func linearAccountLabel(profileId: String) -> String? { + linearAccountName(profileId: profileId) ?? linearAccountEmail(profileId: profileId) + } + + private func linearProfileLabel(profileId: String) -> String { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "Inherited" + } + guard let label = linearAccountLabel(profileId: trimmed) else { + return trimmed + } + return "\(label) (\(trimmed))" + } + + private func linearProjectMenuLabel(_ project: LinearProjectSummary) -> String { + if let teamKey = project.teamKey, !teamKey.isEmpty { + return "\(project.name) (\(teamKey))" + } + if let teamName = project.teamName, !teamName.isEmpty { + return "\(project.name) (\(teamName))" + } + return project.name + } + /** Reload project-level remote execution defaults and Git credential routing. */ @@ -1345,10 +1762,30 @@ struct ProjectDetailView: View { for: project, key: "controlPlane.routing.overrides.github.profile" ) + async let projectLinearProfile = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.profile" + ) + async let projectLinearProjectId = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.projectId" + ) + async let projectLinearProjectName = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.projectName" + ) + async let projectLinearTeamId = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.teamId" + ) async let defaultGitHubProfile = model.getGlobalConfig( key: "controlPlane.extensions[\"dance.hack.github\"].config.defaultProfile" ) + async let defaultLinearProfile = model.getGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.linear\"].config.defaultProfile" + ) async let githubProfiles = model.inspectGitHubProfiles() + async let linearProfiles = model.inspectLinearProfiles() async let systemGitIdentity = model.inspectSystemGitIdentity( projectPath: identityProjectPath ) @@ -1358,8 +1795,14 @@ struct ProjectDetailView: View { let resolvedExecutionNodeId = await executionNodeId let resolvedProjectNodeId = await projectNodeId let resolvedProjectGitHubProfile = await projectGitHubProfile + let resolvedProjectLinearProfile = await projectLinearProfile + let resolvedProjectLinearProjectId = await projectLinearProjectId + let resolvedProjectLinearProjectName = await projectLinearProjectName + let resolvedProjectLinearTeamId = await projectLinearTeamId let resolvedDefaultGitHubProfile = await defaultGitHubProfile + let resolvedDefaultLinearProfile = await defaultLinearProfile let resolvedGitHubProfiles = await githubProfiles + let resolvedLinearProfiles = await linearProfiles let resolvedSystemGitIdentity = await systemGitIdentity if Task.isCancelled { @@ -1406,10 +1849,58 @@ struct ProjectDetailView: View { profileId: resolvedGitHubProfile.isEmpty ? nil : resolvedGitHubProfile ) } + + linearProjectProfile = (resolvedProjectLinearProfile ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + linearBoundProjectId = (resolvedProjectLinearProjectId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + linearBoundProjectName = (resolvedProjectLinearProjectName ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + linearBoundTeamId = (resolvedProjectLinearTeamId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackDefaultLinearProfile = (resolvedDefaultLinearProfile ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + linearDefaultProfile = resolvedLinearProfiles?.defaultProfile ?? fallbackDefaultLinearProfile + linearProfilesById = mapLinearProfilesById(response: resolvedLinearProfiles) + linearProfileOptions = (resolvedLinearProfiles?.profiles ?? []) + .map(\.id) + .sorted { lhs, rhs in + lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending + } + var linearStatusesById: [String: LinearStatusResponse] = [:] + for profileId in linearProfileOptions { + if let status = await model.inspectLinearStatus(profileId: profileId) { + linearStatusesById[profileId] = status + } + } + linearProfileStatusById = linearStatusesById + let resolvedLinearProfile = linearProjectProfile.isEmpty + ? linearDefaultProfile + : linearProjectProfile + linearResolvedProfile = resolvedLinearProfile + if let preloadedStatus = linearStatusesById[resolvedLinearProfile] { + linearResolvedStatus = preloadedStatus + } else { + linearResolvedStatus = await model.inspectLinearStatus( + profileId: resolvedLinearProfile.isEmpty ? nil : resolvedLinearProfile + ) + } + if linearResolvedStatus?.tokenResolved == true { + let projectCatalog = await model.listLinearProjects( + profileId: resolvedLinearProfile.isEmpty ? nil : resolvedLinearProfile + ) + linearProjectOptions = (projectCatalog?.projects ?? []).sorted { lhs, rhs in + lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } else { + linearProjectOptions = [] + } + selectedLinearProjectId = linearBoundProjectId executionMode = RemoteExecutionMode.fromConfig(resolvedExecutionModeRaw) ?? (executionTargetNodeId.isEmpty ? .local : .localEditRemoteRun) executionTargetMessage = "" githubProfileMessage = "" + linearProjectMessage = "" } private func queueExecutionTargetReload() { @@ -1525,6 +2016,15 @@ struct ProjectDetailView: View { } } + private func mapLinearProfilesById(response: LinearProfilesResponse?) -> [String: LinearProfileSummary] { + guard let response else { + return [:] + } + return response.profiles.reduce(into: [:]) { partialResult, profile in + partialResult[profile.id] = profile + } + } + private func persistGitHubProfileOverride() async { executionTargetSaving = true defer { executionTargetSaving = false } @@ -1548,6 +2048,126 @@ struct ProjectDetailView: View { queueExecutionTargetReload() } + private func persistLinearProfileOverride() async { + executionTargetSaving = true + defer { executionTargetSaving = false } + + let trimmed = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) + let didEnable = await model.setProjectConfig( + for: project, + key: "controlPlane.extensions[\"dance.hack.linear\"].enabled", + value: "true" + ) + if !didEnable { + linearProjectMessage = "Failed to enable Linear for this project." + return + } + let didSave = await model.setProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.profile", + value: trimmed + ) + if !didSave { + linearProjectMessage = "Failed to save Linear account routing." + return + } + linearProjectMessage = trimmed.isEmpty + ? "Linear account set to inherited." + : "Linear account set to \(linearProfileLabel(profileId: trimmed))." + executionTargetMessage = "" + githubProfileMessage = "" + await model.refresh() + queueExecutionTargetReload() + } + + private func persistLinearProjectBindingSelection() async { + let selectedProjectId = selectedLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + if selectedProjectId.isEmpty { + await clearLinearProjectBinding() + return + } + + guard let linearProject = linearProjectOptions.first(where: { $0.id == selectedProjectId }) else { + linearProjectMessage = "Choose a valid Linear project before saving." + return + } + + let profileOverride = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) + let response = await model.bindLinearProject( + for: project, + profileId: profileOverride.isEmpty ? nil : profileOverride, + projectId: linearProject.id, + projectName: linearProject.name, + teamId: linearProject.teamId, + clear: false + ) + guard response?.ok == true else { + linearProjectMessage = model.errorMessage ?? "Failed to save Linear project binding." + return + } + + linearProjectMessage = "Linear project bound to \(linearProjectMenuLabel(linearProject))." + executionTargetMessage = "" + githubProfileMessage = "" + await model.refresh() + queueExecutionTargetReload() + } + + private func clearLinearProjectBinding() async { + let profileOverride = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) + let response = await model.bindLinearProject( + for: project, + profileId: profileOverride.isEmpty ? nil : profileOverride, + projectId: nil, + projectName: nil, + teamId: nil, + clear: true + ) + guard response?.ok == true else { + linearProjectMessage = model.errorMessage ?? "Failed to clear Linear project binding." + return + } + + selectedLinearProjectId = "" + linearProjectMessage = "Cleared project-level Linear binding." + executionTargetMessage = "" + githubProfileMessage = "" + await model.refresh() + queueExecutionTargetReload() + } + + private func syncBoundLinearProject(from direction: String) async { + let explicitProjectId = resolvedLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + let explicitTeamId = linearBoundTeamId + .trimmingCharacters(in: .whitespacesAndNewlines) + let ownerMode = direction == "hack" ? "hack" : nil + let result = await model.syncLinearProject( + for: project, + from: direction, + ownerMode: ownerMode, + projectId: explicitProjectId.isEmpty ? nil : explicitProjectId, + teamId: explicitTeamId.isEmpty ? nil : explicitTeamId, + limit: nil, + syncLabels: nil + ) + guard let result, result.ok else { + linearProjectMessage = model.errorMessage ?? "Ticket sync failed." + return + } + + if direction == "linear" { + linearProjectMessage = + "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") • created \(result.created) • updated \(result.updated). Linear stays authoritative for Linear-origin title, body, status, and project binding." + } else { + linearProjectMessage = + "Pushed \(result.processed) Hack ticket\(result.processed == 1 ? "" : "s") • created \(result.created) • updated \(result.updated). Hack stays authoritative for Hack-origin title, body, status, and project binding." + } + executionTargetMessage = "" + githubProfileMessage = "" + } + private struct LifecycleSummaryCounts { let startupHookCount: Int let shutdownHookCount: Int diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index 451fae1e6..6e18c75d6 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -16,6 +16,7 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { case supervisor case permissions case extensions + case linear case github case cloudflare case railway @@ -45,6 +46,8 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { return "Permissions" case .extensions: return "Extensions" + case .linear: + return "Linear" case .github: return "GitHub" case .cloudflare: @@ -80,6 +83,8 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { return "hand.raised.fill" case .extensions: return "puzzlepiece.extension" + case .linear: + return "line.3.horizontal.decrease.circle" case .github: return "chevron.left.forwardslash.chevron.right" case .cloudflare: @@ -192,6 +197,7 @@ struct SettingsOverlayView: View { } Section("Extensions") { settingsRow(.extensions) + settingsRow(.linear) settingsRow(.github) settingsRow(.cloudflare) settingsRow(.railway) @@ -233,6 +239,8 @@ struct SettingsOverlayView: View { PermissionsSettingsView() case .extensions: ExtensionsSettingsView(selection: $selection) + case .linear: + LinearExtensionSettingsView() case .github: GitHubExtensionSettingsView() case .cloudflare: @@ -2640,6 +2648,759 @@ private struct TailscaleOAuthCredentialControl: View { } } +private struct LinearExtensionSettingsView: View { + @Environment(DashboardModel.self) private var model + @State private var isLoadingConfig = false + @State private var isSavingConfig = false + @State private var isLoadingDiagnostics = false + @State private var suppressEnabledToggleChange = false + @State private var suppressSyncToggleChange = false + @State private var enabled = false + @State private var diagnostics: LinearProfilesResponse? = nil + @State private var profileStatusById: [String: LinearStatusResponse] = [:] + @State private var defaultProfile = "" + @State private var syncLabels = false + @State private var syncStatuses = true + @State private var syncDependencies = true + @State private var syncProjects = true + @State private var isAuthenticating = false + @State private var authPollingTask: Task? = nil + @State private var authFlowStatus: LinearOAuthFlowStatusResponse? = nil + @State private var disconnectingLinearProfiles: Set = [] + @State private var message = "" + @State private var lastDiagnosticsRefreshAt: Date? = nil + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Extensions / Linear", + title: "Linear Extension", + subtitle: "Manage connected Linear accounts and default issue-sync routing" + ) + GlassCard(title: "Extension status", systemImage: "line.3.horizontal.decrease.circle") { + HStack(alignment: .center, spacing: 8) { + StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) + StatusPill( + text: "\(linearProfiles.count) account\(linearProfiles.count == 1 ? "" : "s")", + tone: linearProfiles.isEmpty ? .neutral : .good + ) + if !resolvedDefaultProfile.isEmpty { + StatusPill( + text: "Default remote: \(displayNameForRemoteProfileId(resolvedDefaultProfile))", + tone: .neutral + ) + } + Spacer() + Toggle("Enabled", isOn: $enabled) + .labelsHidden() + .toggleStyle(.switch) + .onChange(of: enabled) { _, newValue in + guard !suppressEnabledToggleChange else { return } + Task { + await applyLinearEnabledToggle(newValue) + } + } + if isLoadingConfig || isSavingConfig || isLoadingDiagnostics || isAuthenticating { + ProgressView() + .controlSize(.small) + } + } + + if let lastDiagnosticsRefreshAt { + Text("Last checked \(lastDiagnosticsRefreshAt.formatted(date: .abbreviated, time: .shortened))") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + + GlassCard(title: "Connected accounts", systemImage: "point.3.connected.trianglepath.dotted") { + HStack(alignment: .center, spacing: 10) { + Text("Remote Linear accounts used for ticket sync and project binding.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Spacer() + Button { + toggleLinearAuthFlow() + } label: { + Label( + isAuthenticating ? "Cancel add account" : "Add account", + systemImage: isAuthenticating ? "xmark.circle" : "plus.circle" + ) + } + .adaptiveToolbarButtonProminent() + } + + if isAuthenticating { + StatusPill(text: "Waiting for browser auth", tone: .good) + } else if let authFlowStatus { + switch authFlowStatus.status { + case "complete": + StatusPill(text: "Connected", tone: .good) + case "error", "expired": + StatusPill(text: "Connect failed", tone: .warn) + default: + StatusPill(text: "Connect pending", tone: .neutral) + } + } + + InlineCallout( + tone: linearRoutingCalloutTone, + title: linearRoutingCalloutTitle, + message: linearRoutingCalloutMessage, + actions: [] + ) + + if linearProfiles.isEmpty { + Text("No accounts connected yet.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(linearProfiles, id: \.id) { profile in + let profileStatus = profileStatusById[profile.id] + let accountLabel = linearAccountLabel(profile: profile, status: profileStatus) + let accountEmail = linearAccountEmail(profile: profile, status: profileStatus) + let accountId = linearAccountId(profile: profile, status: profileStatus) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(accountLabel ?? profile.id) + .font(.mono(.subheadline, weight: .semibold)) + if profile.isDefault { + StatusPill(text: "Default remote", tone: .good) + } + if let profileStatus { + StatusPill( + text: profileStatus.tokenResolved ? "Token ready" : "Token missing", + tone: profileStatus.tokenResolved ? .good : .warn + ) + } + Spacer() + if !profile.isDefault { + Button { + Task { await saveLinearDefaultProfile(profile.id) } + } label: { + Label("Set default", systemImage: "star") + } + .adaptiveToolbarButton() + } + Button { + Task { + await reconnectLinearProfile( + profile.id, + setDefault: profile.isDefault + ) + } + } label: { + Label("Reconnect", systemImage: "arrow.clockwise.circle") + } + .adaptiveToolbarButtonProminent() + .disabled( + isAuthenticating || + disconnectingLinearProfiles.contains(profile.id) + ) + Button(role: .destructive) { + Task { await disconnectLinearProfile(profile.id) } + } label: { + Label("Disconnect", systemImage: "trash") + } + .adaptiveToolbarButton() + .disabled( + isAuthenticating || + disconnectingLinearProfiles.contains(profile.id) || + profileStatus?.tokenResolved != true + ) + } + + Text("profile: \(profile.id)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + if let accountEmail, !accountEmail.isEmpty { + Text("email: \(accountEmail)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Text("auth: \(profile.authRef) • service: \(profile.service)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + Text("api: \(profile.apiUrl)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + if let accountId, !accountId.isEmpty { + Text("account id: \(accountId)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + if let profileStatus, + let tokenExpiresAt = profileStatus.tokenExpiresAt, + !tokenExpiresAt.isEmpty + { + Text("token expires: \(tokenExpiresAt)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + if profile.id != linearProfiles.last?.id { + Divider() + .opacity(0.2) + } + } + } + Text( + "Disconnect removes the stored token, keeps the profile record for reconnect later, and leaves any existing project routing in place until you switch it." + ) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + + GlassCard(title: "Sync policy", systemImage: "arrow.triangle.branch") { + VStack(alignment: .leading, spacing: 12) { + InlineCallout( + tone: .neutral, + title: "How sync behaves", + message: linearSyncPolicySummary, + actions: [] + ) + + Text("Manual sync stays the default. These toggles control which Linear fields Hack will translate when you run project or ticket sync tools.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 10) { + Toggle("Sync labels", isOn: $syncLabels) + .toggleStyle(.switch) + .onChange(of: syncLabels) { _, newValue in + guard !suppressSyncToggleChange else { return } + Task { + await saveLinearSyncToggle( + key: "labels", + value: newValue, + successMessage: newValue ? "Linear label sync enabled." : "Linear label sync disabled." + ) + } + } + Text( + "Translate Linear labels and Hack categories during manual sync. Review is still needed when names diverge or one side has no clean parallel." + ) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + VStack(alignment: .leading, spacing: 10) { + Toggle("Sync statuses", isOn: $syncStatuses) + .toggleStyle(.switch) + .onChange(of: syncStatuses) { _, newValue in + guard !suppressSyncToggleChange else { return } + Task { + await saveLinearSyncToggle( + key: "statuses", + value: newValue, + successMessage: newValue ? "Linear status sync enabled." : "Linear status sync disabled." + ) + } + } + Text( + "Translate status/state when both systems expose a matching workflow step. Conflicts on origin-owned status stay review-needed instead of auto-overwriting." + ) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + VStack(alignment: .leading, spacing: 10) { + Toggle("Sync dependencies", isOn: $syncDependencies) + .toggleStyle(.switch) + .onChange(of: syncDependencies) { _, newValue in + guard !suppressSyncToggleChange else { return } + Task { + await saveLinearSyncToggle( + key: "dependencies", + value: newValue, + successMessage: newValue ? "Linear dependency sync enabled." : "Linear dependency sync disabled." + ) + } + } + Text( + "Translate dependency and sub-issue style relationships when the counterpart ticket exists. Missing or ambiguous links stay review-needed." + ) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + VStack(alignment: .leading, spacing: 10) { + Toggle("Sync project mapping", isOn: $syncProjects) + .toggleStyle(.switch) + .onChange(of: syncProjects) { _, newValue in + guard !suppressSyncToggleChange else { return } + Task { + await saveLinearSyncToggle( + key: "projects", + value: newValue, + successMessage: newValue ? "Linear project sync enabled." : "Linear project sync disabled." + ) + } + } + Text( + "Allow Hack projects to read and write the paired Linear project binding. The account choice still comes from the global default or a per-project override." + ) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + InlineCallout( + tone: .warn, + title: "Review still needed", + message: linearSyncReviewSummary, + actions: [] + ) + } + } + + if !message.isEmpty { + InlineCallout( + tone: .neutral, + title: "Linear settings", + message: message, + actions: [] + ) + } + } + .padding(16) + } + .task { + await loadConfigFromDisk() + } + .onChange(of: model.lastUpdated) { _, _ in + Task { await loadConfigFromDisk() } + } + .onDisappear { + cancelLinearAuthFlow(userInitiated: false) + } + } + + private var resolvedDefaultProfile: String { + let trimmed = defaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + return diagnostics?.defaultProfile ?? "" + } + + private var linearProfiles: [LinearProfileSummary] { + (diagnostics?.profiles ?? []).sorted { lhs, rhs in + lhs.id.localizedCaseInsensitiveCompare(rhs.id) == .orderedAscending + } + } + + private func nextLinearProfileId() -> String { + let existing = Set(linearProfiles.map { $0.id.lowercased() }) + if !existing.contains("default") { + return "default" + } + var index = 2 + while existing.contains("account-\(index)") { + index += 1 + } + return "account-\(index)" + } + + private var defaultLinearProfileNeedsRepair: Bool { + let defaultProfileId = resolvedDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) + guard !defaultProfileId.isEmpty else { + return false + } + guard linearProfiles.contains(where: { $0.id == defaultProfileId }) else { + return false + } + guard let status = profileStatusById[defaultProfileId] else { + return false + } + return !status.tokenResolved + } + + private var linearRoutingCalloutTone: StatusTone { + if linearProfiles.isEmpty || defaultLinearProfileNeedsRepair || resolvedDefaultProfile.isEmpty { + return .warn + } + return .neutral + } + + private var linearRoutingCalloutTitle: String { + if linearProfiles.isEmpty { + return "Connect an account first" + } + if defaultLinearProfileNeedsRepair { + return "Default routing needs repair" + } + if resolvedDefaultProfile.isEmpty { + return "Set a default remote" + } + return "Routing and disconnect behavior" + } + + private var linearRoutingCalloutMessage: String { + if linearProfiles.isEmpty { + return "Add a Linear account before ticket or project sync can route through Linear. After the first account connects, set it as the default remote so projects inherit a working profile until they choose their own." + } + + if defaultLinearProfileNeedsRepair { + let defaultLabel = displayNameForRemoteProfileId(resolvedDefaultProfile) + return "The default remote \(defaultLabel) no longer has a usable token. Reconnect it or switch the default before the next sync, because projects without an override still inherit that routing target." + } + + if resolvedDefaultProfile.isEmpty { + return "Projects can still choose a specific Linear account, but anything without a per-project override has no fallback remote until you set a default profile here." + } + + let defaultLabel = displayNameForRemoteProfileId(resolvedDefaultProfile) + return "Projects inherit \(defaultLabel) until they choose a different Linear profile in project settings. Disconnecting an account removes its token immediately, so reroute or reconnect affected projects before the next sync run." + } + + private var linearSyncPolicySummary: String { + "Authority follows ticket origin. Origin-owned fields such as title, body, status, and project binding stay authoritative on the side that created the ticket, while assignees, labels, dependencies, and sub-issue links are best-effort mergeable when enabled. Comments are append-only and synced in FIFO order rather than edited in place." + } + + private var linearSyncReviewSummary: String { + "These toggles only enable translation during manual sync. Hack still flags review-needed cases when both sides changed an origin-owned field, when assignees cannot be mapped cleanly, or when mergeable fields do not have a safe one-to-one counterpart." + } + + private func loadConfigFromDisk() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + let snapshot = GlobalConfigSnapshot.load() + suppressEnabledToggleChange = true + enabled = snapshot.linearExtensionEnabled ?? false + defaultProfile = snapshot.linearDefaultProfile ?? "" + suppressSyncToggleChange = true + syncLabels = snapshot.linearSyncLabels ?? false + syncStatuses = snapshot.linearSyncStatuses ?? true + syncDependencies = snapshot.linearSyncDependencies ?? true + syncProjects = snapshot.linearSyncProjects ?? true + suppressSyncToggleChange = false + suppressEnabledToggleChange = false + message = "" + await refreshLinearDiagnostics() + } + + private func refreshLinearDiagnostics() async { + isLoadingDiagnostics = true + defer { isLoadingDiagnostics = false } + diagnostics = await model.inspectLinearProfiles() + if let diagnostics { + defaultProfile = diagnostics.defaultProfile + } + await refreshLinearProfileStatuses() + lastDiagnosticsRefreshAt = Date() + } + + private func refreshLinearProfileStatuses() async { + guard let diagnostics else { + profileStatusById = [:] + return + } + var statuses: [String: LinearStatusResponse] = [:] + for profile in diagnostics.profiles { + if let status = await model.inspectLinearStatus(profileId: profile.id) { + statuses[profile.id] = status + } + } + profileStatusById = statuses + } + + private func applyLinearEnabledToggle(_ newValue: Bool) async { + isSavingConfig = true + defer { isSavingConfig = false } + let didUpdate = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.linear\"].enabled", + value: newValue ? "true" : "false" + ) + if !didUpdate { + await loadConfigFromDisk() + message = "Failed to update extension enabled state." + return + } + message = newValue ? "Linear extension enabled." : "Linear extension disabled." + await model.refresh() + await refreshLinearDiagnostics() + } + + private func saveLinearSyncToggle( + key: String, + value: Bool, + successMessage: String + ) async { + isSavingConfig = true + defer { isSavingConfig = false } + let didUpdate = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.linear\"].config.sync.\(key)", + value: value ? "true" : "false" + ) + if !didUpdate { + await loadConfigFromDisk() + message = "Failed to update Linear sync policy." + return + } + message = successMessage + await model.refresh() + await refreshLinearDiagnostics() + } + + private func linearAccountLabel( + profile: LinearProfileSummary, + status: LinearStatusResponse? + ) -> String? { + let profileName = profile.accountName?.trimmingCharacters(in: .whitespacesAndNewlines) + if let profileName, !profileName.isEmpty { + return profileName + } + let statusName = status?.accountName?.trimmingCharacters(in: .whitespacesAndNewlines) + if let statusName, !statusName.isEmpty { + return statusName + } + let statusHandle = status?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + if let statusHandle, !statusHandle.isEmpty { + return statusHandle + } + let profileEmail = profile.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + if let profileEmail, !profileEmail.isEmpty { + return profileEmail + } + return nil + } + + private func linearAccountEmail( + profile: LinearProfileSummary, + status: LinearStatusResponse? + ) -> String? { + let profileEmail = profile.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + if let profileEmail, !profileEmail.isEmpty { + return profileEmail + } + let statusEmail = status?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + if let statusEmail, !statusEmail.isEmpty { + return statusEmail + } + return nil + } + + private func linearAccountId( + profile: LinearProfileSummary, + status: LinearStatusResponse? + ) -> String? { + let profileId = profile.accountId?.trimmingCharacters(in: .whitespacesAndNewlines) + if let profileId, !profileId.isEmpty { + return profileId + } + let statusId = status?.accountId?.trimmingCharacters(in: .whitespacesAndNewlines) + if let statusId, !statusId.isEmpty { + return statusId + } + return nil + } + + private func displayNameForRemoteProfileId(_ profileId: String) -> String { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "none" + } + guard let profile = linearProfiles.first(where: { $0.id == trimmed }) else { + return trimmed + } + let status = profileStatusById[trimmed] + guard let label = linearAccountLabel(profile: profile, status: status) else { + return trimmed + } + return "\(label) (\(trimmed))" + } + + private func saveLinearDefaultProfile(_ profileId: String) async { + isSavingConfig = true + defer { isSavingConfig = false } + + let trimmedDefault = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + let didSaveDefault = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.linear\"].config.defaultProfile", + value: trimmedDefault + ) + if !didSaveDefault { + message = "Failed to save Linear default profile." + return + } + + message = trimmedDefault.isEmpty + ? "Linear default profile cleared. Projects now need an explicit Linear routing override before sync can fall back to a remote account." + : "Linear default profile saved. Projects without their own override will now route through \(displayNameForRemoteProfileId(trimmedDefault))." + defaultProfile = trimmedDefault + await model.refresh() + await refreshLinearDiagnostics() + } + + private func reconnectLinearProfile( + _ profileId: String, + setDefault: Bool + ) async { + await connectLinearAccountViaBrowser( + profileId: profileId, + setDefault: setDefault + ) + } + + private func disconnectLinearProfile(_ profileId: String) async { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + disconnectingLinearProfiles.insert(trimmed) + defer { disconnectingLinearProfiles.remove(trimmed) } + + let disconnected = await model.disconnectLinear(profileId: trimmed) + if !disconnected { + message = model.errorMessage ?? "Failed to disconnect Linear account." + return + } + + if resolvedDefaultProfile == trimmed { + message = "Disconnected token for \(displayNameForRemoteProfileId(trimmed)). The profile stays available for reconnect, but projects inheriting this default remote need a reconnect or a different default before the next sync." + } else { + message = "Disconnected token for \(displayNameForRemoteProfileId(trimmed)). The profile stays available for reconnect, and any project routed to it needs a reconnect or a different override before the next sync." + } + await model.refresh() + await refreshLinearDiagnostics() + } + + private func toggleLinearAuthFlow() { + if isAuthenticating { + cancelLinearAuthFlow(userInitiated: true) + return + } + let repairDefaultProfileId = resolvedDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) + let generatedProfileId = defaultLinearProfileNeedsRepair && !repairDefaultProfileId.isEmpty + ? repairDefaultProfileId + : nextLinearProfileId() + let setAsDefault = linearProfiles.isEmpty || defaultLinearProfileNeedsRepair + authPollingTask = Task { + await connectLinearAccountViaBrowser( + profileId: generatedProfileId, + setDefault: setAsDefault + ) + } + } + + private func cancelLinearAuthFlow(userInitiated: Bool) { + authPollingTask?.cancel() + authPollingTask = nil + isAuthenticating = false + if userInitiated { + message = "Linear authentication canceled." + } + } + + private func connectLinearAccountViaBrowser( + profileId: String, + setDefault: Bool + ) async { + defer { + authPollingTask = nil + isAuthenticating = false + } + + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + let profile = trimmed.isEmpty ? "default" : trimmed + isAuthenticating = true + authFlowStatus = nil + message = "Starting Linear browser auth for profile \(profile). When it completes, the profile can be used for default routing or project-specific sync." + + guard + let started = await model.startLinearOAuthFlow( + profileId: profile, + setDefault: setDefault + ) + else { + let detail = model.errorMessage?.trimmingCharacters( + in: .whitespacesAndNewlines + ) + if let detail, !detail.isEmpty { + message = detail + } else { + message = + "Unable to start Linear auth. Confirm network access to the auth broker and retry." + } + return + } + + if setDefault { + defaultProfile = profile + } + + guard let authorizeURL = URL(string: started.authorizeUrl) else { + message = "Auth start returned an invalid authorize URL." + return + } + + NSWorkspace.shared.open(authorizeURL) + message = "Browser opened. Approve access to finish connecting \(profile), then return here to use it for default or per-project routing." + + let formatter = ISO8601DateFormatter() + let expiresAtDate = formatter.date(from: started.expiresAt) + + while !Task.isCancelled { + if let expiresAtDate, Date() >= expiresAtDate { + message = "Authentication flow expired. Start a new connection." + return + } + + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled else { + return + } + + guard + let flowStatus = await model.fetchLinearOAuthFlowStatus( + statusURL: started.statusUrl + ) + else { + let detail = model.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) + if let detail, !detail.isEmpty { + message = detail + return + } + continue + } + + authFlowStatus = flowStatus + if await handleLinearOAuthFlowStatus(flowStatus) { + return + } + } + } + + private func handleLinearOAuthFlowStatus( + _ flowStatus: LinearOAuthFlowStatusResponse + ) async -> Bool { + switch flowStatus.status { + case "complete": + let account = flowStatus.accountName + ?? flowStatus.accountEmail + ?? flowStatus.accountHandle + ?? "Linear account" + message = "Connected \(account) to profile \(flowStatus.profileId). You can now use it for default routing or bind projects to it directly." + await model.refresh() + await refreshLinearDiagnostics() + return true + case "error": + message = flowStatus.error ?? "Linear authentication failed." + return true + case "expired": + message = "Authentication flow expired. Start a new connection." + return true + default: + return false + } + } +} + private struct GitHubExtensionSettingsView: View { @Environment(DashboardModel.self) private var model @State private var isLoadingConfig = false @@ -4116,6 +4877,7 @@ private struct ExtensionsSettingsView: View { @Binding var selection: SettingsSidebarItem @State private var isLoading = false @State private var suppressToggleChange = false + @State private var linearEnabled = false @State private var githubEnabled = false @State private var cloudflareEnabled = false @State private var railwayEnabled = false @@ -4137,6 +4899,16 @@ private struct ExtensionsSettingsView: View { ) GlassCard(title: "Managed extensions", systemImage: "puzzlepiece.extension") { VStack(alignment: .leading, spacing: 12) { + extensionSummaryRow( + item: .linear, + name: "Linear", + description: "Issue sync profiles, default project routing, and broker-based OAuth account setup.", + projectCount: projectCount(for: "dance.hack.linear"), + exposure: extensionExposure(id: "linear"), + isOn: $linearEnabled + ) + Divider() + .opacity(0.2) extensionSummaryRow( item: .github, name: "GitHub", @@ -4260,6 +5032,8 @@ private struct ExtensionsSettingsView: View { for value in (project.extensionsEnabled ?? []) + (project.features ?? []) { let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() switch normalized { + case "linear", "dance.hack.linear": + ids.insert("dance.hack.linear") case "cloudflare", "dance.hack.cloudflare": ids.insert("dance.hack.cloudflare") case "github", "dance.hack.github": @@ -4282,6 +5056,7 @@ private struct ExtensionsSettingsView: View { private func loadConfigFromDisk() async { let snapshot = GlobalConfigSnapshot.load() suppressToggleChange = true + linearEnabled = snapshot.linearExtensionEnabled ?? false githubEnabled = snapshot.githubExtensionEnabled ?? false cloudflareEnabled = snapshot.cloudflareExtensionEnabled ?? false railwayEnabled = snapshot.railwayExtensionEnabled ?? false @@ -4298,6 +5073,8 @@ private struct ExtensionsSettingsView: View { let key: String switch item { + case .linear: + key = "controlPlane.extensions[\"dance.hack.linear\"].enabled" case .github: key = "controlPlane.extensions[\"dance.hack.github\"].enabled" case .cloudflare: @@ -4612,10 +5389,16 @@ private struct LoggingSettingsView: View { private struct GlobalConfigSnapshot { let daemonLaunchdRunAtLoad: Bool? + let linearExtensionEnabled: Bool? let githubExtensionEnabled: Bool? let cloudflareExtensionEnabled: Bool? let railwayExtensionEnabled: Bool? let tailscaleExtensionEnabled: Bool? + let linearDefaultProfile: String? + let linearSyncLabels: Bool? + let linearSyncStatuses: Bool? + let linearSyncDependencies: Bool? + let linearSyncProjects: Bool? let githubDefaultProfile: String? let railwayProject: String? let railwayService: String? @@ -4678,10 +5461,13 @@ private struct GlobalConfigSnapshot { let sessionPreferences = dictionary(preferences, key: "sessions") let containerPreferences = dictionary(preferences, key: "containers") let extensions = dictionary(controlPlane, key: "extensions") + let linearExt = dictionary(extensions, key: "dance.hack.linear") let githubExt = dictionary(extensions, key: "dance.hack.github") let cloudflareExt = dictionary(extensions, key: "dance.hack.cloudflare") let railwayExt = dictionary(extensions, key: "dance.hack.railway") let tailscaleExt = dictionary(extensions, key: "dance.hack.tailscale") + let linearConfig = dictionary(linearExt, key: "config") + let linearSync = dictionary(linearConfig, key: "sync") let githubConfig = dictionary(githubExt, key: "config") let cloudflareConfig = dictionary(cloudflareExt, key: "config") let railwayConfig = dictionary(railwayExt, key: "config") @@ -4689,10 +5475,16 @@ private struct GlobalConfigSnapshot { return Self( daemonLaunchdRunAtLoad: launchd["runAtLoad"] as? Bool, + linearExtensionEnabled: linearExt["enabled"] as? Bool, githubExtensionEnabled: githubExt["enabled"] as? Bool, cloudflareExtensionEnabled: cloudflareExt["enabled"] as? Bool, railwayExtensionEnabled: railwayExt["enabled"] as? Bool, tailscaleExtensionEnabled: tailscaleExt["enabled"] as? Bool, + linearDefaultProfile: linearConfig["defaultProfile"] as? String, + linearSyncLabels: linearSync["labels"] as? Bool, + linearSyncStatuses: linearSync["statuses"] as? Bool, + linearSyncDependencies: linearSync["dependencies"] as? Bool, + linearSyncProjects: linearSync["projects"] as? Bool, githubDefaultProfile: githubConfig["defaultProfile"] as? String, railwayProject: railwayConfig["project"] as? String, railwayService: railwayConfig["service"] as? String, @@ -4728,10 +5520,16 @@ private struct GlobalConfigSnapshot { static var empty: Self { Self( daemonLaunchdRunAtLoad: nil, + linearExtensionEnabled: nil, githubExtensionEnabled: nil, cloudflareExtensionEnabled: nil, railwayExtensionEnabled: nil, tailscaleExtensionEnabled: nil, + linearDefaultProfile: nil, + linearSyncLabels: nil, + linearSyncStatuses: nil, + linearSyncDependencies: nil, + linearSyncProjects: nil, githubDefaultProfile: nil, railwayProject: nil, railwayService: nil, diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index 801cec1e7..b1ece6644 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -6,6 +6,7 @@ import HackDesktopModels struct TicketsView: View { @Environment(DashboardModel.self) private var model @Environment(\.colorScheme) private var colorScheme + @Environment(\.openURL) private var openURL let project: ProjectSummary @@ -18,12 +19,20 @@ struct TicketsView: View { @State private var detailErrorMessage: String? = nil @State private var showCreateSheet = false @State private var selectedFilter: TicketFilter = .all + @State private var selectedOriginFilter: TicketOriginFilter = .both @State private var hasLoadedOnce = false @State private var isPropertiesExpanded = false @State private var isHistoryExpanded = false @State private var searchText = "" @State private var loadNotice: String? = nil @State private var hoveredTicketId: String? = nil + @State private var linearRouteProfile = "" + @State private var linearRouteProjectId = "" + @State private var linearRouteProjectName = "" + @State private var linearRouteTeamId = "" + @State private var linearRouteStatus: LinearStatusResponse? = nil + @State private var pendingBulkSyncAction: TicketBulkSyncAction? = nil + @State private var activeSyncAction: TicketSyncAction? = nil @FocusState private var ticketsListFocused: Bool var body: some View { @@ -42,6 +51,7 @@ struct TicketsView: View { .padding(.bottom, 24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { + await refreshLinearRouting() loadCachedTickets() await refreshTickets() ticketsListFocused = true @@ -49,9 +59,15 @@ struct TicketsView: View { .task(id: selectedTicketId) { await loadTicketDetail() } + .onChange(of: model.lastUpdated) { _, _ in + Task { await refreshLinearRouting() } + } .onChange(of: selectedFilter) { _, _ in updateSelectionAfterRefresh() } + .onChange(of: selectedOriginFilter) { _, _ in + updateSelectionAfterRefresh() + } .sheet(isPresented: $showCreateSheet) { TicketCreateSheet( projectName: project.name, @@ -62,32 +78,128 @@ struct TicketsView: View { } ) } + .confirmationDialog( + pendingBulkSyncAction?.dialogTitle ?? "Confirm Linear sync", + isPresented: bulkSyncConfirmationPresented, + titleVisibility: .visible + ) { + if let pendingBulkSyncAction { + Button(pendingBulkSyncAction.confirmLabel) { + Task { await performBulkSync(pendingBulkSyncAction) } + } + } + Button("Cancel", role: .cancel) { + pendingBulkSyncAction = nil + } + } message: { + if let pendingBulkSyncAction { + Text(pendingBulkSyncAction.message) + } + } } private var header: some View { - HStack(alignment: .center, spacing: 12) { - statusFilterMenu - searchField - Spacer(minLength: 12) - if let loadNotice { - Text(loadNotice) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - newTicketButton - Menu { - Button("Refresh") { - Task { await refreshTickets() } + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 12) { + statusFilterMenu + originFilterMenu + searchField + if !linearRouteStatusLabel.isEmpty { + ticketHeaderBadge( + title: linearRouteStatusLabel, + systemImage: linearRouteStatus?.tokenResolved == true ? "point.3.connected.trianglepath.dotted" : "exclamationmark.triangle", + tone: linearRouteStatus?.tokenResolved == true ? .secondary : .orange + ) } - Button("Sync") { - Task { await syncTickets() } + Button { + openProjectRouting() + } label: { + ticketHeaderBadge( + title: "Project routing", + systemImage: "slider.horizontal.3", + tone: .secondary + ) } - } label: { - Image(systemName: "ellipsis.circle") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.primary.opacity(0.7)) + .buttonStyle(.plain) + Spacer(minLength: 12) + if let activeSyncAction { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(activeSyncAction.progressLabel) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + Button { + requestBulkSyncConfirmation(.pullLinear) + } label: { + Label("Pull Linear", systemImage: "arrow.down.left") + } + .adaptiveToolbarButton() + .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) + Button { + requestBulkSyncConfirmation(.pushHack) + } label: { + Label("Push Hack", systemImage: "arrow.up.right") + } + .adaptiveToolbarButton() + .disabled(!canSyncProjectToLinear || isAnySyncInFlight) + newTicketButton + Menu { + Button("Refresh") { + Task { await refreshTickets() } + } + Button("Sync ticket ref store") { + Task { await syncTickets() } + } + Divider() + Button("Pull Linear into tickets") { + requestBulkSyncConfirmation(.pullLinear) + } + .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) + Button("Push Hack tickets to Linear") { + requestBulkSyncConfirmation(.pushHack) + } + .disabled(!canSyncProjectToLinear || isAnySyncInFlight) + if let selectedTicket { + Divider() + Button("Push selected ticket to Linear") { + Task { await syncSelectedTicketToLinear(selectedTicket) } + } + .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) + Button("Refresh selected ticket from Linear") { + Task { await syncSelectedTicketFromLinear(selectedTicket) } + } + .disabled(selectedTicket.externalKey?.isEmpty != false || isAnySyncInFlight) + if let externalURL = selectedTicket.externalUrl, + let url = URL(string: externalURL) { + Button("Open linked Linear issue") { + openURL(url) + } + } + } + Divider() + Button("Open Linear settings") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + } + Button("Open project routing") { + openProjectRouting() + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.primary.opacity(0.7)) + } + .buttonStyle(PressableIconButtonStyle()) } - .buttonStyle(PressableIconButtonStyle()) + + linearSyncPolicyCallout + loadNoticeCallout } } @@ -95,6 +207,189 @@ struct TicketsView: View { selectedTicketId != nil } + private var selectedTicket: TicketSummary? { + guard let selectedTicketId else { + return nil + } + return tickets.first(where: { $0.ticketId == selectedTicketId }) + } + + private var linearRouteStatusLabel: String { + if linearRouteStatus?.tokenResolved != true { + return "Linear not connected" + } + if !linearRouteProjectName.isEmpty { + return linearRouteProjectName + } + if !linearRouteProjectId.isEmpty { + return linearRouteProjectId + } + if !linearRouteProfile.isEmpty { + return "Profile \(linearRouteProfile)" + } + return "" + } + + private var canSyncProjectFromLinear: Bool { + linearRouteStatus?.tokenResolved == true && !linearRouteProjectId.isEmpty + } + + private var canSyncProjectToLinear: Bool { + linearRouteStatus?.tokenResolved == true + } + + private var isAnySyncInFlight: Bool { + activeSyncAction != nil + } + + private var bulkSyncConfirmationPresented: Binding { + Binding( + get: { pendingBulkSyncAction != nil }, + set: { isPresented in + if !isPresented { + pendingBulkSyncAction = nil + } + } + ) + } + + private var linearSyncPolicyCallout: some View { + InlineCallout( + tone: linearSyncPolicyTone, + title: "Linear sync policy", + message: linearSyncPolicyMessage, + actions: linearSyncPolicyActions + ) + } + + @ViewBuilder + private var loadNoticeCallout: some View { + if let loadNotice { + InlineCallout( + tone: loadNoticeTone, + title: loadNoticeTitle, + message: loadNotice, + actions: loadNoticeActions + ) + } + } + + private var linearSyncPolicyTone: StatusTone { + if linearRouteStatus?.tokenResolved != true || linearRouteProjectId.isEmpty { + return .warn + } + return .neutral + } + + private var linearSyncPolicyMessage: String { + let baseMessage = + "Authority is origin-derived from the ticket owner. Comments stay append-only in arrival order. Assignee, labels, and dependencies are the mergeable fields and may need review." + if linearRouteStatus?.tokenResolved != true { + return "\(baseMessage) Connect a Linear account to enable project pull and per-ticket refresh." + } + if linearRouteProjectId.isEmpty { + return "\(baseMessage) This project still needs a Linear route before bulk pull can target the right project." + } + return "\(baseMessage) Pull Linear when Linear owns the ticket. Push Hack when Hack owns the ticket and you want to publish it outward." + } + + private var linearSyncPolicyActions: [InlineCalloutAction] { + if linearRouteStatus?.tokenResolved != true { + return [ + InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { + openLinearSettings() + }, + InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + openProjectRouting() + }, + ] + } + if linearRouteProjectId.isEmpty { + return [ + InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + openProjectRouting() + } + ] + } + return [] + } + + private var loadNoticeTone: StatusTone { + guard let loadNotice else { return .neutral } + let normalized = loadNotice.lowercased() + if normalized.contains("failed") + || normalized.contains("not linked") + || normalized.contains("timed out") + { + return .warn + } + return .good + } + + private var loadNoticeTitle: String { + guard let loadNotice else { return "Ticket status" } + let normalized = loadNotice.lowercased() + if normalized.contains("timed out") { + return "Using cached tickets" + } + if normalized.contains("failed") { + return "Linear sync needs attention" + } + if normalized.contains("not linked") { + return "Linear link required" + } + if normalized.hasPrefix("pulled") { + return "Linear pull complete" + } + if normalized.hasPrefix("pushed") { + return "Hack push complete" + } + if normalized.hasPrefix("synced") { + return "Ticket pushed to Linear" + } + if normalized.hasPrefix("refreshed") { + return "Ticket refreshed from Linear" + } + return "Ticket status" + } + + private var loadNoticeActions: [InlineCalloutAction] { + guard let loadNotice else { return [] } + let normalized = loadNotice.lowercased() + if normalized.contains("timed out") { + return [ + InlineCalloutAction(label: "Refresh", systemImage: "arrow.clockwise") { + Task { await refreshTickets() } + } + ] + } + if normalized.contains("failed") { + if linearRouteStatus?.tokenResolved != true { + return [ + InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { + openLinearSettings() + } + ] + } + if linearRouteProjectId.isEmpty { + return [ + InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + openProjectRouting() + } + ] + } + } + if normalized.contains("not linked") && linearRouteStatus?.tokenResolved == true { + return [ + InlineCalloutAction(label: "Push selected ticket", systemImage: "arrow.up.right") { + guard let selectedTicket else { return } + Task { await syncSelectedTicketToLinear(selectedTicket) } + } + ] + } + return [] + } + private var statusFilterMenu: some View { Menu { ForEach(TicketFilter.allCases, id: \.self) { filter in @@ -127,6 +422,38 @@ struct TicketsView: View { .menuStyle(.borderlessButton) } + private var originFilterMenu: some View { + Menu { + ForEach(TicketOriginFilter.allCases, id: \.self) { filter in + Button { + selectedOriginFilter = filter + } label: { + if selectedOriginFilter == filter { + Label("\(filter.label) (\(filter.count(in: tickets)))", systemImage: "checkmark") + } else { + Text("\(filter.label) (\(filter.count(in: tickets)))") + } + } + } + } label: { + HStack(spacing: 8) { + Image(systemName: "tray.full") + .font(.system(size: 13, weight: .semibold)) + Text(selectedOriginFilter.label) + .font(.system(size: 13, weight: .semibold)) + Text("\(selectedOriginFilter.count(in: tickets))") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + headerControlCapsuleBackground + } + } + .menuStyle(.borderlessButton) + } + private var searchField: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") @@ -261,13 +588,33 @@ struct TicketsView: View { VStack(alignment: .leading, spacing: 8) { Text("No tickets found.") .font(.system(size: 15, weight: .semibold)) - Text("Try another filter or create a new ticket.") + Text("Adjust filters, pull from Linear, or create a new ticket.") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) - Button("New ticket") { - showCreateSheet = true + HStack(spacing: 8) { + Button("New ticket") { + showCreateSheet = true + } + .adaptiveToolbarButton() + + Button("Pull Linear") { + requestBulkSyncConfirmation(.pullLinear) + } + .adaptiveToolbarButtonProminent() + .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) + + Button("Project routing") { + openProjectRouting() + } + .adaptiveToolbarButton() + + if linearRouteStatus?.tokenResolved != true { + Button("Linear settings") { + openLinearSettings() + } + .adaptiveToolbarButton() + } } - .adaptiveToolbarButton() } .padding(.horizontal, 14) .padding(.top, 8) @@ -304,6 +651,7 @@ struct TicketsView: View { .font(.system(size: 15, weight: .semibold)) .lineLimit(2) .multilineTextAlignment(.leading) + ticketRowMetadata(ticket) } Spacer(minLength: 0) } @@ -325,6 +673,23 @@ struct TicketsView: View { .animation(.easeInOut(duration: 0.12), value: hoveredTicketId) } + private func ticketRowMetadata(_ ticket: TicketSummary) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ticketMetaPill(ticket.owner, tone: ticket.owner == "linear" ? .orange : .secondary) + ticketMetaPill(ticket.source, tone: ticket.source == "linear" ? .orange : .secondary) + ticketMetaPill(ticketAuthorityBadgeLabel(for: ticket), tone: ticketAuthorityBadgeColor(for: ticket)) + if ticket.linearSyncUXState.reviewHint != nil { + ticketMetaPill("Review sync", tone: .orange) + } + if let externalKey = ticket.externalKey, !externalKey.isEmpty { + ticketMetaPill(externalKey, tone: .accentColor) + } + } + .padding(.top, 2) + } + } + private func ticketDetailView(_ detail: TicketDetailResponse) -> some View { VStack(alignment: .leading, spacing: 20) { HStack(alignment: .top, spacing: 12) { @@ -339,6 +704,7 @@ struct TicketsView: View { .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) } + ticketDetailMetadata(detail.ticket) } Spacer() statusMenu(ticket: detail.ticket) @@ -352,11 +718,192 @@ struct TicketsView: View { markdownBody(body) } } + + ticketSyncGuidanceCallout(detail.ticket) + } + } + + private func ticketDetailMetadata(_ ticket: TicketSummary) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ticketMetaPill(ticket.owner, tone: ticket.owner == "linear" ? .orange : .secondary) + ticketMetaPill(ticket.source, tone: ticket.source == "linear" ? .orange : .secondary) + ticketMetaPill(ticketAuthorityBadgeLabel(for: ticket), tone: ticketAuthorityBadgeColor(for: ticket)) + if ticket.linearSyncUXState.reviewHint != nil { + ticketMetaPill("Review sync", tone: .orange) + } + if let externalSystem = ticket.externalSystem, !externalSystem.isEmpty { + ticketMetaPill(externalSystem, tone: .accentColor) + } + if let externalKey = ticket.externalKey, !externalKey.isEmpty { + ticketMetaPill(externalKey, tone: .accentColor) + } + if let projectName = ticket.externalProjectName, !projectName.isEmpty { + ticketMetaPill(projectName, tone: .secondary) + } + if !ticket.tags.isEmpty { + ForEach(ticket.tags, id: \.self) { tag in + ticketMetaPill(tag, tone: .secondary) + } + } + } + } + } + + private func ticketSyncGuidanceCallout(_ ticket: TicketSummary) -> some View { + let guidance = ticketSyncGuidance(for: ticket) + return InlineCallout( + tone: guidance.tone, + title: guidance.title, + message: guidance.message, + actions: ticketSyncGuidanceActions(for: ticket) + ) + } + + private func ticketSyncGuidanceActions(for ticket: TicketSummary) -> [InlineCalloutAction] { + let syncState = ticket.linearSyncUXState + if syncState.reviewHint != nil { + return [ + InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + openProjectRouting() + } + ] + } + + if !syncState.isLinkedToLinear { + if linearRouteStatus?.tokenResolved == true { + return [ + InlineCalloutAction(label: "Push Hack changes", systemImage: "arrow.up.right") { + Task { await syncSelectedTicketToLinear(ticket) } + } + ] + } + return [ + InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { + openLinearSettings() + }, + InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + openProjectRouting() + }, + ] + } + + switch syncState.authority { + case .linear: + if let externalKey = ticket.externalKey, !externalKey.isEmpty { + return [ + InlineCalloutAction(label: "Pull Linear changes", systemImage: "arrow.down.left") { + Task { await syncSelectedTicketFromLinear(ticket) } + } + ] + } + return [] + case .hack: + if linearRouteStatus?.tokenResolved == true { + return [ + InlineCalloutAction(label: "Push Hack changes", systemImage: "arrow.up.right") { + Task { await syncSelectedTicketToLinear(ticket) } + } + ] + } + return [ + InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { + openLinearSettings() + }, + ] + } + } + + private func ticketAuthorityBadgeLabel(for ticket: TicketSummary) -> String { + if ticket.linearSyncUXState.reviewHint != nil { + return "Review authority" + } + switch ticket.linearSyncUXState.authority { + case .hack: + return "Hack authority" + case .linear: + return "Linear authority" + } + } + + private func ticketAuthorityBadgeColor(for ticket: TicketSummary) -> Color { + if ticket.linearSyncUXState.reviewHint != nil { + return .orange + } + switch ticket.linearSyncUXState.authority { + case .hack: + return .secondary + case .linear: + return .orange + } + } + + private func ticketSyncGuidance(for ticket: TicketSummary) -> TicketSyncGuidance { + let syncState = ticket.linearSyncUXState + if let reviewHint = syncState.reviewHint { + return TicketSyncGuidance( + title: "Review authority before syncing", + message: "\(syncState.shortGuidance) \(reviewHint)", + tone: .warn + ) + } + + if !syncState.isLinkedToLinear { + return TicketSyncGuidance( + title: "Local ticket", + message: syncState.shortGuidance, + tone: .neutral + ) + } + + switch syncState.authority { + case .hack: + return TicketSyncGuidance( + title: "Hack owns authoritative edits", + message: "\(syncState.shortGuidance) Push when you want to publish local changes outward.", + tone: .neutral + ) + case .linear: + return TicketSyncGuidance( + title: "Linear owns authoritative edits", + message: "\(syncState.shortGuidance) Pull when you want the local ticket to match the linked Linear issue.", + tone: .neutral + ) } } private func ticketDetailFooter(_ detail: TicketDetailResponse) -> some View { VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Button { + Task { await syncSelectedTicketToLinear(detail.ticket) } + } label: { + Label("Push Hack changes", systemImage: "arrow.up.right") + } + .adaptiveToolbarButtonProminent() + .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) + + Button { + Task { await syncSelectedTicketFromLinear(detail.ticket) } + } label: { + Label("Pull Linear changes", systemImage: "arrow.down.left") + } + .adaptiveToolbarButton() + .disabled(detail.ticket.externalKey?.isEmpty != false || isAnySyncInFlight) + + if let externalURL = detail.ticket.externalUrl, + let url = URL(string: externalURL) { + Button { + openURL(url) + } label: { + Label("Open issue", systemImage: "link") + } + .adaptiveToolbarButton() + } + + Spacer() + } + bottomPinnedDisclosure(title: "History", isExpanded: $isHistoryExpanded) { if detail.events.isEmpty { Text("No history events for this ticket.") @@ -637,6 +1184,151 @@ struct TicketsView: View { await refreshTickets() } + private func requestBulkSyncConfirmation(_ action: TicketBulkSyncAction) { + pendingBulkSyncAction = action + } + + private func performBulkSync(_ action: TicketBulkSyncAction) async { + pendingBulkSyncAction = nil + switch action { + case .pullLinear: + await syncProjectFromLinear() + case .pushHack: + await syncProjectToLinear() + } + } + + private func runSyncAction( + _ action: TicketSyncAction, + operation: () async -> Void + ) async { + activeSyncAction = action + defer { activeSyncAction = nil } + await operation() + } + + private func refreshLinearRouting() async { + async let projectProfile = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.profile" + ) + async let projectId = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.projectId" + ) + async let projectName = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.projectName" + ) + async let teamId = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.teamId" + ) + async let defaultProfile = model.getGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.linear\"].config.defaultProfile" + ) + + let resolvedProjectProfile = (await projectProfile ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedDefaultProfile = (await defaultProfile ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + linearRouteProfile = resolvedProjectProfile.isEmpty ? resolvedDefaultProfile : resolvedProjectProfile + linearRouteProjectId = (await projectId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + linearRouteProjectName = (await projectName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + linearRouteTeamId = (await teamId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + linearRouteStatus = await model.inspectLinearStatus( + profileId: linearRouteProfile.isEmpty ? nil : linearRouteProfile + ) + } + + private func syncProjectFromLinear() async { + await runSyncAction(.pullProjectFromLinear) { + loadNotice = nil + let result = await model.syncLinearProject( + for: project, + from: "linear", + ownerMode: nil, + projectId: linearRouteProjectId.isEmpty ? nil : linearRouteProjectId, + teamId: nil, + limit: nil, + syncLabels: nil + ) + guard let result, result.ok else { + loadNotice = model.errorMessage ?? "Linear sync failed." + return + } + loadNotice = "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from Linear." + await refreshTickets() + } + } + + private func syncProjectToLinear() async { + await runSyncAction(.pushProjectToLinear) { + loadNotice = nil + let result = await model.syncLinearProject( + for: project, + from: "hack", + ownerMode: "hack", + projectId: linearRouteProjectId.isEmpty ? nil : linearRouteProjectId, + teamId: linearRouteTeamId.isEmpty ? nil : linearRouteTeamId, + limit: nil, + syncLabels: nil + ) + guard let result, result.ok else { + loadNotice = model.errorMessage ?? "Linear sync failed." + return + } + loadNotice = "Pushed \(result.processed) Hack ticket\(result.processed == 1 ? "" : "s") to Linear." + await refreshTickets() + } + } + + private func syncSelectedTicketToLinear(_ ticket: TicketSummary) async { + await runSyncAction(.pushTicketToLinear(ticket.ticketId)) { + loadNotice = nil + let result = await model.syncLinearIssue( + for: project, + from: "hack", + issueIdentifier: nil, + ticketId: ticket.ticketId, + projectId: linearRouteProjectId.isEmpty ? nil : linearRouteProjectId, + teamId: linearRouteTeamId.isEmpty ? nil : linearRouteTeamId, + syncLabels: nil + ) + guard let result, result.ok else { + loadNotice = model.errorMessage ?? "Ticket sync failed." + return + } + loadNotice = "Synced \(result.ticketId) to \(result.issueIdentifier)." + await refreshTickets() + } + } + + private func syncSelectedTicketFromLinear(_ ticket: TicketSummary) async { + guard let issueIdentifier = ticket.externalKey, !issueIdentifier.isEmpty else { + loadNotice = "This ticket is not linked to a Linear issue yet." + return + } + await runSyncAction(.pullTicketFromLinear(ticket.ticketId)) { + loadNotice = nil + let result = await model.syncLinearIssue( + for: project, + from: "linear", + issueIdentifier: issueIdentifier, + ticketId: nil, + projectId: nil, + teamId: nil, + syncLabels: nil + ) + guard let result, result.ok else { + loadNotice = model.errorMessage ?? "Ticket refresh failed." + return + } + loadNotice = "Refreshed \(result.ticketId) from \(result.issueIdentifier)." + await refreshTickets() + } + } + private func parseTicketRefs(_ text: String) -> [String] { let parts = text .split { $0 == "," || $0 == " " || $0 == "\n" || $0 == "\t" } @@ -646,18 +1338,19 @@ struct TicketsView: View { } private var filteredTickets: [TicketSummary] { + let originFiltered = tickets.filter { selectedOriginFilter.matches(ticket: $0) } let base: [TicketSummary] switch selectedFilter { case .all: - base = tickets + base = originFiltered case .open: - base = tickets.filter { $0.status == .open } + base = originFiltered.filter { $0.status == .open } case .inProgress: - base = tickets.filter { $0.status == .inProgress } + base = originFiltered.filter { $0.status == .inProgress } case .blocked: - base = tickets.filter { $0.status == .blocked } + base = originFiltered.filter { $0.status == .blocked } case .done: - base = tickets.filter { $0.status == .done } + base = originFiltered.filter { $0.status == .done } } let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) @@ -730,7 +1423,16 @@ struct TicketsView: View { private func inspectorLinkRows(for ticket: TicketSummary) -> [DetailRowItem] { [ DetailRowItem(label: "ID", value: ticket.ticketId), - DetailRowItem(label: "Status", value: ticket.status.label) + DetailRowItem(label: "Status", value: ticket.status.label), + DetailRowItem(label: "Owner", value: ticket.owner), + DetailRowItem(label: "Source", value: ticket.source), + DetailRowItem(label: "Tags", value: ticket.tags.joined(separator: ", ").nilIfEmpty ?? "—"), + DetailRowItem(label: "Remote", value: ticket.externalSystem ?? "—"), + DetailRowItem(label: "Issue key", value: ticket.externalKey ?? "—"), + DetailRowItem(label: "Issue id", value: ticket.externalId ?? "—"), + DetailRowItem(label: "Linear URL", value: ticket.externalUrl ?? "—"), + DetailRowItem(label: "Project", value: ticket.externalProjectName ?? ticket.externalProjectId ?? "—"), + DetailRowItem(label: "Team", value: ticket.externalTeamId ?? "—") ] } @@ -833,6 +1535,69 @@ struct TicketsView: View { ) } + private func ticketMetaPill(_ text: String, tone: Color) -> some View { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(tone.opacity(colorScheme == .dark ? 0.18 : 0.1)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(tone.opacity(colorScheme == .dark ? 0.34 : 0.2), lineWidth: 1) + ) + .foregroundStyle(tone) + } + + private func ticketHeaderBadge( + title: String, + systemImage: String, + tone: Color + ) -> some View { + HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.system(size: 11, weight: .semibold)) + Text(title) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background { + Capsule(style: .continuous) + .fill(tone.opacity(colorScheme == .dark ? 0.16 : 0.08)) + .overlay( + Capsule(style: .continuous) + .stroke(tone.opacity(colorScheme == .dark ? 0.28 : 0.18), lineWidth: 1) + ) + } + .foregroundStyle(tone) + } + + private func openProjectRouting() { + model.selectedItem = .project(project.id) + model.selectedProjectTab = .overview + NotificationCenter.default.post( + name: .hackProjectNavigationRequested, + object: nil, + userInfo: [ + ProjectNavigationRequest.projectIdKey: project.id, + ProjectNavigationRequest.tabKey: ProjectTab.overview.rawValue, + ProjectNavigationRequest.sidebarKey: "remoteExecution", + ] + ) + } + + private func openLinearSettings() { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.linear.rawValue] + ) + } + private func ticketPanel( contentPadding: CGFloat = 14, @ViewBuilder content: () -> Content @@ -1054,6 +1819,101 @@ private enum TicketFilter: String, CaseIterable { } } +private enum TicketOriginFilter: String, CaseIterable { + case both + case hack + case linear + + var label: String { + switch self { + case .both: + return "All origins" + case .hack: + return "Hack only" + case .linear: + return "Linear only" + } + } + + func matches(ticket: TicketSummary) -> Bool { + let owner = ticket.owner.lowercased() + let source = ticket.source.lowercased() + let externalSystem = ticket.externalSystem?.lowercased() ?? "" + switch self { + case .both: + return true + case .hack: + return owner == "hack" || source == "hack" + case .linear: + return owner == "linear" || source == "linear" || externalSystem == "linear" + } + } + + func count(in tickets: [TicketSummary]) -> Int { + tickets.filter { matches(ticket: $0) }.count + } +} + +private enum TicketBulkSyncAction: String, Identifiable { + case pullLinear + case pushHack + + var id: String { rawValue } + + var dialogTitle: String { + switch self { + case .pullLinear: + return "Pull Linear issues into tickets?" + case .pushHack: + return "Push Hack tickets to Linear?" + } + } + + var confirmLabel: String { + switch self { + case .pullLinear: + return "Pull from Linear" + case .pushHack: + return "Push to Linear" + } + } + + var message: String { + switch self { + case .pullLinear: + return "Use this when Linear is the source of truth for the project. Linear-owned tickets will refresh local authoritative fields, while comments remain append-only." + case .pushHack: + return "Use this when Hack owns the local ticket set for this project. Hack-owned tickets will publish outward, while assignees, labels, and dependencies merge best effort." + } + } +} + +private enum TicketSyncAction: Equatable { + case pullProjectFromLinear + case pushProjectToLinear + case pushTicketToLinear(String) + case pullTicketFromLinear(String) + + var progressLabel: String { + switch self { + case .pullProjectFromLinear: + return "Pulling Linear project…" + case .pushProjectToLinear: + return "Pushing Hack tickets…" + case let .pushTicketToLinear(ticketId): + return "Pushing \(ticketId)…" + case let .pullTicketFromLinear(ticketId): + return "Refreshing \(ticketId)…" + } + } +} + +private struct TicketSyncGuidance { + let title: String + let message: String + let tone: StatusTone +} + private enum TicketDateFormatter { static let isoWithFractional: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 7ff09ce36..c72a2aa55 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -108,6 +108,47 @@ public actor HackCLIClient { let error: String? } + private struct LinearOAuthStartEnvelope: Decodable { + let ok: Bool + let flow: LinearOAuthStartEnvelopeFlow + } + + private struct LinearOAuthStartEnvelopeFlow: Decodable { + let flowId: String + let profileId: String + let setDefault: Bool + let authorizeUrl: String + let deviceCode: String + let pollUrl: String + let expiresAt: String + } + + private struct LinearOAuthStatusEnvelope: Decodable { + let ok: Bool + let status: LinearOAuthStatusEnvelopeStatus + } + + private struct LinearOAuthStatusEnvelopeStatus: Decodable { + let id: String + let status: String + let profileId: String + let setDefault: Bool + let createdAt: String + let expiresAt: String + let completedAt: String? + let claimedAt: String? + let accountHandle: String? + let accountLogin: String? + let accountName: String? + let accountId: String? + let accountEmail: String? + let token: String? + let tokenExpiresAt: String? + let refreshToken: String? + let refreshTokenExpiresAt: String? + let error: String? + } + public init() {} public func fetchProjects(includeGlobal: Bool) async throws -> ProjectListResponse { @@ -401,6 +442,127 @@ public actor HackCLIClient { return try decodeJsonOrThrow(GitHubStatusResponse.self, result: result) } + public func inspectLinearProfiles() async throws -> LinearProfilesResponse { + let result = try await run( + ["x", "linear", "profiles", "--json"], + allowNonZeroExit: true + ) + return try decodeJsonOrThrow(LinearProfilesResponse.self, result: result) + } + + public func inspectLinearStatus(profileId: String? = nil) async throws -> LinearStatusResponse { + var args = ["x", "linear", "status", "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearStatusResponse.self, result: result) + } + + public func disconnectLinear(profileId: String) async throws { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + var args = ["x", "linear", "disconnect"] + if !trimmed.isEmpty { + args.append(contentsOf: ["--profile", trimmed]) + } + _ = try await run(args) + } + + public func listLinearProjects(profileId: String? = nil) async throws -> LinearProjectsResponse { + var args = ["x", "linear", "projects", "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearProjectsResponse.self, result: result) + } + + public func bindLinearProject( + path: String, + profileId: String?, + projectId: String?, + projectName: String?, + teamId: String?, + clear: Bool + ) async throws -> LinearProjectBindingResponse { + var args = ["x", "linear", "project-bind", "--json"] + if clear { + args.append("--clear") + } + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-id", projectId]) + } + if let projectName, !projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-name", projectName]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + let result = try await run(args, allowNonZeroExit: true, cwd: path) + return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) + } + + public func syncLinearProject( + path: String, + from direction: String, + ownerMode: String? = nil, + projectId: String? = nil, + teamId: String? = nil, + limit: Int? = nil, + syncLabels: Bool? = nil + ) async throws -> LinearProjectSyncResponse { + var args = ["x", "linear", "sync-project", "--from", direction, "--json"] + if let ownerMode, !ownerMode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--owner", ownerMode]) + } + if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-id", projectId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + if let limit { + args.append(contentsOf: ["--limit", String(limit)]) + } + if syncLabels == true { + args.append("--sync-labels") + } + let result = try await run(args, allowNonZeroExit: true, cwd: path) + return try decodeJsonOrThrow(LinearProjectSyncResponse.self, result: result) + } + + public func syncLinearIssue( + path: String, + from direction: String, + issueIdentifier: String? = nil, + ticketId: String? = nil, + projectId: String? = nil, + teamId: String? = nil, + syncLabels: Bool? = nil + ) async throws -> LinearIssueSyncResponse { + var args = ["x", "linear", "sync-issue", "--from", direction, "--json"] + if let issueIdentifier, !issueIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--issue", issueIdentifier]) + } + if let ticketId, !ticketId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--ticket", ticketId]) + } + if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-id", projectId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + if syncLabels == true { + args.append("--sync-labels") + } + let result = try await run(args, allowNonZeroExit: true, cwd: path) + return try decodeJsonOrThrow(LinearIssueSyncResponse.self, result: result) + } + /// Resolves the effective system Git identity (global or repo-effective) and optional GitHub CLI account. /// /// This is read-only host identity state and is intentionally separate from remote OAuth/App profile routing. @@ -536,6 +698,104 @@ public actor HackCLIClient { throw HackCLIError.network("Auth server returned invalid JSON.") } + /// Starts cloud Linear OAuth with the dedicated auth broker surface. + public func startLinearOAuthFlow( + profileId: String, + setDefault: Bool + ) async throws -> LinearOAuthFlowStartResponse { + var lastError: String? = nil + for candidate in resolveAuthServerCandidates() { + guard + let startURL = buildAuthURL( + base: candidate, + path: "/v1/auth/linear/start", + queryItems: [ + URLQueryItem(name: "profile", value: profileId), + URLQueryItem(name: "setDefault", value: setDefault ? "1" : "0"), + URLQueryItem(name: "set_default", value: setDefault ? "1" : "0"), + ] + ) + else { + continue + } + do { + let body = try await fetchAuthBody(url: startURL) + if let direct = tryDecodeLenient(LinearOAuthFlowStartResponse.self, from: body) { + return direct + } + if let wrapped = tryDecodeLenient(LinearOAuthStartEnvelope.self, from: body), wrapped.ok { + guard + let statusURL = buildAuthURLWithQuery( + urlString: wrapped.flow.pollUrl, + queryItems: [ + URLQueryItem(name: "deviceCode", value: wrapped.flow.deviceCode), + URLQueryItem(name: "claim", value: "1"), + ] + )?.absoluteString + else { + throw HackCLIError.network("Auth flow returned an invalid status URL.") + } + return LinearOAuthFlowStartResponse( + ok: true, + flowId: wrapped.flow.flowId, + profileId: wrapped.flow.profileId, + setDefault: wrapped.flow.setDefault, + authorizeUrl: wrapped.flow.authorizeUrl, + statusUrl: statusURL, + expiresAt: wrapped.flow.expiresAt + ) + } + throw HackCLIError.network("Auth server returned invalid JSON.") + } catch { + lastError = error.localizedDescription + } + } + + throw HackCLIError.network( + lastError + ?? "Unable to reach any configured auth broker endpoint. Check network/broker status and retry." + ) + } + + /// Polls Linear broker OAuth state and imports claimed tokens into local keychain-backed profiles. + public func fetchLinearOAuthFlowStatus( + statusURL: String + ) async throws -> LinearOAuthFlowStatusResponse { + guard let url = URL(string: statusURL) else { + throw HackCLIError.network("Invalid auth flow status URL.") + } + let body = try await fetchAuthBody(url: url) + if let direct = tryDecodeLenient(LinearOAuthFlowStatusResponse.self, from: body) { + return direct + } + if let wrapped = tryDecodeLenient(LinearOAuthStatusEnvelope.self, from: body), wrapped.ok { + if let token = normalized(wrapped.status.token) { + do { + try await persistLinearTokenFromBrokerFlow( + profileId: wrapped.status.profileId, + token: token, + tokenExpiresAt: wrapped.status.tokenExpiresAt, + refreshToken: wrapped.status.refreshToken, + refreshTokenExpiresAt: wrapped.status.refreshTokenExpiresAt, + setDefault: wrapped.status.setDefault + ) + } catch { + throw HackCLIError.network( + "Linear OAuth callback succeeded, but Hack could not save the token locally (\(error.localizedDescription)). Retry Add account and allow keychain access." + ) + } + } else if wrapped.status.status == "claimed" { + let profileId = wrapped.status.profileId + let localStatus = try await inspectLinearStatus(profileId: profileId) + if !localStatus.tokenResolved { + return brokerClaimedWithoutLocalLinearTokenStatus(wrapped.status) + } + } + return normalizeLinearBrokerFlowStatus(wrapped.status) + } + throw HackCLIError.network("Auth server returned invalid JSON.") + } + public func bootstrapRailwayNode( request: RailwayBootstrapRequest ) async throws -> RailwayBootstrapResponse { @@ -1087,6 +1347,56 @@ public actor HackCLIClient { ) } + private func normalizeLinearBrokerFlowStatus( + _ wrapped: LinearOAuthStatusEnvelopeStatus + ) -> LinearOAuthFlowStatusResponse { + let normalizedStatus: String + switch wrapped.status { + case "claimed": + normalizedStatus = "complete" + default: + normalizedStatus = wrapped.status + } + return LinearOAuthFlowStatusResponse( + id: wrapped.id, + status: normalizedStatus, + profileId: wrapped.profileId, + setDefault: wrapped.setDefault, + createdAt: wrapped.createdAt, + expiresAt: wrapped.expiresAt, + completedAt: wrapped.completedAt ?? wrapped.claimedAt, + accountHandle: wrapped.accountHandle, + accountLogin: wrapped.accountLogin, + accountName: wrapped.accountName, + accountId: wrapped.accountId, + accountEmail: wrapped.accountEmail, + tokenExpiresAt: wrapped.tokenExpiresAt, + error: wrapped.error + ) + } + + private func brokerClaimedWithoutLocalLinearTokenStatus( + _ wrapped: LinearOAuthStatusEnvelopeStatus + ) -> LinearOAuthFlowStatusResponse { + return LinearOAuthFlowStatusResponse( + id: wrapped.id, + status: "error", + profileId: wrapped.profileId, + setDefault: wrapped.setDefault, + createdAt: wrapped.createdAt, + expiresAt: wrapped.expiresAt, + completedAt: wrapped.completedAt ?? wrapped.claimedAt, + accountHandle: wrapped.accountHandle, + accountLogin: wrapped.accountLogin, + accountName: wrapped.accountName, + accountId: wrapped.accountId, + accountEmail: wrapped.accountEmail, + tokenExpiresAt: wrapped.tokenExpiresAt, + error: + "OAuth token was claimed remotely but is not available in local profile \(wrapped.profileId). Re-run Add account and allow keychain access." + ) + } + /// Persist a broker-issued GitHub token into local keychain-backed profile storage. private func persistGitHubTokenFromBrokerFlow( profileId: String, @@ -1125,6 +1435,40 @@ public actor HackCLIClient { _ = try await run(args, stdin: "\(token)\n") } + /// Persist a broker-issued Linear token into local keychain-backed profile storage. + private func persistLinearTokenFromBrokerFlow( + profileId: String, + token: String, + tokenExpiresAt: String?, + refreshToken: String?, + refreshTokenExpiresAt: String?, + setDefault: Bool + ) async throws { + var args = ["x", "linear", "connect", "--profile", profileId, "--stdin"] + if setDefault { + args.append("--set-default") + } + let envelope = LinearTokenPersistEnvelope( + token: token, + expiresAt: normalized(tokenExpiresAt), + refreshToken: normalized(refreshToken), + refreshTokenExpiresAt: normalized(refreshTokenExpiresAt) + ) + let encoder = JSONEncoder() + let payload = try encoder.encode(envelope) + guard let text = String(data: payload, encoding: .utf8) else { + throw HackCLIError.invalidJson + } + _ = try await run(args, stdin: "\(text)\n") + } + + private struct LinearTokenPersistEnvelope: Encodable { + let token: String + let expiresAt: String? + let refreshToken: String? + let refreshTokenExpiresAt: String? + } + private func fetchAuthBody(url: URL) async throws -> String { var request = URLRequest(url: url) request.timeoutInterval = Self.authRequestTimeoutSeconds diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 50da9db0b..92e26f85a 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -1176,6 +1176,293 @@ public struct GitHubOAuthFlowStatusResponse: Decodable, Hashable { } } +public struct LinearProfileSummary: Decodable, Hashable { + public let id: String + public let isDefault: Bool + public let authRef: String + public let service: String + public let tokenEnv: String + public let apiUrl: String + public let accountId: String? + public let accountName: String? + public let accountEmail: String? + + public init( + id: String, + isDefault: Bool, + authRef: String, + service: String, + tokenEnv: String, + apiUrl: String, + accountId: String?, + accountName: String?, + accountEmail: String? + ) { + self.id = id + self.isDefault = isDefault + self.authRef = authRef + self.service = service + self.tokenEnv = tokenEnv + self.apiUrl = apiUrl + self.accountId = accountId + self.accountName = accountName + self.accountEmail = accountEmail + } +} + +public struct LinearProfilesResponse: Decodable, Hashable { + public let selectedProfile: String + public let selectedSource: String + public let defaultProfile: String + public let projectOverride: String? + public let selectedMissing: Bool + public let profiles: [LinearProfileSummary] + + public init( + selectedProfile: String, + selectedSource: String, + defaultProfile: String, + projectOverride: String?, + selectedMissing: Bool, + profiles: [LinearProfileSummary] + ) { + self.selectedProfile = selectedProfile + self.selectedSource = selectedSource + self.defaultProfile = defaultProfile + self.projectOverride = projectOverride + self.selectedMissing = selectedMissing + self.profiles = profiles + } +} + +public struct LinearStatusResponse: Decodable, Hashable { + public let extensionId: String + public let selectedProfile: String + public let selectedSource: String + public let defaultProfile: String + public let selectedMissing: Bool + public let authRef: String + public let service: String + public let tokenEnvFallback: String + public let apiUrl: String + public let accountId: String? + public let accountName: String? + public let accountEmail: String? + public let tokenResolved: Bool + public let tokenSource: String? + public let tokenExpiresAt: String? + public let error: String? + public let profileError: String? + + public init( + extensionId: String, + selectedProfile: String, + selectedSource: String, + defaultProfile: String, + selectedMissing: Bool, + authRef: String, + service: String, + tokenEnvFallback: String, + apiUrl: String, + accountId: String?, + accountName: String?, + accountEmail: String?, + tokenResolved: Bool, + tokenSource: String?, + tokenExpiresAt: String?, + error: String?, + profileError: String? + ) { + self.extensionId = extensionId + self.selectedProfile = selectedProfile + self.selectedSource = selectedSource + self.defaultProfile = defaultProfile + self.selectedMissing = selectedMissing + self.authRef = authRef + self.service = service + self.tokenEnvFallback = tokenEnvFallback + self.apiUrl = apiUrl + self.accountId = accountId + self.accountName = accountName + self.accountEmail = accountEmail + self.tokenResolved = tokenResolved + self.tokenSource = tokenSource + self.tokenExpiresAt = tokenExpiresAt + self.error = error + self.profileError = profileError + } +} + +public struct LinearOAuthFlowStartResponse: Decodable, Hashable { + public let ok: Bool + public let flowId: String + public let profileId: String + public let setDefault: Bool + public let authorizeUrl: String + public let statusUrl: String + public let expiresAt: String + + public init( + ok: Bool, + flowId: String, + profileId: String, + setDefault: Bool, + authorizeUrl: String, + statusUrl: String, + expiresAt: String + ) { + self.ok = ok + self.flowId = flowId + self.profileId = profileId + self.setDefault = setDefault + self.authorizeUrl = authorizeUrl + self.statusUrl = statusUrl + self.expiresAt = expiresAt + } +} + +public struct LinearOAuthFlowStatusResponse: Decodable, Hashable { + public let id: String + public let status: String + public let profileId: String + public let setDefault: Bool + public let createdAt: String + public let expiresAt: String + public let completedAt: String? + public let accountHandle: String? + public let accountLogin: String? + public let accountName: String? + public let accountId: String? + public let accountEmail: String? + public let tokenExpiresAt: String? + public let error: String? + + public init( + id: String, + status: String, + profileId: String, + setDefault: Bool, + createdAt: String, + expiresAt: String, + completedAt: String?, + accountHandle: String?, + accountLogin: String?, + accountName: String?, + accountId: String?, + accountEmail: String?, + tokenExpiresAt: String?, + error: String? + ) { + self.id = id + self.status = status + self.profileId = profileId + self.setDefault = setDefault + self.createdAt = createdAt + self.expiresAt = expiresAt + self.completedAt = completedAt + self.accountHandle = accountHandle + self.accountLogin = accountLogin + self.accountName = accountName + self.accountId = accountId + self.accountEmail = accountEmail + self.tokenExpiresAt = tokenExpiresAt + self.error = error + } +} + +public struct LinearProjectSummary: Decodable, Hashable, Identifiable { + public let id: String + public let name: String + public let teamId: String + public let teamKey: String? + public let teamName: String? + + public init( + id: String, + name: String, + teamId: String, + teamKey: String?, + teamName: String? + ) { + self.id = id + self.name = name + self.teamId = teamId + self.teamKey = teamKey + self.teamName = teamName + } +} + +public struct LinearProjectsResponse: Decodable, Hashable { + public let profile: String + public let projects: [LinearProjectSummary] + + public init(profile: String, projects: [LinearProjectSummary]) { + self.profile = profile + self.projects = projects + } +} + +public struct LinearProjectBindingResponse: Decodable, Hashable { + public let ok: Bool + public let cleared: Bool? + public let profileId: String? + public let projectId: String? + public let projectName: String? + public let teamId: String? + + public init( + ok: Bool, + cleared: Bool?, + profileId: String?, + projectId: String?, + projectName: String?, + teamId: String? + ) { + self.ok = ok + self.cleared = cleared + self.profileId = profileId + self.projectId = projectId + self.projectName = projectName + self.teamId = teamId + } +} + +public struct LinearIssueSyncResponse: Decodable, Hashable { + public let ok: Bool + public let operation: String + public let ticketId: String + public let issueIdentifier: String + public let issueId: String? + + public init( + ok: Bool, + operation: String, + ticketId: String, + issueIdentifier: String, + issueId: String? + ) { + self.ok = ok + self.operation = operation + self.ticketId = ticketId + self.issueIdentifier = issueIdentifier + self.issueId = issueId + } +} + +public struct LinearProjectSyncResponse: Decodable, Hashable { + public let ok: Bool + public let processed: Int + public let created: Int + public let updated: Int + + public init(ok: Bool, processed: Int, created: Int, updated: Int) { + self.ok = ok + self.processed = processed + self.created = created + self.updated = updated + } +} + public struct RailwayBootstrapRequest: Hashable { public let railwayProject: String? public let railwayService: String? @@ -1550,6 +1837,16 @@ public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { public let updatedAt: String public let dependsOn: [String] public let blocks: [String] + public let owner: String + public let source: String + public let tags: [String] + public let externalSystem: String? + public let externalId: String? + public let externalKey: String? + public let externalUrl: String? + public let externalProjectId: String? + public let externalProjectName: String? + public let externalTeamId: String? public let projectId: String? public let projectName: String? @@ -1564,6 +1861,16 @@ public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { updatedAt: String, dependsOn: [String], blocks: [String], + owner: String, + source: String, + tags: [String], + externalSystem: String?, + externalId: String?, + externalKey: String?, + externalUrl: String?, + externalProjectId: String?, + externalProjectName: String?, + externalTeamId: String?, projectId: String?, projectName: String? ) { @@ -1575,9 +1882,182 @@ public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { self.updatedAt = updatedAt self.dependsOn = dependsOn self.blocks = blocks + self.owner = owner + self.source = source + self.tags = tags + self.externalSystem = externalSystem + self.externalId = externalId + self.externalKey = externalKey + self.externalUrl = externalUrl + self.externalProjectId = externalProjectId + self.externalProjectName = externalProjectName + self.externalTeamId = externalTeamId self.projectId = projectId self.projectName = projectName } + + private enum CodingKeys: String, CodingKey { + case ticketId + case title + case body + case status + case createdAt + case updatedAt + case dependsOn + case blocks + case owner + case source + case tags + case externalSystem + case externalId + case externalKey + case externalUrl + case externalProjectId + case externalProjectName + case externalTeamId + case projectId + case projectName + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + ticketId = try container.decode(String.self, forKey: .ticketId) + title = try container.decode(String.self, forKey: .title) + body = try container.decodeIfPresent(String.self, forKey: .body) + status = try container.decode(TicketStatus.self, forKey: .status) + createdAt = try container.decode(String.self, forKey: .createdAt) + updatedAt = try container.decode(String.self, forKey: .updatedAt) + dependsOn = try container.decodeIfPresent([String].self, forKey: .dependsOn) ?? [] + blocks = try container.decodeIfPresent([String].self, forKey: .blocks) ?? [] + owner = try container.decodeIfPresent(String.self, forKey: .owner) ?? "hack" + source = try container.decodeIfPresent(String.self, forKey: .source) ?? "hack" + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + externalSystem = try container.decodeIfPresent(String.self, forKey: .externalSystem) + externalId = try container.decodeIfPresent(String.self, forKey: .externalId) + externalKey = try container.decodeIfPresent(String.self, forKey: .externalKey) + externalUrl = try container.decodeIfPresent(String.self, forKey: .externalUrl) + externalProjectId = try container.decodeIfPresent(String.self, forKey: .externalProjectId) + externalProjectName = try container.decodeIfPresent(String.self, forKey: .externalProjectName) + externalTeamId = try container.decodeIfPresent(String.self, forKey: .externalTeamId) + projectId = try container.decodeIfPresent(String.self, forKey: .projectId) + projectName = try container.decodeIfPresent(String.self, forKey: .projectName) + } +} + +/// Describes which system currently owns authoritative Linear-sync fields for a ticket. +public enum LinearSyncAuthority: String, Hashable { + case hack + case linear + + public var label: String { + switch self { + case .hack: + "Hack" + case .linear: + "Linear" + } + } +} + +/// Pure, metadata-derived sync hints for UI surfaces that need to explain Linear ticket behavior. +public struct LinearSyncUXState: Hashable { + public let authority: LinearSyncAuthority + public let isLinkedToLinear: Bool + public let shortGuidance: String + public let reviewHint: String? + + public init(ticket: TicketSummary) { + let authority = Self.resolveAuthority(source: ticket.source) + let isLinkedToLinear = Self.isLinkedToLinear(ticket: ticket) + self.authority = authority + self.isLinkedToLinear = isLinkedToLinear + shortGuidance = Self.makeShortGuidance( + authority: authority, + isLinkedToLinear: isLinkedToLinear + ) + reviewHint = Self.makeReviewHint( + source: ticket.source, + owner: ticket.owner, + isLinkedToLinear: isLinkedToLinear + ) + } + + private static func resolveAuthority(source: String) -> LinearSyncAuthority { + switch LinearSyncActor(rawValue: source.normalizedSyncActor) ?? .unknown { + case .linear: + .linear + case .hack, .unknown: + .hack + } + } + + private static func isLinkedToLinear(ticket: TicketSummary) -> Bool { + if ticket.source.normalizedSyncActor == LinearSyncActor.linear.rawValue { + return true + } + if ticket.owner.normalizedSyncActor == LinearSyncActor.linear.rawValue { + return true + } + if ticket.externalSystem?.normalizedSyncActor == LinearSyncActor.linear.rawValue { + return true + } + if ticket.externalId?.isEmpty == false { + return true + } + if ticket.externalKey?.isEmpty == false { + return true + } + return false + } + + private static func makeShortGuidance( + authority: LinearSyncAuthority, + isLinkedToLinear: Bool + ) -> String { + guard isLinkedToLinear else { + return "Local only. Connect to Linear to sync this ticket." + } + return "\(authority.label) controls title, body, status, and project. Comments append only. Assignee, labels, and dependencies sync best effort." + } + + private static func makeReviewHint( + source: String, + owner: String, + isLinkedToLinear: Bool + ) -> String? { + guard isLinkedToLinear else { + return nil + } + let normalizedSource = LinearSyncActor(rawValue: source.normalizedSyncActor) ?? .unknown + let normalizedOwner = LinearSyncActor(rawValue: owner.normalizedSyncActor) ?? .unknown + guard normalizedSource == .unknown || normalizedOwner == .unknown || normalizedSource != normalizedOwner + else { + return nil + } + return "Review assignee, labels, and dependencies before the next sync." + } +} + +public extension TicketSummary { + var linearSyncAuthority: LinearSyncAuthority { + linearSyncUXState.authority + } + + var linearSyncUXState: LinearSyncUXState { + LinearSyncUXState(ticket: self) + } +} + +private enum LinearSyncActor: String { + case hack + case linear + case unknown +} + +private extension String { + var normalizedSyncActor: String { + trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } } public struct TicketEvent: Decodable, Identifiable, Hashable { diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift new file mode 100644 index 000000000..e956cf817 --- /dev/null +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift @@ -0,0 +1,102 @@ +import XCTest + +@testable import HackDesktopModels + +final class LinearSyncUXModelTests: XCTestCase { + func testHackOriginLinkedTicketIsHackAuthoritative() { + let ticket = makeTicketSummary( + owner: "hack", + source: "hack", + externalSystem: "linear", + externalId: "lin_issue_1", + externalKey: "ENG-101" + ) + + XCTAssertEqual(ticket.linearSyncAuthority, .hack) + XCTAssertTrue(ticket.linearSyncUXState.isLinkedToLinear) + XCTAssertNil(ticket.linearSyncUXState.reviewHint) + XCTAssertTrue(ticket.linearSyncUXState.shortGuidance.contains("Hack controls title, body, status, and project.")) + } + + func testLinearOriginTicketIsLinearAuthoritative() { + let ticket = makeTicketSummary( + owner: "linear", + source: "linear", + externalSystem: "linear", + externalId: "lin_issue_2", + externalKey: "ENG-102" + ) + + XCTAssertEqual(ticket.linearSyncAuthority, .linear) + XCTAssertTrue(ticket.linearSyncUXState.isLinkedToLinear) + XCTAssertNil(ticket.linearSyncUXState.reviewHint) + XCTAssertTrue(ticket.linearSyncUXState.shortGuidance.contains("Linear controls title, body, status, and project.")) + } + + func testLinkedLinearTicketWithMismatchedOwnershipNeedsReviewHint() { + let ticket = makeTicketSummary( + owner: "linear", + source: "hack", + externalSystem: "linear", + externalId: "lin_issue_3", + externalKey: "ENG-103" + ) + + XCTAssertEqual(ticket.linearSyncAuthority, .hack) + XCTAssertTrue(ticket.linearSyncUXState.isLinkedToLinear) + XCTAssertEqual( + ticket.linearSyncUXState.reviewHint, + "Review assignee, labels, and dependencies before the next sync." + ) + XCTAssertTrue(ticket.linearSyncUXState.shortGuidance.contains("Comments append only.")) + } + + func testUnlinkedLocalTicketUsesLocalOnlyGuidance() { + let ticket = makeTicketSummary( + owner: "hack", + source: "hack", + externalSystem: nil, + externalId: nil, + externalKey: nil + ) + + XCTAssertEqual(ticket.linearSyncAuthority, .hack) + XCTAssertFalse(ticket.linearSyncUXState.isLinkedToLinear) + XCTAssertNil(ticket.linearSyncUXState.reviewHint) + XCTAssertEqual( + ticket.linearSyncUXState.shortGuidance, + "Local only. Connect to Linear to sync this ticket." + ) + } + + private func makeTicketSummary( + owner: String, + source: String, + externalSystem: String?, + externalId: String?, + externalKey: String? + ) -> TicketSummary { + TicketSummary( + ticketId: "T-00001", + title: "Sync issue", + body: "Sync issue body", + status: .open, + createdAt: "2026-03-05T12:00:00Z", + updatedAt: "2026-03-05T12:00:00Z", + dependsOn: [], + blocks: [], + owner: owner, + source: source, + tags: ["linear"], + externalSystem: externalSystem, + externalId: externalId, + externalKey: externalKey, + externalUrl: "https://linear.app/hack/issue/\(externalKey ?? "local")", + externalProjectId: "proj-1", + externalProjectName: "Alpha", + externalTeamId: "team-1", + projectId: "local-proj-1", + projectName: "Hack CLI" + ) + } +} diff --git a/bun.lock b/bun.lock index bd12da03e..000487f9a 100644 --- a/bun.lock +++ b/bun.lock @@ -40,11 +40,11 @@ }, "packages/cli": { "name": "@hack/cli", - "version": "1.12.0", + "version": "1.17.0", }, "packages/db": { "name": "@hack/db", - "version": "1.12.0", + "version": "1.17.0", "dependencies": { "@neondatabase/serverless": "^1.0.1", "drizzle-orm": "^0.44.5", @@ -58,8 +58,9 @@ }, "services/auth-broker": { "name": "@hack/auth-broker", - "version": "1.12.0", + "version": "1.17.0", "dependencies": { + "@better-auth/drizzle-adapter": "^1.3.12", "@neondatabase/serverless": "^1.0.2", "better-auth": "^1.3.12", "drizzle-orm": "^0.44.5", @@ -86,6 +87,8 @@ "@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.3", "", { "peerDependencies": { "@better-auth/core": "1.5.3", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" } }, "sha512-dib9V1vpwDu+TKLC+L+8Q5bLNS0uE3JCT4pGotw52pnpiQF8msoMK4eEfri19f8DtNltpb2F2yzyIsTugBBYNQ=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="], "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], @@ -1338,7 +1341,7 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@hack/auth-broker/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@hack/auth-broker/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@hack/db/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -1874,7 +1877,7 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@hack/auth-broker/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "@hack/auth-broker/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "@hack/db/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], diff --git a/docs/cli.md b/docs/cli.md index 8573e610d..d46d6c85c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,6 +28,7 @@ Run `hack help` or `hack help ` for interactive help. | `hack open` | Open a URL for the project (default: https://.hack) | Project | | `hack tui` | Open the project TUI (services + logs) | Project | | `hack branch` | Manage branch aliases for a project | Project | +| `hack linear` | Linear account connection and ticket sync | Project | | `hack config` | Read/write hack.config.json values | Project | | `hack env` | Manage project environment variables and secrets | Project | | `hack session` | Manage terminal sessions for hack projects | Project | @@ -648,6 +649,13 @@ Usage: `hack tickets ` `hack tickets` is a convenience alias for the tickets extension (`hack x tickets ...`). Run `hack tickets` with no args to see available subcommands. +### hack linear + +Usage: `hack linear ` + +`hack linear` is a convenience alias for the Linear extension (`hack x linear ...`). +Run `hack linear` with no args to see available subcommands. + ## Internal commands ### hack internal diff --git a/docs/env.md b/docs/env.md index 99a19270c..3314d7971 100644 --- a/docs/env.md +++ b/docs/env.md @@ -61,6 +61,8 @@ Fields: Notes: - `hack env set` also supports interactive prompting when `KEY` or `VALUE` is omitted. - Keychain mode uses service name `hack-` (project name from `.hack/hack.config.json`). +- Encrypted-file mode uses `HACK_SECRETS_FILE_KEY` (if set) before keychain key lookup (`hack-secrets-backend/encrypted-file-key`). +- If you keep getting macOS keychain prompts in encrypted-file mode, set `HACK_SECRETS_FILE_KEY` in your shell/`.env.local` and restart `hackd`. - `cloud` mode currently uses provider-scoped shim storage to validate adapter contracts before provider-native transports land. ## Backend Strategy Contract (`controlPlane.secrets`) diff --git a/docs/extensions.md b/docs/extensions.md index f39866dc4..b4c8b0a81 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -104,6 +104,7 @@ Use `hack x help` to list commands. - Tailscale: `hack x tailscale setup|status|inspect|oauth-status|oauth-connect|oauth-disconnect|ip` - Tickets: `hack x tickets setup|create|update|list|show|status|sync|tui` (see `docs/guides/tickets.md`) - GitHub: `hack x github connect|oauth-connect|disconnect|profiles|use|status|pr-upsert` +- Linear: `hack x linear setup|connect|oauth-connect|disconnect|status|profiles|use|projects|project-bind|sync-issue|sync-project` Gateway tokens default to `read` scope. Use `--scope write` to permit non-GET requests (also requires global `controlPlane.gateway.allowWrites = true`). @@ -272,6 +273,57 @@ Notes: - macOS desktop shows read-only system Git identity (host-level inherited Git/gh context) separately from remote OAuth/App accounts to keep local gateway identity and remote auth routing boundaries explicit. - `auth.hack` and `auth.hack.gy` are routed by global Caddy to the daemon auth listener (`127.0.0.1:7790` by default), so this endpoint can also host future provider callbacks/hooks. +### Linear extension (`hack x linear`) + +Linear integration supports multiple auth profiles, project-level binding, and manual sync in both +directions between Linear issues and hack tickets. + +Quick start: + +```bash +# connect a Linear account/profile +hack x linear oauth-connect --profile work --set-default +# or, default one-shot behavior (falls back to OAuth when no token is present): +hack x linear connect --profile work --set-default + +# bind this hack project to a default Linear project +hack x linear project-bind --profile work --project-id "" + +# one-off syncs (manual by default) +hack x linear sync-issue --from linear --issue ENG-123 +hack x linear sync-issue --from hack --ticket T-00001 + +# bulk sync by project (still manual) +hack x linear sync-project --from linear +hack x linear sync-project --from hack --owner both +``` + +Notes: +- Profile selection precedence is: + `--profile` -> `controlPlane.routing.overrides.linear.profile` -> + `controlPlane.extensions["dance.hack.linear"].config.defaultProfile` -> implicit `default`. +- Project mapping is stored in project config: + `controlPlane.routing.overrides.linear.projectId|projectName|teamId`. +- Sync is manual by default (`sync-issue` / `sync-project`); there is no background autosync yet. +- Ticket provenance is preserved during sync via `owner`, `source`, `tags`, and `external*` linkage fields. +- `hack x linear connect` uses token mode when `--token`/`--stdin`/`$HACK_LINEAR_API_TOKEN` is present, and otherwise falls back to browser OAuth. +- Linear OAuth uses PKCE; client secret is optional. A client id is still required (`--client-id`, `controlPlane.extensions["dance.hack.linear"].config.oauthClientId`, `HACK_LINEAR_OAUTH_CLIENT_ID`, or `HACK_LINEAR_CLIENT_ID`). +- Auth-broker + Linear Agent setup can use these env aliases directly: + - `HACK_LINEAR_CLIENT_ID` (OAuth client id) + - `HACK_LINEAR_SECRET` (optional OAuth client secret) + - `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional developer app token) + - `HACK_LINEAR_WEBHOOK_SECRET` (webhook signature secret) +- Auth-broker Linear endpoints: + - OAuth callback page: `/linear/callback` + - Webhook ingest: `/linear/webhooks` (legacy alias: `/v1/integrations/linear/webhook`) +- Sync toggles are configurable at + `controlPlane.extensions["dance.hack.linear"].config.sync`: + - `labels` (default `false`) + - `statuses` (default `true`) + - `dependencies` (default `true`) + - `projects` (default `true`) +- Architecture + rollout guide: `docs/guides/linear-integration-architecture.md`. + #### job-show Usage: `hack x supervisor job-show [options]` @@ -460,15 +512,15 @@ Usage: `hack x tickets setup [--global] [--agents|--claude|--all] [--check|--rem #### create -Usage: `hack x tickets create --title "..." [--body "..."] [--body-file ] [--body-stdin] [--depends-on "T-00001"] [--blocks "T-00002"] [--actor "..."] [--json]` +Usage: `hack x tickets create --title "..." [--body "..."] [--body-file ] [--body-stdin] [--depends-on "T-00001"] [--blocks "T-00002"] [--owner "hack|linear|..."] [--source "hack|linear|..."] [--tag "foo"] [--external-system "linear"] [--external-id ""] [--external-key "ENG-123"] [--external-url ""] [--external-project-id ""] [--external-project-name "..."] [--external-team-id ""] [--actor "..."] [--json]` #### update -Usage: `hack x tickets update [--title "..."] [--body "..."] [--body-file ] [--body-stdin] [--depends-on "T-00001"] [--blocks "T-00002"] [--clear-depends-on] [--clear-blocks] [--actor "..."] [--json]` +Usage: `hack x tickets update [--title "..."] [--body "..."] [--body-file ] [--body-stdin] [--depends-on "T-00001"] [--blocks "T-00002"] [--clear-depends-on] [--clear-blocks] [--owner "..."] [--source "..."] [--tag "foo"] [--clear-tags] [--external-system "linear"] [--external-id ""] [--external-key "ENG-123"] [--external-url ""] [--external-project-id ""] [--external-project-name "..."] [--external-team-id ""] [--actor "..."] [--json]` #### list -Usage: `hack x tickets list [--json]` +Usage: `hack x tickets list [--owner "..."] [--source "..."] [--external-system "..."] [--json]` #### tui diff --git a/docs/guides/linear-integration-architecture.md b/docs/guides/linear-integration-architecture.md new file mode 100644 index 000000000..02f3db9bd --- /dev/null +++ b/docs/guides/linear-integration-architecture.md @@ -0,0 +1,71 @@ +# Linear Integration Architecture (Manual-First) + +This architecture keeps hack tickets lightweight while supporting bidirectional sync with Linear. + +## Goals + +1. Support multiple Linear accounts/profiles. +2. Bind each hack project to a default Linear profile + project/team. +3. Keep sync manual by default (`sync-issue`, `sync-project`) before enabling autosync. +4. Preserve source-of-truth metadata (`owner`, `source`, `external*`, `tags`) on every synced ticket. +5. Support dependency translation (Linear parent/sub-issue -> hack `dependsOn`). + +## Surfaces + +1. CLI: + - `hack linear connect|oauth-connect|profiles|use` + - `hack linear project-bind` + - `hack linear sync-issue --from linear|hack` + - `hack linear sync-project --from linear|hack --owner hack|linear|both` +2. Mac app: + - Uses the same extension commands and profile/project routing config. +3. Auth broker (`services/auth-broker`): + - OAuth callback page: `/linear/callback` + - Webhook ingest: `/linear/webhooks` (legacy alias: `/v1/integrations/linear/webhook`) + - Provider discovery includes Linear OAuth + webhook metadata. + +## Ticket Ownership + Provenance + +Synced tickets always carry explicit ownership and lineage metadata: + +1. `owner`: `hack` | `linear` (or both mode for selection during bulk sync). +2. `source`: original system (`hack` or `linear`). +3. `tags`: optional label/category parity (when label sync enabled). +4. `externalSystem`, `externalId`, `externalKey`, `externalUrl`, `externalProjectId`, `externalProjectName`, `externalTeamId`. + +This guarantees filtering by local-only, Linear-only, or mixed sets without ambiguous state. + +## Sync Mapping + +1. Status: + - Linear `completed`/`canceled` -> hack `done` + - Linear `started` -> hack `in_progress` + - Linear `unstarted` -> hack `open` +2. Dependencies: + - Linear parent/sub-issue links map to hack `dependsOn` when dependency sync is enabled. +3. Labels: + - Optional (`sync.labels=false` by default) to keep tickets lightweight until needed. + +## Config + +### CLI / extension + +- `controlPlane.extensions["dance.hack.linear"].config.defaultProfile` +- `controlPlane.extensions["dance.hack.linear"].config.profiles` +- `controlPlane.routing.overrides.linear.profile|projectId|projectName|teamId` +- `controlPlane.extensions["dance.hack.linear"].config.sync.labels|statuses|dependencies|projects` + +### Auth broker / Railway env + +- `HACK_LINEAR_CLIENT_ID` +- `HACK_LINEAR_SECRET` (optional with PKCE) +- `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional, for agent/system automation) +- `HACK_LINEAR_WEBHOOK_SECRET` +- `HACK_LINEAR_REDIRECT_URI` (default `/linear/callback`) +- `HACK_LINEAR_WEBHOOK_PATH` (default `/linear/webhooks`) + +## Rollout Plan + +1. Phase 1 (current): manual sync only, explicit one-off operations. +2. Phase 2: webhook-driven candidate updates (still gated/manual apply). +3. Phase 3: selective autosync per project/profile with conflict policies. diff --git a/docs/plans/2026-03-05-linear-sync-ux-design.md b/docs/plans/2026-03-05-linear-sync-ux-design.md new file mode 100644 index 000000000..5ca937b0d --- /dev/null +++ b/docs/plans/2026-03-05-linear-sync-ux-design.md @@ -0,0 +1,45 @@ +# Linear Sync UX Design + +## Goal +Add a macOS UX pass for Linear sync that makes authority, merge behavior, and review expectations obvious without changing the underlying git-backed ticket store. + +## Decisions +- Tickets remain git-backed JSON/event-log state. +- Sync ownership is origin-derived: `hack` or `linear`. +- Comments are append-only, immutable, FIFO. +- Authoritative fields: title, body/description, status, project binding. +- Mergeable fields: assignee, labels, dependencies/sub-issues. +- Conflicts are only surfaced when both sides changed an authoritative field since last sync. +- Mergeable-field ambiguity should be shown as review-needed state, not silently overwritten. +- Best-effort assignee sync is allowed, but ambiguous identity matches must not auto-apply. + +## UX Direction +- Reuse existing app patterns: + - global status strip for broad command progress + - inline callouts for screen-specific sync policy and action results + - disabled/loading button states for in-flight operations +- Add clear policy copy where sync actions live: + - Tickets tab: what pull/push will do, which side is authoritative, what is append-only + - Project routing: project-level authority and review expectations + - Settings: global sync contract and current limits +- Add lightweight review scaffolding now, not full conflict-resolution infrastructure: + - authority badges from origin/source metadata + - linked/mergeable state hints + - review-needed messaging for ambiguous fields and future authoritative conflicts + - action confirmations that explain what will sync before the command runs + +## Non-Goals +- No migration from git-backed tickets to SQLite. +- No full field-level conflict engine in this pass. +- No editable ownership override in this pass. +- No comment edit/delete synchronization. + +## Storage Direction +If sync metadata grows, prefer a future SQLite sidecar for sync metadata only: +- sync shadow state +- conflict records +- identity mappings +- comment dedupe cursors +- dependency translation cache + +Do not migrate ticket source-of-truth storage in this pass. diff --git a/docs/plans/2026-03-05-linear-sync-ux-plan.md b/docs/plans/2026-03-05-linear-sync-ux-plan.md new file mode 100644 index 000000000..c2481f0e3 --- /dev/null +++ b/docs/plans/2026-03-05-linear-sync-ux-plan.md @@ -0,0 +1,95 @@ +# Linear Sync UX Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make Linear sync behavior understandable in the macOS app by surfacing authority, merge rules, review-needed states, and clearer action feedback. + +**Architecture:** Keep the existing git-backed ticket substrate and broker/CLI sync commands. Add a small client-side sync UX model in `HackDesktopModels`, then apply it consistently across Tickets, Project Routing, and Linear Settings using existing `InlineCallout`, badge, and progress patterns. + +**Tech Stack:** SwiftUI, Swift Package Manager, HackDesktopModels, DashboardFeature, HackCLIService + +--- + +### Task 1: Add sync UX model helpers + +**Files:** +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` +- Test: `apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift` + +**Step 1: Write the failing test** +- Add tests for derived authority, mergeable state, and review-needed messaging from `TicketSummary`. + +**Step 2: Run test to verify it fails** +Run: `swift test --package-path apps/macos --filter LinearSyncUXModelTests` +Expected: FAIL because the sync UX helpers do not exist yet. + +**Step 3: Write minimal implementation** +- Add lightweight sync UX helper types/functions to `Models.swift`. +- Keep them pure and derived from existing ticket metadata. + +**Step 4: Run test to verify it passes** +Run: `swift test --package-path apps/macos --filter LinearSyncUXModelTests` +Expected: PASS + +### Task 2: Upgrade Tickets tab sync affordances + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift` + +**Step 1: Write the failing test** +- Covered indirectly by model tests; use compile/build as the verification gate for this view-only task. + +**Step 2: Implement minimal UX changes** +- Replace raw `loadNotice` text with structured inline callout treatment. +- Add a sync policy callout near pull/push actions. +- Show per-ticket authority/review hints from the new model helpers. +- Add lightweight action confirmation for sync directions. + +**Step 3: Run build to verify it compiles** +Run: `swift build --package-path apps/macos` +Expected: PASS + +### Task 3: Upgrade project routing Linear UX + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift` + +**Step 1: Implement minimal UX changes** +- Add project-level sync contract callout. +- Improve bound-project status/result messages. +- Make pull/push actions explain authority and merge behavior. + +**Step 2: Run build to verify it compiles** +Run: `swift build --package-path apps/macos` +Expected: PASS + +### Task 4: Upgrade Linear settings guidance + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` + +**Step 1: Implement minimal UX changes** +- Add a concise global sync policy summary. +- Clarify what sync toggles affect and what remains append-only/review-needed. +- Improve auth/disconnect messaging where it intersects sync routing. + +**Step 2: Run build to verify it compiles** +Run: `swift build --package-path apps/macos` +Expected: PASS + +### Task 5: Final verification + +**Files:** +- Verify only + +**Step 1: Run focused model tests** +Run: `swift test --package-path apps/macos --filter LinearSyncUXModelTests` +Expected: PASS + +**Step 2: Run macOS package build** +Run: `swift build --package-path apps/macos` +Expected: PASS + +**Step 3: Run repo verification gates** +Run: `bun run build && bun run typecheck && bun run test && bun run check && hack ps` +Expected: PASS diff --git a/scripts/install-cli.ts b/scripts/install-cli.ts index d023b1f02..35308a9e6 100644 --- a/scripts/install-cli.ts +++ b/scripts/install-cli.ts @@ -175,12 +175,13 @@ async function desiredInstall(opts: { readonly targetPath: string; }): Promise { if (opts.mode === "dev") { + const bunExecutable = resolveBunExecutablePath(); const content = [ "#!/usr/bin/env bash", "set -euo pipefail", "# hack-cli local-dev shim (auto-generated)", "", - `exec bun ${shellQuote(resolve(opts.repoRoot, "index.ts"))} "$@"`, + `exec ${shellQuote(bunExecutable)} ${shellQuote(resolve(opts.repoRoot, "index.ts"))} "$@"`, "", ].join("\n"); @@ -210,6 +211,12 @@ async function desiredInstall(opts: { }; } +function resolveBunExecutablePath(): string { + const candidate = + typeof process.execPath === "string" ? process.execPath.trim() : ""; + return candidate.length > 0 ? candidate : "bun"; +} + type CurrentInstall = | { readonly status: "missing" } | { diff --git a/services/auth-broker/.env.example b/services/auth-broker/.env.example index bcaa9c226..3f521aa46 100644 --- a/services/auth-broker/.env.example +++ b/services/auth-broker/.env.example @@ -9,6 +9,26 @@ GITHUB_APP_SLUG= # Optional explicit install URL override (defaults to https://github.com/apps//installations/new) GITHUB_APP_INSTALL_URL= +# Linear OAuth + agent webhook credentials +# HACK_* names are preferred and match hack-cli extension defaults. +HACK_LINEAR_CLIENT_ID= +HACK_LINEAR_SECRET= +HACK_LINEAR_DEVELOPER_APP_TOKEN= +HACK_LINEAR_WEBHOOK_SECRET= +HACK_LINEAR_OAUTH_ACTOR=app +# Optional compatibility aliases for external environments: +LINEAR_CLIENT_ID= +LINEAR_CLIENT_SECRET= +LINEAR_DEVELOPER_APP_TOKEN= +LINEAR_WEBHOOK_SIGNING_SECRET= +# Optional OAuth/webhook overrides. +HACK_LINEAR_SCOPES=read,write,app:mentionable,app:assignable +HACK_LINEAR_AUTHORIZE_URL=https://linear.app/oauth/authorize +HACK_LINEAR_TOKEN_URL=https://api.linear.app/oauth/token +HACK_LINEAR_API_BASE_URL=https://api.linear.app +HACK_LINEAR_REDIRECT_URI=https://auth.hack.broker/linear/callback +HACK_LINEAR_WEBHOOK_PATH=/linear/webhooks + # Optional broker network settings AUTH_BROKER_PUBLIC_BASE_URL=http://127.0.0.1:8080 HOST=0.0.0.0 diff --git a/services/auth-broker/README.md b/services/auth-broker/README.md index 9f1cb0ab0..ae602a599 100644 --- a/services/auth-broker/README.md +++ b/services/auth-broker/README.md @@ -30,6 +30,7 @@ stay decoupled: 3. `src/modules/providers/plugin.ts` 4. `src/modules/better-auth/plugin.ts` 5. `src/modules/github-oauth/plugin.ts` +6. `src/modules/linear-agent/plugin.ts` GitHub OAuth routes are internally composed from focused plugins so callback, provider, and polling route plumbing can evolve independently: @@ -64,8 +65,11 @@ return `405` for non-`GET` requests. 4. `GET /gh/start` 5. `GET /gh/callback` 6. `GET /v1/auth/github/flows/:flowId` -7. `GET /v1/auth/better-auth/status` -8. `ALL /api/auth/*` (proxied to Better Auth handler) +7. `GET /linear/callback` +8. `POST /linear/webhooks` (Linear agent + webhook ingest) +9. `POST /v1/integrations/linear/webhook` (legacy alias) +10. `GET /v1/auth/better-auth/status` +11. `ALL /api/auth/*` (proxied to Better Auth handler) When `requireInstallation=1` is used, flow polling can defer token claim until an installation is visible for the flow, enabling one-pass authorize+install UX. @@ -93,6 +97,21 @@ so desktop users can return focus to the app immediately after browser auth. 16. `BETTER_AUTH_URL` (optional base URL override) 17. `BETTER_AUTH_TRUSTED_ORIGINS` (optional comma-separated origins) 18. `BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from GitHub email if no match exists) +19. `HACK_LINEAR_CLIENT_ID` (recommended Linear OAuth client id) +20. `HACK_LINEAR_SECRET` (optional Linear OAuth client secret; PKCE can run without it) +21. `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional app token for agent/system automations) +22. `HACK_LINEAR_WEBHOOK_SECRET` (recommended Linear webhook signing secret) +23. `HACK_LINEAR_SCOPES` (optional; default: `read,write,app:mentionable,app:assignable`) +24. `HACK_LINEAR_OAUTH_ACTOR` (optional; default: `app` for Linear agent/app installs) +25. `HACK_LINEAR_REDIRECT_URI` (default: `${AUTH_BROKER_PUBLIC_BASE_URL}/linear/callback`) +26. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) +27. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) +28. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) +29. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) +30. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) + +When running broker from repo root (`bun run auth:dev`), Linear env aliases also +fallback to root `.env.local` / `.env` if process env values are unset. ## GitHub App setup (permissions + private key) diff --git a/services/auth-broker/package.json b/services/auth-broker/package.json index 9b76602e7..b7ba9e52d 100644 --- a/services/auth-broker/package.json +++ b/services/auth-broker/package.json @@ -13,6 +13,7 @@ "auth:migrate": "bunx @better-auth/cli@latest migrate --config src/better-auth.ts" }, "dependencies": { + "@better-auth/drizzle-adapter": "^1.3.12", "@neondatabase/serverless": "^1.0.2", "better-auth": "^1.3.12", "drizzle-orm": "^0.44.5", diff --git a/services/auth-broker/src/app.ts b/services/auth-broker/src/app.ts index d1b0ad7ff..c9ddb85a3 100644 --- a/services/auth-broker/src/app.ts +++ b/services/auth-broker/src/app.ts @@ -9,6 +9,8 @@ import { FlowStore } from "./flow-store.ts"; import { createBetterAuthPlugin } from "./modules/better-auth/plugin.ts"; import { createCoreRoutesPlugin } from "./modules/core/plugin.ts"; import { createGitHubOAuthPlugin } from "./modules/github-oauth/plugin.ts"; +import { createLinearAgentPlugin } from "./modules/linear-agent/plugin.ts"; +import { createLinearOAuthPlugin } from "./modules/linear-oauth/plugin.ts"; import { createProvidersPlugin } from "./modules/providers/plugin.ts"; import { createSharedMiddlewarePlugin } from "./plugins/shared-middleware.ts"; @@ -72,6 +74,17 @@ export function createAuthBrokerApp({ betterAuthRuntime, }) ) + .use( + createLinearAgentPlugin({ + config, + }) + ) + .use( + createLinearOAuthPlugin({ + config, + flowStore, + }) + ) .onStart(() => { const intervalMs = Math.max(config.flowSweepIntervalMs, 5000); flowSweepTimer = setInterval(() => { @@ -101,11 +114,23 @@ function isReadOnlyRoutePath(input: { readonly path: string }): boolean { if (input.path === "/v1/auth/better-auth/status") { return true; } + if (input.path === "/linear/callback") { + return true; + } + if (input.path === "/linear/start") { + return true; + } + if (input.path === "/v1/auth/linear/refresh") { + return false; + } if (input.path.startsWith("/gh/")) { return true; } if (input.path.startsWith("/v1/auth/github/")) { return true; } + if (input.path.startsWith("/v1/auth/linear/")) { + return true; + } return false; } diff --git a/services/auth-broker/src/better-auth-link.ts b/services/auth-broker/src/better-auth-link.ts index 6d5558865..0662ad9cc 100644 --- a/services/auth-broker/src/better-auth-link.ts +++ b/services/auth-broker/src/better-auth-link.ts @@ -69,7 +69,11 @@ export async function resolveBetterAuthUserFromGitHubAccount(input: { const createdUserId = await createBetterAuthUserByEmail({ runtime, email: accountEmail, - name: normalizeText(input.account.accountName) ?? input.account.login, + name: + normalizeText(input.account.accountName) ?? + normalizeText(input.account.login) ?? + normalizeText(input.account.accountHandle) ?? + accountEmail, }); if (createdUserId) { return { state: "created_new", userId: createdUserId }; diff --git a/services/auth-broker/src/config.ts b/services/auth-broker/src/config.ts index e83a2c464..059c73f48 100644 --- a/services/auth-broker/src/config.ts +++ b/services/auth-broker/src/config.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + export type BrokerConfig = { readonly port: number; readonly host: string; @@ -14,6 +17,17 @@ export type BrokerConfig = { readonly githubApiBaseUrl: string; readonly githubRedirectUri: string; readonly betterAuthGitHubAutoProvisionUsers: boolean; + readonly linearClientId?: string; + readonly linearClientSecret?: string; + readonly linearDeveloperAppToken?: string; + readonly linearActor: "user" | "app"; + readonly linearScopes: string; + readonly linearAuthorizeUrl: string; + readonly linearTokenUrl: string; + readonly linearApiBaseUrl: string; + readonly linearRedirectUri: string; + readonly linearWebhookPath: string; + readonly linearWebhookSigningSecret?: string; readonly flowTtlMs: number; readonly flowSweepIntervalMs: number; }; @@ -26,10 +40,22 @@ const DEFAULT_GITHUB_SCOPES = "read:user,user:email,read:org"; const DEFAULT_GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; const DEFAULT_GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; const DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com"; +const DEFAULT_LINEAR_SCOPES = "read,write,app:mentionable,app:assignable"; +const DEFAULT_LINEAR_ACTOR = "app"; +const DEFAULT_LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize"; +const DEFAULT_LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token"; +const DEFAULT_LINEAR_API_BASE_URL = "https://api.linear.app"; +const DEFAULT_LINEAR_WEBHOOK_PATH = "/linear/webhooks"; const DEFAULT_FLOW_TTL_MS = 10 * 60 * 1000; const DEFAULT_FLOW_SWEEP_INTERVAL_MS = 30 * 1000; const TRAILING_SLASH_PATTERN = /\/$/; const GITHUB_SCOPE_SPLIT_PATTERN = /[,\s]+/; +const NEWLINE_SPLIT_PATTERN = /\r?\n/; +const ROOT_ENV_FALLBACK_FILES = [ + resolve(import.meta.dir, "../../..", ".env.local"), + resolve(import.meta.dir, "../../..", ".env"), +] as const; +let cachedRootEnvFallback: Map | null = null; /** * Resolve auth broker runtime config from environment variables. @@ -38,18 +64,12 @@ export function resolveConfig(): BrokerConfig { const publicBaseUrl = normalizeUrl(process.env.AUTH_BROKER_PUBLIC_BASE_URL) ?? DEFAULT_PUBLIC_BASE_URL; - const githubClientId = normalizeRequiredEnv("GITHUB_CLIENT_ID"); - const githubClientSecret = normalizeRequiredEnv("GITHUB_CLIENT_SECRET"); - const githubRedirectUri = - normalizeUrl(process.env.GITHUB_REDIRECT_URI) ?? - `${publicBaseUrl}/gh/callback`; - const githubAppSlug = - normalizeString(process.env.GITHUB_APP_SLUG) ?? undefined; - const githubAppInstallUrl = - normalizeUrl(process.env.GITHUB_APP_INSTALL_URL) ?? - (githubAppSlug - ? `https://github.com/apps/${encodeURIComponent(githubAppSlug)}/installations/new` - : undefined); + const github = resolveGitHubConfig({ + publicBaseUrl, + }); + const linear = resolveLinearConfig({ + publicBaseUrl, + }); return { port: parsePort(process.env.PORT) ?? DEFAULT_PORT, @@ -57,25 +77,11 @@ export function resolveConfig(): BrokerConfig { publicBaseUrl, flowStorePath: normalizeString(process.env.FLOW_STORE_PATH) ?? DEFAULT_FLOW_STORE_PATH, - githubClientId, - githubClientSecret, - githubAppId: normalizeString(process.env.GITHUB_APP_ID) ?? undefined, - githubAppSlug, - githubAppInstallUrl, - githubScopes: - normalizeGitHubScopes(process.env.GITHUB_SCOPES) ?? DEFAULT_GITHUB_SCOPES, - githubAuthorizeUrl: - normalizeUrl(process.env.GITHUB_AUTHORIZE_URL) ?? - DEFAULT_GITHUB_AUTHORIZE_URL, - githubTokenUrl: - normalizeUrl(process.env.GITHUB_TOKEN_URL) ?? DEFAULT_GITHUB_TOKEN_URL, - githubApiBaseUrl: - normalizeUrl(process.env.GITHUB_API_BASE_URL) ?? - DEFAULT_GITHUB_API_BASE_URL, - githubRedirectUri, + ...github, betterAuthGitHubAutoProvisionUsers: parseBoolean(process.env.BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS) ?? false, + ...linear, flowTtlMs: parsePositiveInt(process.env.FLOW_TTL_MS) ?? DEFAULT_FLOW_TTL_MS, flowSweepIntervalMs: parsePositiveInt(process.env.FLOW_SWEEP_INTERVAL_MS) ?? @@ -137,8 +143,140 @@ function parseBoolean(value: string | undefined): boolean | null { return null; } +function resolveGitHubConfig(input: { + readonly publicBaseUrl: string; +}): Pick< + BrokerConfig, + | "githubClientId" + | "githubClientSecret" + | "githubAppId" + | "githubAppSlug" + | "githubAppInstallUrl" + | "githubScopes" + | "githubAuthorizeUrl" + | "githubTokenUrl" + | "githubApiBaseUrl" + | "githubRedirectUri" +> { + const githubClientId = normalizeRequiredEnv("GITHUB_CLIENT_ID"); + const githubClientSecret = normalizeRequiredEnv("GITHUB_CLIENT_SECRET"); + const githubAppSlug = + normalizeString(process.env.GITHUB_APP_SLUG) ?? undefined; + const githubAppInstallUrl = + normalizeUrl(process.env.GITHUB_APP_INSTALL_URL) ?? + (githubAppSlug + ? `https://github.com/apps/${encodeURIComponent(githubAppSlug)}/installations/new` + : undefined); + return { + githubClientId, + githubClientSecret, + githubAppId: normalizeString(process.env.GITHUB_APP_ID) ?? undefined, + githubAppSlug, + githubAppInstallUrl, + githubScopes: + normalizeGitHubScopes(process.env.GITHUB_SCOPES) ?? DEFAULT_GITHUB_SCOPES, + githubAuthorizeUrl: + normalizeUrl(process.env.GITHUB_AUTHORIZE_URL) ?? + DEFAULT_GITHUB_AUTHORIZE_URL, + githubTokenUrl: + normalizeUrl(process.env.GITHUB_TOKEN_URL) ?? DEFAULT_GITHUB_TOKEN_URL, + githubApiBaseUrl: + normalizeUrl(process.env.GITHUB_API_BASE_URL) ?? + DEFAULT_GITHUB_API_BASE_URL, + githubRedirectUri: + normalizeUrl(process.env.GITHUB_REDIRECT_URI) ?? + `${input.publicBaseUrl}/gh/callback`, + }; +} + +function resolveLinearConfig(input: { + readonly publicBaseUrl: string; +}): Pick< + BrokerConfig, + | "linearClientId" + | "linearClientSecret" + | "linearDeveloperAppToken" + | "linearActor" + | "linearScopes" + | "linearAuthorizeUrl" + | "linearTokenUrl" + | "linearApiBaseUrl" + | "linearRedirectUri" + | "linearWebhookPath" + | "linearWebhookSigningSecret" +> { + const linearClientId = normalizeString( + readFirstEnv(["HACK_LINEAR_CLIENT_ID", "LINEAR_CLIENT_ID"]) + ); + const linearClientSecret = normalizeString( + readFirstEnv(["HACK_LINEAR_SECRET", "LINEAR_CLIENT_SECRET"]) + ); + const linearDeveloperAppToken = normalizeString( + readFirstEnv([ + "HACK_LINEAR_DEVELOPER_APP_TOKEN", + "LINEAR_DEVELOPER_APP_TOKEN", + ]) + ); + return { + linearClientId: linearClientId ?? undefined, + linearClientSecret: linearClientSecret ?? undefined, + linearDeveloperAppToken: linearDeveloperAppToken ?? undefined, + linearActor: + normalizeLinearActor( + readFirstEnv(["HACK_LINEAR_OAUTH_ACTOR", "LINEAR_OAUTH_ACTOR"]) + ) ?? DEFAULT_LINEAR_ACTOR, + linearScopes: + normalizeOAuthScopes({ + value: readFirstEnv(["HACK_LINEAR_SCOPES", "LINEAR_SCOPES"]), + }) ?? DEFAULT_LINEAR_SCOPES, + linearAuthorizeUrl: + normalizeUrl( + readFirstEnv(["HACK_LINEAR_AUTHORIZE_URL", "LINEAR_AUTHORIZE_URL"]) + ) ?? DEFAULT_LINEAR_AUTHORIZE_URL, + linearTokenUrl: + normalizeUrl( + readFirstEnv(["HACK_LINEAR_TOKEN_URL", "LINEAR_TOKEN_URL"]) + ) ?? DEFAULT_LINEAR_TOKEN_URL, + linearApiBaseUrl: + normalizeUrl( + readFirstEnv(["HACK_LINEAR_API_BASE_URL", "LINEAR_API_BASE_URL"]) + ) ?? DEFAULT_LINEAR_API_BASE_URL, + linearRedirectUri: + normalizeUrl( + readFirstEnv(["HACK_LINEAR_REDIRECT_URI", "LINEAR_REDIRECT_URI"]) + ) ?? `${input.publicBaseUrl}/linear/callback`, + linearWebhookPath: + normalizePath( + readFirstEnv(["HACK_LINEAR_WEBHOOK_PATH", "LINEAR_WEBHOOK_PATH"]) + ) ?? DEFAULT_LINEAR_WEBHOOK_PATH, + linearWebhookSigningSecret: + normalizeString( + readFirstEnv([ + "HACK_LINEAR_WEBHOOK_SECRET", + "LINEAR_WEBHOOK_SIGNING_SECRET", + ]) + ) ?? undefined, + }; +} + +function normalizeLinearActor( + value: string | undefined +): "user" | "app" | null { + const normalized = normalizeString(value)?.toLowerCase(); + if (normalized === "user" || normalized === "app") { + return normalized; + } + return null; +} + function normalizeGitHubScopes(value: string | undefined): string | null { - const normalized = normalizeString(value); + return normalizeOAuthScopes({ value }); +} + +function normalizeOAuthScopes(input: { + readonly value: string | undefined; +}): string | null { + const normalized = normalizeString(input.value); if (!normalized) { return null; } @@ -172,3 +310,97 @@ function normalizeUrl(value: string | undefined): string | null { return null; } } + +function normalizePath(value: string | undefined): string | null { + const normalized = normalizeString(value); + if (!normalized) { + return null; + } + const withLeadingSlash = normalized.startsWith("/") + ? normalized + : `/${normalized}`; + if (withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")) { + return withLeadingSlash.slice(0, -1); + } + return withLeadingSlash; +} + +function readFirstEnv(keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]; + if (value !== undefined) { + return value; + } + } + const fallback = resolveRootEnvFallback(); + for (const key of keys) { + const value = fallback.get(key); + if (value !== undefined) { + return value; + } + } + return undefined; +} + +function resolveRootEnvFallback(): Map { + if (cachedRootEnvFallback) { + return cachedRootEnvFallback; + } + const values = new Map(); + for (const filePath of ROOT_ENV_FALLBACK_FILES) { + const parsed = parseDotenvFile({ filePath }); + for (const [key, value] of parsed.entries()) { + if (!values.has(key)) { + values.set(key, value); + } + } + } + cachedRootEnvFallback = values; + return values; +} + +function parseDotenvFile(input: { + readonly filePath: string; +}): Map { + let raw = ""; + try { + raw = readFileSync(input.filePath, "utf8"); + } catch { + return new Map(); + } + const values = new Map(); + const lines = raw.split(NEWLINE_SPLIT_PATTERN); + for (const line of lines) { + const trimmed = line.trim(); + if (!(trimmed && !trimmed.startsWith("#"))) { + continue; + } + const eq = trimmed.indexOf("="); + if (eq <= 0) { + continue; + } + const key = trimmed.slice(0, eq).trim(); + if (!key) { + continue; + } + const rawValue = trimmed.slice(eq + 1); + const value = normalizeDotenvValue({ value: rawValue }); + values.set(key, value); + } + return values; +} + +function normalizeDotenvValue(input: { readonly value: string }): string { + const raw = input.value.trim(); + if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { + return raw + .slice(1, -1) + .replaceAll("\\n", "\n") + .replaceAll('\\"', '"') + .replaceAll("\\\\", "\\"); + } + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1); + } + return raw; +} diff --git a/services/auth-broker/src/flow-store.ts b/services/auth-broker/src/flow-store.ts index 0b52f3f6b..e0d8e23b1 100644 --- a/services/auth-broker/src/flow-store.ts +++ b/services/auth-broker/src/flow-store.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; -import type { GitHubFlowPublicStatus, GitHubOAuthFlow } from "./types.ts"; +import type { OAuthFlow, OAuthFlowPublicStatus } from "./types.ts"; const FLOW_STORE_VERSION = 1 as const; const FLOW_STATUS_VALUES = new Set([ @@ -15,7 +15,7 @@ const FLOW_STATUS_VALUES = new Set([ type PersistedFlowStorePayload = { readonly version: typeof FLOW_STORE_VERSION; - readonly flows: readonly GitHubOAuthFlow[]; + readonly flows: readonly OAuthFlow[]; }; type FlowStoreOptions = { @@ -31,7 +31,7 @@ type FlowStoreOptions = { * - One-time token claim via `deviceCode` */ export class FlowStore { - private readonly flowsById = new Map(); + private readonly flowsById = new Map(); private readonly flowIdByState = new Map(); private readonly filePath: string | null; @@ -40,13 +40,13 @@ export class FlowStore { this.loadFromDisk(); } - createFlow(flow: GitHubOAuthFlow): void { + createFlow(flow: OAuthFlow): void { this.flowsById.set(flow.id, flow); this.flowIdByState.set(flow.state, flow.id); this.persist(); } - getByState(state: string): GitHubOAuthFlow | null { + getByState(state: string): OAuthFlow | null { const flowId = this.flowIdByState.get(state); if (!flowId) { return null; @@ -54,7 +54,7 @@ export class FlowStore { return this.getById(flowId); } - getById(flowId: string): GitHubOAuthFlow | null { + getById(flowId: string): OAuthFlow | null { return this.flowsById.get(flowId) ?? null; } @@ -62,7 +62,7 @@ export class FlowStore { readonly flowId: string; readonly error: string; readonly status?: "error" | "expired"; - }): GitHubOAuthFlow | null { + }): OAuthFlow | null { const flow = this.getById(opts.flowId); if (!flow) { return null; @@ -76,11 +76,13 @@ export class FlowStore { markComplete(opts: { readonly flowId: string; - readonly account: GitHubOAuthFlow["account"]; + readonly account: OAuthFlow["account"]; readonly token: string; readonly tokenExpiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; readonly installationId?: string; - }): GitHubOAuthFlow | null { + }): OAuthFlow | null { const flow = this.getById(opts.flowId); if (!flow) { return null; @@ -88,6 +90,8 @@ export class FlowStore { flow.account = opts.account; flow.token = opts.token; flow.tokenExpiresAt = opts.tokenExpiresAt; + flow.refreshToken = opts.refreshToken; + flow.refreshTokenExpiresAt = opts.refreshTokenExpiresAt; flow.installationId = opts.installationId; flow.status = "complete"; flow.completedAt = new Date().toISOString(); @@ -99,7 +103,7 @@ export class FlowStore { readonly flowId: string; readonly installationIds: readonly string[]; readonly installationId?: string; - }): GitHubOAuthFlow | null { + }): OAuthFlow | null { const flow = this.getById(opts.flowId); if (!flow?.account) { return null; @@ -122,7 +126,7 @@ export class FlowStore { readonly requireInstallation: boolean; readonly nowMs?: number; }): - | { readonly ok: true; readonly status: GitHubFlowPublicStatus } + | { readonly ok: true; readonly status: OAuthFlowPublicStatus } | { readonly ok: false; readonly error: string; @@ -161,17 +165,23 @@ export class FlowStore { } const claimedAt = new Date(nowMs).toISOString(); - const status: GitHubFlowPublicStatus = { + const status: OAuthFlowPublicStatus = { ...base, status: "claimed", claimedAt, token: flow.token, tokenExpiresAt: flow.tokenExpiresAt, + ...(flow.refreshToken ? { refreshToken: flow.refreshToken } : {}), + ...(flow.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: flow.refreshTokenExpiresAt } + : {}), }; flow.status = "claimed"; flow.claimedAt = claimedAt; flow.token = undefined; flow.tokenExpiresAt = undefined; + flow.refreshToken = undefined; + flow.refreshTokenExpiresAt = undefined; changed = true; if (changed) { this.persist(); @@ -245,9 +255,10 @@ export function hashDeviceCode(deviceCode: string): string { return createHash("sha256").update(deviceCode).digest("hex"); } -function toPublicStatus(flow: GitHubOAuthFlow): GitHubFlowPublicStatus { +function toPublicStatus(flow: OAuthFlow): OAuthFlowPublicStatus { return { id: flow.id, + provider: flow.provider, status: flow.status, profileId: flow.profileId, setDefault: flow.setDefault, @@ -255,6 +266,9 @@ function toPublicStatus(flow: GitHubOAuthFlow): GitHubFlowPublicStatus { expiresAt: new Date(flow.expiresAtMs).toISOString(), ...(flow.completedAt ? { completedAt: flow.completedAt } : {}), ...(flow.claimedAt ? { claimedAt: flow.claimedAt } : {}), + ...(flow.account?.accountHandle + ? { accountHandle: flow.account.accountHandle } + : {}), ...(flow.account?.login ? { accountLogin: flow.account.login } : {}), ...(flow.account?.accountName ? { accountName: flow.account.accountName } @@ -308,7 +322,7 @@ function parsePersistedPayload(input: { if (!Array.isArray(input.value.flows)) { return null; } - const flows: GitHubOAuthFlow[] = []; + const flows: OAuthFlow[] = []; for (const entry of input.value.flows) { const parsed = parsePersistedFlow({ value: entry }); if (parsed) { @@ -323,16 +337,18 @@ function parsePersistedPayload(input: { function parsePersistedFlow(input: { readonly value: unknown; -}): GitHubOAuthFlow | null { +}): OAuthFlow | null { if (!isRecord(input.value)) { return null; } const id = asNonEmptyString(input.value.id); const state = asNonEmptyString(input.value.state); + const provider = asProvider(input.value.provider) ?? "github"; const profileId = asNonEmptyString(input.value.profileId); const deviceCodeHash = asNonEmptyString(input.value.deviceCodeHash); const authorizeUrl = asNonEmptyString(input.value.authorizeUrl); const redirectUri = asNonEmptyString(input.value.redirectUri); + const codeVerifier = asOptionalString(input.value.codeVerifier); const status = asFlowStatus(input.value.status); const setDefault = typeof input.value.setDefault === "boolean" ? input.value.setDefault : null; @@ -361,16 +377,22 @@ function parsePersistedFlow(input: { const installationId = asOptionalString(input.value.installationId); const token = asOptionalString(input.value.token); const tokenExpiresAt = asOptionalString(input.value.tokenExpiresAt); + const refreshToken = asOptionalString(input.value.refreshToken); + const refreshTokenExpiresAt = asOptionalString( + input.value.refreshTokenExpiresAt + ); const error = asOptionalString(input.value.error); const completedAt = asOptionalString(input.value.completedAt); const claimedAt = asOptionalString(input.value.claimedAt); return { id, + provider, state, profileId, setDefault, deviceCodeHash, authorizeUrl, + ...(codeVerifier ? { codeVerifier } : {}), ...(appId ? { appId } : {}), ...(appSlug ? { appSlug } : {}), ...(appInstallUrl ? { appInstallUrl } : {}), @@ -382,6 +404,8 @@ function parsePersistedFlow(input: { ...(installationId ? { installationId } : {}), ...(token ? { token } : {}), ...(tokenExpiresAt ? { tokenExpiresAt } : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(refreshTokenExpiresAt ? { refreshTokenExpiresAt } : {}), ...(error ? { error } : {}), ...(completedAt ? { completedAt } : {}), ...(claimedAt ? { claimedAt } : {}), @@ -390,14 +414,12 @@ function parsePersistedFlow(input: { function parsePersistedFlowAccount(input: { readonly value: unknown; -}): GitHubOAuthFlow["account"] | undefined { +}): OAuthFlow["account"] | undefined { if (!isRecord(input.value)) { return undefined; } - const login = asNonEmptyString(input.value.login); - if (!login) { - return undefined; - } + const login = asOptionalString(input.value.login); + const accountHandle = asOptionalString(input.value.accountHandle); const installationIds = Array.isArray(input.value.installationIds) ? input.value.installationIds.filter( (entry): entry is string => @@ -409,8 +431,23 @@ function parsePersistedFlowAccount(input: { const accountEmail = asOptionalString(input.value.accountEmail); const betterAuthUserId = asOptionalString(input.value.betterAuthUserId); const betterAuthLinkState = asOptionalString(input.value.betterAuthLinkState); + if ( + !( + login || + accountHandle || + accountName || + accountId || + accountEmail || + installationIds.length > 0 || + betterAuthUserId || + betterAuthLinkState + ) + ) { + return undefined; + } return { - login, + ...(login ? { login } : {}), + ...(accountHandle ? { accountHandle } : {}), installationIds, ...(accountName ? { accountName } : {}), ...(accountId ? { accountId } : {}), @@ -419,7 +456,7 @@ function parsePersistedFlowAccount(input: { ...(betterAuthLinkState ? { betterAuthLinkState: betterAuthLinkState as NonNullable< - GitHubOAuthFlow["account"] + OAuthFlow["account"] >["betterAuthLinkState"], } : {}), @@ -448,14 +485,21 @@ function asPositiveNumber(value: unknown): number | null { return value; } -function asFlowStatus(value: unknown): GitHubOAuthFlow["status"] | null { +function asFlowStatus(value: unknown): OAuthFlow["status"] | null { if (typeof value !== "string") { return null; } - if (!FLOW_STATUS_VALUES.has(value as GitHubOAuthFlow["status"])) { + if (!FLOW_STATUS_VALUES.has(value as OAuthFlow["status"])) { return null; } - return value as GitHubOAuthFlow["status"]; + return value as OAuthFlow["status"]; +} + +function asProvider(value: unknown): OAuthFlow["provider"] | null { + if (value === "github" || value === "linear") { + return value; + } + return null; } function isRecord(value: unknown): value is Record { diff --git a/services/auth-broker/src/linear.ts b/services/auth-broker/src/linear.ts new file mode 100644 index 000000000..211946e23 --- /dev/null +++ b/services/auth-broker/src/linear.ts @@ -0,0 +1,262 @@ +import type { OAuthFlowAccount } from "./types.ts"; + +const USER_AGENT = "hack-auth-broker"; + +type LinearTokenExchangeResult = + | { + readonly ok: true; + readonly token: string; + readonly tokenExpiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + } + | { + readonly ok: false; + readonly error: string; + readonly statusCode: number; + }; + +type LinearIdentityResult = + | { + readonly ok: true; + readonly account: OAuthFlowAccount; + } + | { + readonly ok: false; + readonly error: string; + readonly statusCode: number; + }; + +export function buildAuthorizeUrl(input: { + readonly authorizeUrl: string; + readonly clientId: string; + readonly redirectUri: string; + readonly actor?: "user" | "app"; + readonly scopes: string; + readonly state: string; + readonly codeChallenge: string; +}): string { + const url = new URL(input.authorizeUrl); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + if (input.actor) { + url.searchParams.set("actor", input.actor); + } + url.searchParams.set("scope", input.scopes); + url.searchParams.set("state", input.state); + url.searchParams.set("prompt", "consent"); + url.searchParams.set("code_challenge", input.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +export async function exchangeCodeForToken(input: { + readonly tokenUrl: string; + readonly clientId: string; + readonly clientSecret?: string; + readonly code: string; + readonly redirectUri: string; + readonly codeVerifier: string; +}): Promise { + return await exchangeLinearToken({ + tokenUrl: input.tokenUrl, + clientId: input.clientId, + ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}), + body: { + grant_type: "authorization_code", + client_id: input.clientId, + ...(input.clientSecret ? { client_secret: input.clientSecret } : {}), + code: input.code, + redirect_uri: input.redirectUri, + code_verifier: input.codeVerifier, + }, + }); +} + +export async function refreshAccessToken(input: { + readonly tokenUrl: string; + readonly clientId: string; + readonly clientSecret?: string; + readonly refreshToken: string; +}): Promise { + return await exchangeLinearToken({ + tokenUrl: input.tokenUrl, + clientId: input.clientId, + ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}), + body: { + grant_type: "refresh_token", + client_id: input.clientId, + ...(input.clientSecret ? { client_secret: input.clientSecret } : {}), + refresh_token: input.refreshToken, + }, + }); +} + +async function exchangeLinearToken(input: { + readonly tokenUrl: string; + readonly clientId: string; + readonly clientSecret?: string; + readonly body: Record; +}): Promise { + const form = new URLSearchParams(); + for (const [key, value] of Object.entries(input.body)) { + form.set(key, value); + } + const response = await fetch(input.tokenUrl, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + "user-agent": USER_AGENT, + }, + body: form.toString(), + }); + const payload = (await response.json().catch(() => null)) as unknown; + if (!isRecord(payload)) { + return { + ok: false, + statusCode: response.status, + error: `token_exchange_failed: invalid payload (${response.status})`, + }; + } + if (!response.ok) { + return { + ok: false, + statusCode: response.status, + error: + readOptionalString(payload.error_description) ?? + readOptionalString(payload.error) ?? + response.statusText, + }; + } + const token = readOptionalString(payload.access_token); + if (!token) { + return { + ok: false, + statusCode: 502, + error: "token_exchange_failed: missing access_token", + }; + } + const expiresIn = readOptionalPositiveInt(payload.expires_in); + const refreshToken = readOptionalString(payload.refresh_token); + const refreshTokenExpiresIn = readOptionalPositiveInt( + payload.refresh_token_expires_in + ); + return { + ok: true, + token, + ...(expiresIn !== null + ? { + tokenExpiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + } + : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(refreshTokenExpiresIn !== null + ? { + refreshTokenExpiresAt: new Date( + Date.now() + refreshTokenExpiresIn * 1000 + ).toISOString(), + } + : {}), + }; +} + +export async function fetchIdentity(input: { + readonly apiBaseUrl: string; + readonly token: string; +}): Promise { + const response = await fetch(input.apiBaseUrl, { + method: "POST", + headers: { + Authorization: input.token, + "content-type": "application/json", + accept: "application/json", + "user-agent": USER_AGENT, + }, + body: JSON.stringify({ + query: [ + "query LinearViewer {", + " viewer {", + " id", + " name", + " email", + " displayName", + " }", + "}", + ].join("\n"), + }), + }); + const payload = (await response.json().catch(() => null)) as unknown; + if (!isRecord(payload)) { + return { + ok: false, + statusCode: response.status, + error: `identity_lookup_failed: invalid payload (${response.status})`, + }; + } + const errors = Array.isArray(payload.errors) + ? payload.errors + .map((entry) => + isRecord(entry) ? readOptionalString(entry.message) : null + ) + .filter((entry): entry is string => Boolean(entry)) + : []; + if (!response.ok || errors.length > 0) { + return { + ok: false, + statusCode: response.status, + error: errors[0] ?? response.statusText, + }; + } + + const viewer = + isRecord(payload.data) && isRecord(payload.data.viewer) + ? payload.data.viewer + : null; + const accountId = viewer ? readOptionalString(viewer.id) : null; + if (!accountId) { + return { + ok: false, + statusCode: 502, + error: "identity_lookup_failed: missing viewer.id", + }; + } + const accountName = + (viewer && readOptionalString(viewer.displayName)) ?? + (viewer && readOptionalString(viewer.name)) ?? + undefined; + const accountEmail = + (viewer && readOptionalString(viewer.email)) ?? undefined; + return { + ok: true, + account: { + accountId, + ...(accountName ? { accountName } : {}), + ...(accountEmail ? { accountEmail } : {}), + ...(accountName ? { accountHandle: accountName } : {}), + }, + }; +} + +function readOptionalString(value: unknown): string | null { + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function readOptionalPositiveInt(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + return Math.max(0, Math.floor(value)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/services/auth-broker/src/modules/github-oauth/service.ts b/services/auth-broker/src/modules/github-oauth/service.ts index 1301ffd0d..049666286 100644 --- a/services/auth-broker/src/modules/github-oauth/service.ts +++ b/services/auth-broker/src/modules/github-oauth/service.ts @@ -61,6 +61,7 @@ export function createFlow(input: { input.flowStore.createFlow({ id: flowId, + provider: "github", state, profileId, setDefault, @@ -124,13 +125,14 @@ export async function refreshFlowInstallationsIfNeeded(input: { if (!identity.ok) { return; } + const installationIds = identity.account.installationIds ?? []; const installationId = - identity.account.installationIds.length === 1 - ? (identity.account.installationIds[0] ?? undefined) + installationIds.length === 1 + ? (installationIds[0] ?? undefined) : undefined; input.flowStore.updateInstallationState({ flowId: flow.id, - installationIds: identity.account.installationIds, + installationIds, ...(installationId ? { installationId } : {}), }); } @@ -248,10 +250,11 @@ export async function handleGitHubCallback(input: { }); } + const installationIds = identity.account.installationIds ?? []; const installationId = installationIdFromCallback ?? - (identity.account.installationIds.length === 1 - ? (identity.account.installationIds[0] ?? undefined) + (installationIds.length === 1 + ? (installationIds[0] ?? undefined) : undefined); const betterAuthLink = await resolveBetterAuthUserFromGitHubAccount({ diff --git a/services/auth-broker/src/modules/linear-agent/plugin.ts b/services/auth-broker/src/modules/linear-agent/plugin.ts new file mode 100644 index 000000000..83f8d4e30 --- /dev/null +++ b/services/auth-broker/src/modules/linear-agent/plugin.ts @@ -0,0 +1,288 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +import { Elysia } from "elysia"; + +import type { BrokerConfig } from "../../config.ts"; + +const MAX_LINEAR_WEBHOOK_SKEW_MS = 60_000; +const SHA256_PREFIX = "sha256="; +const HEX_PATTERN = /^[0-9a-f]+$/; +const LEGACY_LINEAR_WEBHOOK_PATH = "/v1/integrations/linear/webhook"; + +type CreateLinearAgentPluginOptions = { + readonly config: BrokerConfig; +}; + +type LinearWebhookEvent = { + readonly action?: string; + readonly type?: string; + readonly webhookTimestamp?: string; +}; + +/** + * Linear agent webhook ingestion route. + * + * The handler acknowledges payloads immediately and returns minimal metadata so + * controller-side workers can be added later without changing route contracts. + */ +export function createLinearAgentPlugin({ + config, +}: CreateLinearAgentPluginOptions) { + const app = new Elysia({ + name: "hack-auth-broker.linear-agent", + }); + + for (const path of uniqueWebhookPaths({ + configuredPath: config.linearWebhookPath, + })) { + app.post(path, async ({ request, set }) => { + const rawBody = await request.text(); + const signatureHeader = request.headers.get("linear-signature"); + + const signature = verifyLinearWebhookSignature({ + rawBody, + signatureHeader, + secret: config.linearWebhookSigningSecret, + }); + if (!signature.ok) { + set.status = signature.statusCode; + return { + ok: false, + error: signature.error, + } as const; + } + + const payload = parseJson({ raw: rawBody }); + if (!payload.ok) { + set.status = 400; + return { + ok: false, + error: "invalid_json", + } as const; + } + + const parsedEvent = parseLinearWebhookEvent({ payload: payload.value }); + if (!parsedEvent.ok) { + set.status = 400; + return { + ok: false, + error: parsedEvent.error, + } as const; + } + + const replayCheck = validateWebhookTimestamp({ + event: parsedEvent.event, + requireTimestamp: signature.verified, + nowMs: Date.now(), + }); + if (!replayCheck.ok) { + set.status = replayCheck.statusCode; + return { + ok: false, + error: replayCheck.error, + } as const; + } + + set.status = 202; + return { + ok: true, + accepted: true, + provider: "linear", + signatureVerified: signature.verified, + eventType: parsedEvent.event.type ?? null, + action: parsedEvent.event.action ?? null, + webhookTimestamp: parsedEvent.event.webhookTimestamp ?? null, + } as const; + }); + } + return app; +} + +function verifyLinearWebhookSignature(input: { + readonly rawBody: string; + readonly signatureHeader: string | null; + readonly secret?: string; +}): + | { readonly ok: true; readonly verified: boolean } + | { + readonly ok: false; + readonly statusCode: number; + readonly error: string; + } { + if (!input.secret) { + return { ok: true, verified: false }; + } + const normalizedHeader = normalizeSignatureHeader({ + signatureHeader: input.signatureHeader, + }); + if (!normalizedHeader) { + return { + ok: false, + statusCode: 401, + error: "missing_linear_signature", + }; + } + const expected = createHmac("sha256", input.secret) + .update(input.rawBody, "utf8") + .digest("hex"); + const verified = safeHexEqual({ + left: normalizedHeader, + right: expected, + }); + if (!verified) { + return { + ok: false, + statusCode: 401, + error: "invalid_linear_signature", + }; + } + return { + ok: true, + verified: true, + }; +} + +function normalizeSignatureHeader(input: { + readonly signatureHeader: string | null; +}): string | null { + const raw = input.signatureHeader?.trim().toLowerCase(); + if (!raw) { + return null; + } + const normalized = raw.startsWith(SHA256_PREFIX) + ? raw.slice(SHA256_PREFIX.length) + : raw; + if (!(normalized.length > 0 && HEX_PATTERN.test(normalized))) { + return null; + } + return normalized; +} + +function safeHexEqual(input: { + readonly left: string; + readonly right: string; +}): boolean { + if (input.left.length !== input.right.length) { + return false; + } + if ( + !( + input.left.length > 0 && + HEX_PATTERN.test(input.left) && + HEX_PATTERN.test(input.right) + ) + ) { + return false; + } + const left = Buffer.from(input.left, "hex"); + const right = Buffer.from(input.right, "hex"); + if (left.length !== right.length) { + return false; + } + return timingSafeEqual(left, right); +} + +function parseJson(input: { + readonly raw: string; +}): { readonly ok: true; readonly value: unknown } | { readonly ok: false } { + if (!input.raw.trim()) { + return { ok: true, value: {} }; + } + try { + return { + ok: true, + value: JSON.parse(input.raw) as unknown, + }; + } catch { + return { ok: false }; + } +} + +function parseLinearWebhookEvent(input: { + readonly payload: unknown; +}): + | { readonly ok: true; readonly event: LinearWebhookEvent } + | { readonly ok: false; readonly error: string } { + if (!isRecord(input.payload)) { + return { ok: false, error: "invalid_linear_event_payload" }; + } + const action = normalizeString(input.payload.action); + const type = normalizeString(input.payload.type); + const webhookTimestamp = normalizeString(input.payload.webhookTimestamp); + return { + ok: true, + event: { + ...(action ? { action } : {}), + ...(type ? { type } : {}), + ...(webhookTimestamp ? { webhookTimestamp } : {}), + }, + }; +} + +function validateWebhookTimestamp(input: { + readonly event: LinearWebhookEvent; + readonly requireTimestamp: boolean; + readonly nowMs: number; +}): + | { readonly ok: true } + | { + readonly ok: false; + readonly statusCode: number; + readonly error: string; + } { + const timestamp = normalizeString(input.event.webhookTimestamp); + if (!timestamp) { + return input.requireTimestamp + ? { + ok: false, + statusCode: 401, + error: "missing_linear_webhook_timestamp", + } + : { ok: true }; + } + const parsedMs = Date.parse(timestamp); + if (!Number.isFinite(parsedMs)) { + return { + ok: false, + statusCode: 400, + error: "invalid_linear_webhook_timestamp", + }; + } + if (Math.abs(input.nowMs - parsedMs) > MAX_LINEAR_WEBHOOK_SKEW_MS) { + return { + ok: false, + statusCode: 401, + error: "stale_linear_webhook_timestamp", + }; + } + return { ok: true }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object"); +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function uniqueWebhookPaths(input: { + readonly configuredPath: string; +}): readonly string[] { + const ordered = [input.configuredPath, LEGACY_LINEAR_WEBHOOK_PATH]; + const seen = new Set(); + const out: string[] = []; + for (const path of ordered) { + const normalized = path.trim(); + if (!(normalized && !seen.has(normalized))) { + continue; + } + seen.add(normalized); + out.push(normalized); + } + return out; +} diff --git a/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts b/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts new file mode 100644 index 000000000..33e5368ca --- /dev/null +++ b/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts @@ -0,0 +1,29 @@ +import { Elysia } from "elysia"; + +import type { BrokerConfig } from "../../config.ts"; +import type { FlowStore } from "../../flow-store.ts"; +import { LinearOAuthModel } from "./model.ts"; +import { handleLinearCallback } from "./service.ts"; + +type CreateLinearOAuthCallbackRoutesPluginOptions = { + readonly config: BrokerConfig; + readonly flowStore: FlowStore; +}; + +export function createLinearOAuthCallbackRoutesPlugin({ + config, + flowStore, +}: CreateLinearOAuthCallbackRoutesPluginOptions) { + return new Elysia({ + name: "hack-auth-broker.linear-oauth.callback-routes", + }).get( + "/linear/callback", + ({ query }) => + handleLinearCallback({ + config, + flowStore, + query, + }), + { query: LinearOAuthModel.callbackQuery } + ); +} diff --git a/services/auth-broker/src/modules/linear-oauth/flow-status-routes-plugin.ts b/services/auth-broker/src/modules/linear-oauth/flow-status-routes-plugin.ts new file mode 100644 index 000000000..c88b8031c --- /dev/null +++ b/services/auth-broker/src/modules/linear-oauth/flow-status-routes-plugin.ts @@ -0,0 +1,42 @@ +import { Elysia } from "elysia"; + +import type { FlowStore } from "../../flow-store.ts"; +import { LinearOAuthModel } from "./model.ts"; +import { isTruthy } from "./service.ts"; + +type CreateLinearOAuthFlowStatusRoutesPluginOptions = { + readonly flowStore: FlowStore; +}; + +export function createLinearOAuthFlowStatusRoutesPlugin({ + flowStore, +}: CreateLinearOAuthFlowStatusRoutesPluginOptions) { + return new Elysia({ + name: "hack-auth-broker.linear-oauth.flow-status-routes", + }).all( + "/v1/auth/linear/flows/:flowId", + ({ params, query, set }) => { + const status = flowStore.getStatus({ + flowId: params.flowId, + deviceCode: query.deviceCode, + claimToken: isTruthy(query.claim), + requireInstallation: false, + }); + if (!status.ok) { + set.status = status.statusCode; + return { + ok: false, + error: status.error, + } as const; + } + return { + ok: true, + status: status.status, + } as const; + }, + { + params: LinearOAuthModel.flowStatusParams, + query: LinearOAuthModel.flowStatusQuery, + } + ); +} diff --git a/services/auth-broker/src/modules/linear-oauth/model.ts b/services/auth-broker/src/modules/linear-oauth/model.ts new file mode 100644 index 000000000..ed806fcd7 --- /dev/null +++ b/services/auth-broker/src/modules/linear-oauth/model.ts @@ -0,0 +1,31 @@ +import { t, type UnwrapSchema } from "elysia"; + +export const LinearOAuthModel = { + flowStatusQuery: t.Object({ + deviceCode: t.String(), + claim: t.Optional(t.String()), + }), + startQuery: t.Object({ + profile: t.Optional(t.String()), + setDefault: t.Optional(t.String()), + redirect: t.Optional(t.String()), + }), + callbackQuery: t.Object({ + code: t.Optional(t.String()), + state: t.Optional(t.String()), + error: t.Optional(t.String()), + error_description: t.Optional(t.String()), + }), + refreshBody: t.Object({ + refreshToken: t.String(), + }), + flowStatusParams: t.Object({ + flowId: t.String(), + }), +} as const; + +export type LinearOAuthModel = { + readonly [K in keyof typeof LinearOAuthModel]: UnwrapSchema< + (typeof LinearOAuthModel)[K] + >; +}; diff --git a/services/auth-broker/src/modules/linear-oauth/plugin.ts b/services/auth-broker/src/modules/linear-oauth/plugin.ts new file mode 100644 index 000000000..a3fd6d60a --- /dev/null +++ b/services/auth-broker/src/modules/linear-oauth/plugin.ts @@ -0,0 +1,44 @@ +import { Elysia } from "elysia"; + +import type { BrokerConfig } from "../../config.ts"; +import type { FlowStore } from "../../flow-store.ts"; +import { createLinearOAuthCallbackRoutesPlugin } from "./callback-routes-plugin.ts"; +import { createLinearOAuthFlowStatusRoutesPlugin } from "./flow-status-routes-plugin.ts"; +import { createLinearOAuthRefreshRoutesPlugin } from "./refresh-routes-plugin.ts"; +import { createLinearOAuthStartRoutesPlugin } from "./start-routes-plugin.ts"; + +type CreateLinearOAuthPluginOptions = { + readonly config: BrokerConfig; + readonly flowStore: FlowStore; +}; + +export function createLinearOAuthPlugin({ + config, + flowStore, +}: CreateLinearOAuthPluginOptions) { + return new Elysia({ + name: "hack-auth-broker.linear-oauth", + }) + .use( + createLinearOAuthStartRoutesPlugin({ + config, + flowStore, + }) + ) + .use( + createLinearOAuthCallbackRoutesPlugin({ + config, + flowStore, + }) + ) + .use( + createLinearOAuthRefreshRoutesPlugin({ + config, + }) + ) + .use( + createLinearOAuthFlowStatusRoutesPlugin({ + flowStore, + }) + ); +} diff --git a/services/auth-broker/src/modules/linear-oauth/refresh-routes-plugin.ts b/services/auth-broker/src/modules/linear-oauth/refresh-routes-plugin.ts new file mode 100644 index 000000000..f2603869b --- /dev/null +++ b/services/auth-broker/src/modules/linear-oauth/refresh-routes-plugin.ts @@ -0,0 +1,46 @@ +import { Elysia } from "elysia"; + +import type { BrokerConfig } from "../../config.ts"; +import { LinearOAuthModel } from "./model.ts"; +import { handleRefreshToken } from "./service.ts"; + +type CreateLinearOAuthRefreshRoutesPluginOptions = { + readonly config: BrokerConfig; +}; + +export function createLinearOAuthRefreshRoutesPlugin({ + config, +}: CreateLinearOAuthRefreshRoutesPluginOptions) { + return new Elysia({ + name: "hack-auth-broker.linear-oauth.refresh-routes", + }).post( + "/v1/auth/linear/refresh", + async ({ body, set }) => { + const refreshed = await handleRefreshToken({ + config, + body, + }); + if (!refreshed.ok) { + set.status = refreshed.statusCode; + return { + ok: false, + error: refreshed.error, + } as const; + } + return { + ok: true, + token: refreshed.token, + ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}), + ...(refreshed.refreshToken + ? { refreshToken: refreshed.refreshToken } + : {}), + ...(refreshed.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: refreshed.refreshTokenExpiresAt } + : {}), + } as const; + }, + { + body: LinearOAuthModel.refreshBody, + } + ); +} diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts new file mode 100644 index 000000000..63754f8b0 --- /dev/null +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -0,0 +1,353 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +import type { BrokerConfig } from "../../config.ts"; +import { type FlowStore, hashDeviceCode } from "../../flow-store.ts"; +import { + buildAuthorizeUrl, + exchangeCodeForToken, + fetchIdentity, + refreshAccessToken, +} from "../../linear.ts"; +import type { LinearOAuthModel } from "./model.ts"; + +export type StartFlowPayload = { + readonly flowId: string; + readonly profileId: string; + readonly setDefault: boolean; + readonly authorizeUrl: string; + readonly requestedScopes: string; + readonly deviceCode: string; + readonly pollUrl: string; + readonly expiresAt: string; +}; + +export function createFlow(input: { + readonly config: BrokerConfig; + readonly flowStore: FlowStore; + readonly query: Pick< + LinearOAuthModel["startQuery"], + "profile" | "setDefault" + >; +}): StartFlowPayload { + if (!(input.config.linearClientId && input.config.linearRedirectUri)) { + throw new Error("linear_oauth_not_configured"); + } + const nowMs = Date.now(); + const flowId = randomUUID(); + const state = makeToken(); + const deviceCode = makeToken(); + const profileId = normalizeText(input.query.profile) ?? "default"; + const setDefault = isTruthy(input.query.setDefault); + const expiresAtMs = nowMs + input.config.flowTtlMs; + const codeVerifier = randomBytes(64).toString("base64url"); + const codeChallenge = createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + const authorizeUrl = buildAuthorizeUrl({ + authorizeUrl: input.config.linearAuthorizeUrl, + clientId: input.config.linearClientId, + redirectUri: input.config.linearRedirectUri, + actor: input.config.linearActor, + scopes: input.config.linearScopes, + state, + codeChallenge, + }); + + input.flowStore.createFlow({ + id: flowId, + provider: "linear", + state, + profileId, + setDefault, + deviceCodeHash: hashDeviceCode(deviceCode), + authorizeUrl, + codeVerifier, + createdAtMs: nowMs, + expiresAtMs, + redirectUri: input.config.linearRedirectUri, + status: "pending", + }); + + return { + flowId, + profileId, + setDefault, + authorizeUrl, + requestedScopes: input.config.linearScopes, + deviceCode, + pollUrl: `${input.config.publicBaseUrl}/v1/auth/linear/flows/${flowId}`, + expiresAt: new Date(expiresAtMs).toISOString(), + }; +} + +export async function handleLinearCallback(input: { + readonly config: BrokerConfig; + readonly flowStore: FlowStore; + readonly query: LinearOAuthModel["callbackQuery"]; +}): Promise { + const state = normalizeText(input.query.state); + if (!state) { + return renderCallbackPage({ + title: "Missing state", + body: "This Linear sign-in session is missing a state token.", + statusCode: 400, + }); + } + + const flow = input.flowStore.getByState(state); + if (!(flow && flow.provider === "linear")) { + return renderCallbackPage({ + title: "Session not found", + body: "This Linear sign-in session was not found or already expired.", + statusCode: 404, + }); + } + + const nowMs = Date.now(); + if (nowMs > flow.expiresAtMs) { + input.flowStore.markError({ + flowId: flow.id, + error: "oauth_flow_expired", + status: "expired", + }); + return renderCallbackPage({ + title: "Session expired", + body: "This Linear sign-in session expired. Start a new flow from Hack.", + statusCode: 410, + }); + } + + const oauthError = normalizeText(input.query.error); + if (oauthError) { + const oauthDescription = normalizeText(input.query.error_description); + input.flowStore.markError({ + flowId: flow.id, + error: oauthDescription + ? `${oauthError}: ${oauthDescription}` + : oauthError, + status: "error", + }); + return renderCallbackPage({ + title: "Linear authorization failed", + body: + oauthDescription ?? + "Linear denied access. Return to Hack and try again.", + statusCode: 400, + }); + } + + const code = normalizeText(input.query.code); + if (!code) { + input.flowStore.markError({ + flowId: flow.id, + error: "missing_authorization_code", + status: "error", + }); + return renderCallbackPage({ + title: "Missing authorization code", + body: "Linear did not return a code for this sign-in request.", + statusCode: 400, + }); + } + + const clientId = input.config.linearClientId; + if (!(clientId && flow.codeVerifier)) { + input.flowStore.markError({ + flowId: flow.id, + error: "linear_oauth_not_configured", + status: "error", + }); + return renderCallbackPage({ + title: "Linear OAuth not configured", + body: "The auth broker is missing Linear OAuth configuration.", + statusCode: 412, + }); + } + + const exchange = await exchangeCodeForToken({ + tokenUrl: input.config.linearTokenUrl, + clientId, + ...(input.config.linearClientSecret + ? { clientSecret: input.config.linearClientSecret } + : {}), + code, + redirectUri: flow.redirectUri, + codeVerifier: flow.codeVerifier, + }); + if (!exchange.ok) { + input.flowStore.markError({ + flowId: flow.id, + error: exchange.error, + status: "error", + }); + return renderCallbackPage({ + title: "Token exchange failed", + body: exchange.error, + statusCode: 502, + }); + } + + const identity = await fetchIdentity({ + apiBaseUrl: input.config.linearApiBaseUrl, + token: exchange.token, + }); + if (!identity.ok) { + input.flowStore.markError({ + flowId: flow.id, + error: identity.error, + status: "error", + }); + return renderCallbackPage({ + title: "Linear account lookup failed", + body: identity.error, + statusCode: 502, + }); + } + + input.flowStore.markComplete({ + flowId: flow.id, + account: identity.account, + token: exchange.token, + tokenExpiresAt: exchange.tokenExpiresAt, + refreshToken: exchange.refreshToken, + refreshTokenExpiresAt: exchange.refreshTokenExpiresAt, + }); + + return renderCallbackPage({ + title: "Linear connected", + body: "Authorization is complete. Return to Hack to finish account setup.", + statusCode: 200, + success: true, + }); +} + +export async function handleRefreshToken(input: { + readonly config: BrokerConfig; + readonly body: LinearOAuthModel["refreshBody"]; +}): Promise< + | { + readonly ok: true; + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + } + | { readonly ok: false; readonly error: string; readonly statusCode: number } +> { + const refreshToken = normalizeText(input.body.refreshToken); + if (!refreshToken) { + return { + ok: false, + error: "missing_refresh_token", + statusCode: 400, + }; + } + if (!input.config.linearClientId) { + return { + ok: false, + error: "linear_oauth_not_configured", + statusCode: 412, + }; + } + + const refresh = await refreshAccessToken({ + tokenUrl: input.config.linearTokenUrl, + clientId: input.config.linearClientId, + ...(input.config.linearClientSecret + ? { clientSecret: input.config.linearClientSecret } + : {}), + refreshToken, + }); + if (!refresh.ok) { + return { + ok: false, + error: refresh.error, + statusCode: refresh.statusCode >= 400 ? refresh.statusCode : 502, + }; + } + + return { + ok: true, + token: refresh.token, + ...(refresh.tokenExpiresAt ? { expiresAt: refresh.tokenExpiresAt } : {}), + ...(refresh.refreshToken ? { refreshToken: refresh.refreshToken } : {}), + ...(refresh.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: refresh.refreshTokenExpiresAt } + : {}), + }; +} + +export function isTruthy(value: string | undefined): boolean { + if (!value) { + return false; + } + const normalized = value.trim().toLowerCase(); + return ["1", "true", "yes", "on"].includes(normalized); +} + +export function redirect(location: string): Response { + return new Response(null, { + status: 302, + headers: { + location, + "cache-control": "no-store", + }, + }); +} + +function renderCallbackPage(input: { + readonly title: string; + readonly body: string; + readonly statusCode: number; + readonly success?: boolean; +}): Response { + const html = [ + "", + "", + "", + ' ', + ' ', + ` ${escapeHtml(input.title)}`, + " ", + "", + "", + '
', + `

${escapeHtml(input.title)}

`, + `

${escapeHtml(input.body)}

`, + "
", + "", + "", + ].join("\n"); + return new Response(html, { + status: input.statusCode, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +function normalizeText(value: string | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function makeToken(): string { + return randomBytes(32).toString("base64url"); +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts b/services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts new file mode 100644 index 000000000..182501486 --- /dev/null +++ b/services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts @@ -0,0 +1,64 @@ +import { Elysia } from "elysia"; + +import type { BrokerConfig } from "../../config.ts"; +import type { FlowStore } from "../../flow-store.ts"; +import { LinearOAuthModel } from "./model.ts"; +import { createFlow, isTruthy, redirect } from "./service.ts"; + +type CreateLinearOAuthStartRoutesPluginOptions = { + readonly config: BrokerConfig; + readonly flowStore: FlowStore; +}; + +export function createLinearOAuthStartRoutesPlugin({ + config, + flowStore, +}: CreateLinearOAuthStartRoutesPluginOptions) { + return new Elysia({ + name: "hack-auth-broker.linear-oauth.start-routes", + }) + .all( + "/v1/auth/linear/start", + ({ query, set }) => { + if (!config.linearClientId) { + set.status = 412; + return { + ok: false, + error: "linear_oauth_not_configured", + } as const; + } + const flow = createFlow({ + config, + flowStore, + query, + }); + if (isTruthy(query.redirect)) { + return redirect(flow.authorizeUrl); + } + return { + ok: true, + flow, + } as const; + }, + { query: LinearOAuthModel.startQuery } + ) + .all( + "/linear/start", + ({ query, set }) => { + if (!config.linearClientId) { + set.status = 412; + return { + ok: false, + error: "linear_oauth_not_configured", + } as const; + } + const flow = createFlow({ + config, + flowStore, + query, + }); + return redirect(flow.authorizeUrl); + }, + { query: LinearOAuthModel.startQuery } + ); +} diff --git a/services/auth-broker/src/modules/providers/plugin.ts b/services/auth-broker/src/modules/providers/plugin.ts index 218a7a224..8a54c729b 100644 --- a/services/auth-broker/src/modules/providers/plugin.ts +++ b/services/auth-broker/src/modules/providers/plugin.ts @@ -35,6 +35,21 @@ export function createProvidersPlugin({ appSlug: config.githubAppSlug, appInstallUrl: config.githubAppInstallUrl, }, + { + id: "linear", + enabled: Boolean(config.linearClientId), + mode: "oauth+agent", + requestedScopes: config.linearScopes, + redirectUri: config.linearRedirectUri, + authorizeUrl: config.linearAuthorizeUrl, + tokenUrl: config.linearTokenUrl, + apiBaseUrl: config.linearApiBaseUrl, + webhookPath: config.linearWebhookPath, + developerAppTokenConfigured: Boolean(config.linearDeveloperAppToken), + webhookSignatureVerification: config.linearWebhookSigningSecret + ? "hmac-sha256" + : "disabled", + }, ] as const, })); } diff --git a/services/auth-broker/src/types.ts b/services/auth-broker/src/types.ts index 030f3d335..360f592c8 100644 --- a/services/auth-broker/src/types.ts +++ b/services/auth-broker/src/types.ts @@ -5,12 +5,15 @@ export type FlowStatus = | "expired" | "claimed"; -export type GitHubFlowAccount = { - readonly login: string; +export type OAuthProvider = "github" | "linear"; + +export type OAuthFlowAccount = { + readonly login?: string; + readonly accountHandle?: string; readonly accountName?: string; readonly accountId?: string; readonly accountEmail?: string; - readonly installationIds: readonly string[]; + readonly installationIds?: readonly string[]; readonly betterAuthUserId?: string; readonly betterAuthLinkState?: | "disabled" @@ -21,13 +24,15 @@ export type GitHubFlowAccount = { | "error"; }; -export type GitHubOAuthFlow = { +export type OAuthFlow = { readonly id: string; + readonly provider: OAuthProvider; readonly state: string; readonly profileId: string; readonly setDefault: boolean; readonly deviceCodeHash: string; readonly authorizeUrl: string; + readonly codeVerifier?: string; readonly appId?: string; readonly appSlug?: string; readonly appInstallUrl?: string; @@ -36,16 +41,19 @@ export type GitHubOAuthFlow = { readonly redirectUri: string; installationId?: string; status: FlowStatus; - account?: GitHubFlowAccount; + account?: OAuthFlowAccount; token?: string; tokenExpiresAt?: string; + refreshToken?: string; + refreshTokenExpiresAt?: string; error?: string; completedAt?: string; claimedAt?: string; }; -export type GitHubFlowPublicStatus = { +export type OAuthFlowPublicStatus = { readonly id: string; + readonly provider: OAuthProvider; readonly status: FlowStatus; readonly profileId: string; readonly setDefault: boolean; @@ -56,6 +64,7 @@ export type GitHubFlowPublicStatus = { readonly expiresAt: string; readonly completedAt?: string; readonly claimedAt?: string; + readonly accountHandle?: string; readonly accountLogin?: string; readonly accountName?: string; readonly accountId?: string; @@ -72,5 +81,11 @@ export type GitHubFlowPublicStatus = { readonly installationIds?: readonly string[]; readonly token?: string; readonly tokenExpiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; readonly error?: string; }; + +export type GitHubFlowAccount = OAuthFlowAccount; +export type GitHubOAuthFlow = OAuthFlow; +export type GitHubFlowPublicStatus = OAuthFlowPublicStatus; diff --git a/services/auth-broker/tests/config.test.ts b/services/auth-broker/tests/config.test.ts index 720371d8b..051cbd183 100644 --- a/services/auth-broker/tests/config.test.ts +++ b/services/auth-broker/tests/config.test.ts @@ -9,6 +9,18 @@ const ENV_KEYS = [ "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GITHUB_SCOPES", + "HACK_LINEAR_CLIENT_ID", + "HACK_LINEAR_SECRET", + "HACK_LINEAR_DEVELOPER_APP_TOKEN", + "HACK_LINEAR_OAUTH_ACTOR", + "HACK_LINEAR_WEBHOOK_SECRET", + "HACK_LINEAR_SCOPES", + "HACK_LINEAR_REDIRECT_URI", + "HACK_LINEAR_WEBHOOK_PATH", + "LINEAR_OAUTH_ACTOR", + "LINEAR_CLIENT_ID", + "LINEAR_CLIENT_SECRET", + "LINEAR_WEBHOOK_SIGNING_SECRET", "FLOW_STORE_PATH", "BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS", ] as const; @@ -55,6 +67,68 @@ describe("auth broker config", () => { } ); }); + + test("resolves linear aliases and defaults", () => { + withEnv( + { + GITHUB_CLIENT_ID: "test-client-id", + GITHUB_CLIENT_SECRET: "test-client-secret", + HACK_LINEAR_CLIENT_ID: "hack-linear-client", + HACK_LINEAR_SECRET: "hack-linear-secret", + HACK_LINEAR_WEBHOOK_SECRET: "hook-secret", + HACK_LINEAR_DEVELOPER_APP_TOKEN: "app-token", + HACK_LINEAR_SCOPES: + "read,write,app:mentionable,app:assignable,read,write", + }, + () => { + const config = resolveConfig(); + expect(config.linearClientId).toBe("hack-linear-client"); + expect(config.linearClientSecret).toBe("hack-linear-secret"); + expect(config.linearWebhookSigningSecret).toBe("hook-secret"); + expect(config.linearDeveloperAppToken).toBe("app-token"); + expect(config.linearScopes).toBe( + "read,write,app:mentionable,app:assignable" + ); + expect(config.linearActor).toBe("app"); + expect(config.linearRedirectUri).toBe( + "http://127.0.0.1:8080/linear/callback" + ); + expect(config.linearWebhookPath).toBe("/linear/webhooks"); + } + ); + }); + + test("uses legacy linear env aliases when hack aliases are absent", () => { + withEnv( + { + GITHUB_CLIENT_ID: "test-client-id", + GITHUB_CLIENT_SECRET: "test-client-secret", + LINEAR_CLIENT_ID: "legacy-linear-client", + LINEAR_CLIENT_SECRET: "legacy-linear-secret", + LINEAR_WEBHOOK_SIGNING_SECRET: "legacy-webhook-secret", + }, + () => { + const config = resolveConfig(); + expect(config.linearClientId).toBe("legacy-linear-client"); + expect(config.linearClientSecret).toBe("legacy-linear-secret"); + expect(config.linearWebhookSigningSecret).toBe("legacy-webhook-secret"); + } + ); + }); + + test("normalizes configured linear oauth actor", () => { + withEnv( + { + GITHUB_CLIENT_ID: "test-client-id", + GITHUB_CLIENT_SECRET: "test-client-secret", + HACK_LINEAR_OAUTH_ACTOR: " user ", + }, + () => { + const config = resolveConfig(); + expect(config.linearActor).toBe("user"); + } + ); + }); }); function withEnv(overrides: EnvMap, run: () => void): void { diff --git a/services/auth-broker/tests/flow-store.test.ts b/services/auth-broker/tests/flow-store.test.ts index 33f389dc4..f32c8e24d 100644 --- a/services/auth-broker/tests/flow-store.test.ts +++ b/services/auth-broker/tests/flow-store.test.ts @@ -12,6 +12,7 @@ function createFlow(input: { }): GitHubOAuthFlow { return { id: input.id, + provider: "github", state: `${input.id}-state`, profileId: "default", setDefault: true, @@ -48,6 +49,8 @@ describe("FlowStore persistence", () => { }, token: "gho_test", tokenExpiresAt: new Date(nowMs + 3_600_000).toISOString(), + refreshToken: "ghr_refresh_test", + refreshTokenExpiresAt: new Date(nowMs + 7_200_000).toISOString(), installationId: "123", }); @@ -56,6 +59,10 @@ describe("FlowStore persistence", () => { expect(flow).not.toBeNull(); expect(flow?.status).toBe("complete"); expect(flow?.token).toBe("gho_test"); + expect(flow?.refreshToken).toBe("ghr_refresh_test"); + expect(flow?.refreshTokenExpiresAt).toBe( + new Date(nowMs + 7_200_000).toISOString() + ); expect(flow?.account?.login).toBe("roodboi"); const claimed = reloaded.getStatus({ @@ -70,11 +77,17 @@ describe("FlowStore persistence", () => { } expect(claimed.status.status).toBe("claimed"); expect(claimed.status.token).toBe("gho_test"); + expect(claimed.status.refreshToken).toBe("ghr_refresh_test"); + expect(claimed.status.refreshTokenExpiresAt).toBe( + new Date(nowMs + 7_200_000).toISOString() + ); const afterClaimReload = new FlowStore({ filePath }); const claimedFlow = afterClaimReload.getById("flow-1"); expect(claimedFlow?.status).toBe("claimed"); expect(claimedFlow?.token).toBeUndefined(); + expect(claimedFlow?.refreshToken).toBeUndefined(); + expect(claimedFlow?.refreshTokenExpiresAt).toBeUndefined(); } finally { rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index d9317c1be..7c4ff32a2 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { createHmac } from "node:crypto"; import { FlowStore } from "../src/flow-store.ts"; import { createAuthBrokerApp } from "../src/index.ts"; @@ -30,11 +31,30 @@ function createTestConfig() { githubApiBaseUrl: "https://api.github.com", githubRedirectUri: "http://127.0.0.1:8080/gh/callback", betterAuthGitHubAutoProvisionUsers: false, + linearClientId: "linear-client-id", + linearClientSecret: "linear-client-secret", + linearActor: "app", + linearScopes: "read,write,app:mentionable,app:assignable", + linearAuthorizeUrl: "https://linear.app/oauth/authorize", + linearTokenUrl: "https://api.linear.app/oauth/token", + linearApiBaseUrl: "https://api.linear.app", + linearRedirectUri: "http://127.0.0.1:8080/linear/callback", + linearWebhookPath: "/linear/webhooks", flowTtlMs: 60_000, flowSweepIntervalMs: 60_000, } as const; } +function resolveFetchUrl(input: string | Request | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof Request) { + return input.url; + } + return input.toString(); +} + describe("auth broker github flow routes", () => { test("better auth status endpoint is exposed", async () => { const app = createAuthBrokerApp({ @@ -125,6 +145,373 @@ describe("auth broker github flow routes", () => { expect(payload.error).toBe("test-disabled"); }); + test("providers route includes linear metadata", async () => { + const app = createAuthBrokerApp({ + config: { + ...createTestConfig(), + linearClientId: "linear-client-id", + linearWebhookSigningSecret: "linear-hook-secret", + }, + flowStore: new FlowStore(), + }); + const response = await app.handle( + new Request("http://localhost/v1/auth/providers") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly providers: ReadonlyArray<{ + readonly id: string; + readonly enabled: boolean; + readonly webhookPath?: string; + readonly webhookSignatureVerification?: string; + }>; + }; + const linearProvider = payload.providers.find((p) => p.id === "linear"); + expect(linearProvider).toBeDefined(); + expect(linearProvider?.enabled).toBe(true); + expect(linearProvider?.webhookPath).toBe("/linear/webhooks"); + expect(linearProvider?.webhookSignatureVerification).toBe("hmac-sha256"); + }); + + test("linear start flow requests app actor mode by default", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + }); + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/start?profile=default") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as StartFlowResponse; + expect(payload.ok).toBe(true); + const authorizeUrl = new URL(payload.flow.authorizeUrl); + expect(authorizeUrl.searchParams.get("actor")).toBe("app"); + expect(authorizeUrl.searchParams.get("scope")).toBe( + "read,write,app:mentionable,app:assignable" + ); + }); + + test("linear webhook accepts valid signed payload", async () => { + const secret = "linear-hook-secret"; + const app = createAuthBrokerApp({ + config: { + ...createTestConfig(), + linearWebhookSigningSecret: secret, + }, + flowStore: new FlowStore(), + }); + const rawBody = JSON.stringify({ + action: "create", + type: "Issue", + webhookTimestamp: new Date().toISOString(), + }); + const signature = createHmac("sha256", secret) + .update(rawBody, "utf8") + .digest("hex"); + const response = await app.handle( + new Request("http://localhost/linear/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + "linear-signature": signature, + }, + body: rawBody, + }) + ); + expect(response.status).toBe(202); + const payload = (await response.json()) as { + readonly ok: boolean; + readonly signatureVerified: boolean; + readonly accepted: boolean; + readonly eventType: string | null; + }; + expect(payload.ok).toBe(true); + expect(payload.accepted).toBe(true); + expect(payload.signatureVerified).toBe(true); + expect(payload.eventType).toBe("Issue"); + }); + + test("linear webhook rejects invalid signature", async () => { + const app = createAuthBrokerApp({ + config: { + ...createTestConfig(), + linearWebhookSigningSecret: "linear-hook-secret", + }, + flowStore: new FlowStore(), + }); + const response = await app.handle( + new Request("http://localhost/linear/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + "linear-signature": + "0000000000000000000000000000000000000000000000000000000000000000", + }, + body: JSON.stringify({ + action: "create", + type: "Issue", + webhookTimestamp: new Date().toISOString(), + }), + }) + ); + expect(response.status).toBe(401); + const payload = (await response.json()) as { + readonly ok: boolean; + readonly error: string; + }; + expect(payload.ok).toBe(false); + expect(payload.error).toBe("invalid_linear_signature"); + }); + + test("linear callback route rejects unknown flow state", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + }); + const response = await app.handle( + new Request("http://localhost/linear/callback?code=test&state=state") + ); + expect(response.status).toBe(404); + const html = await response.text(); + expect(html).toContain("Session not found"); + }); + + test("linear webhook legacy alias remains available", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + }); + const response = await app.handle( + new Request("http://localhost/v1/integrations/linear/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ type: "Issue", action: "create" }), + }) + ); + expect(response.status).toBe(202); + const payload = (await response.json()) as { readonly ok: boolean }; + expect(payload.ok).toBe(true); + }); + + test("linear start route issues flow payload and polling reads pending state", async () => { + const flowStore = new FlowStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + }); + + const startResponse = await app.handle( + new Request( + "http://localhost/v1/auth/linear/start?profile=default&setDefault=true" + ) + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + expect(startPayload.ok).toBe(true); + expect(startPayload.flow.authorizeUrl).toContain( + "linear.app/oauth/authorize" + ); + expect(startPayload.flow.authorizeUrl).toContain("code_challenge="); + + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + if (!flow) { + return; + } + + const pollResponse = await app.handle( + new Request( + `http://localhost/v1/auth/linear/flows/${startPayload.flow.flowId}?deviceCode=${encodeURIComponent(startPayload.flow.deviceCode)}` + ) + ); + expect(pollResponse.status).toBe(200); + const pollPayload = (await pollResponse.json()) as { + readonly ok: true; + readonly status: { + readonly status: string; + readonly profileId: string; + readonly setDefault: boolean; + }; + }; + expect(pollPayload.ok).toBe(true); + expect(pollPayload.status.status).toBe("pending"); + expect(pollPayload.status.profileId).toBe("default"); + expect(pollPayload.status.setDefault).toBe(true); + }); + + test("linear callback completes flow and claim returns token", async () => { + const flowStore = new FlowStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + }); + + const startResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/start?profile=work") + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + if (!flow) { + return; + } + + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + const url = resolveFetchUrl(input); + if (url === createTestConfig().linearTokenUrl) { + return new Response( + JSON.stringify({ + access_token: "lin_oauth_token", + expires_in: 3600, + refresh_token: "lin_refresh_token", + refresh_token_expires_in: 86_400, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + } + if (url === createTestConfig().linearApiBaseUrl) { + const bodyText = + typeof init?.body === "string" ? init.body : String(init?.body ?? ""); + if (bodyText.includes("viewer")) { + return new Response( + JSON.stringify({ + data: { + viewer: { + id: "usr_linear", + name: "Linear User", + email: "linear@example.com", + displayName: "Linear User", + }, + }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + } + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + try { + const callbackResponse = await app.handle( + new Request( + `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` + ) + ); + expect(callbackResponse.status).toBe(200); + const callbackHtml = await callbackResponse.text(); + expect(callbackHtml).toContain("Linear connected"); + + const claimResponse = await app.handle( + new Request( + `http://localhost/v1/auth/linear/flows/${startPayload.flow.flowId}?deviceCode=${encodeURIComponent(startPayload.flow.deviceCode)}&claim=1` + ) + ); + expect(claimResponse.status).toBe(200); + const claimPayload = (await claimResponse.json()) as { + readonly ok: true; + readonly status: { + readonly status: string; + readonly token?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly accountEmail?: string; + }; + }; + expect(claimPayload.ok).toBe(true); + expect(claimPayload.status.status).toBe("claimed"); + expect(claimPayload.status.token).toBe("lin_oauth_token"); + expect(claimPayload.status.refreshToken).toBe("lin_refresh_token"); + expect(claimPayload.status.refreshTokenExpiresAt).toBeDefined(); + expect(claimPayload.status.accountEmail).toBe("linear@example.com"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("linear refresh route exchanges refresh token", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + const url = resolveFetchUrl(input); + if (url !== createTestConfig().linearTokenUrl) { + return new Response("not found", { status: 404 }); + } + + const payload = + typeof init?.body === "string" + ? Object.fromEntries(new URLSearchParams(init.body)) + : {}; + expect(init?.headers).toMatchObject({ + "content-type": "application/x-www-form-urlencoded", + }); + expect(payload.grant_type).toBe("refresh_token"); + expect(payload.refresh_token).toBe("lin_refresh_token"); + + return new Response( + JSON.stringify({ + access_token: "lin_refreshed_access_token", + expires_in: 1800, + refresh_token: "lin_rotated_refresh_token", + refresh_token_expires_in: 172_800, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + }) as unknown as typeof fetch; + + try { + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/refresh", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + refreshToken: "lin_refresh_token", + }), + }) + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly token: string; + readonly refreshToken?: string; + readonly expiresAt?: string; + readonly refreshTokenExpiresAt?: string; + }; + expect(payload.ok).toBe(true); + expect(payload.token).toBe("lin_refreshed_access_token"); + expect(payload.refreshToken).toBe("lin_rotated_refresh_token"); + expect(payload.expiresAt).toBeDefined(); + expect(payload.refreshTokenExpiresAt).toBeDefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("start route issues flow payload and polling reads pending state", async () => { const flowStore = new FlowStore(); const app = createAuthBrokerApp({ diff --git a/services/auth-broker/tests/package-manifest.test.ts b/services/auth-broker/tests/package-manifest.test.ts new file mode 100644 index 000000000..44a5204f7 --- /dev/null +++ b/services/auth-broker/tests/package-manifest.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +type PackageJson = { + readonly dependencies?: Record; +}; + +describe("auth broker package manifest", () => { + test("declares runtime packages required by better-auth", () => { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8") + ) as PackageJson; + + expect(packageJson.dependencies?.["better-auth"]).toBeDefined(); + expect( + packageJson.dependencies?.["@better-auth/drizzle-adapter"] + ).toBeDefined(); + }); +}); diff --git a/src/cli/run.ts b/src/cli/run.ts index 9fe04c156..008cfd822 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -42,7 +42,9 @@ export async function runCli(argv: readonly string[]): Promise { } const isExtensionDispatcher = - resolved.command?.name === "x" || resolved.command?.name === "tickets"; + resolved.command?.name === "x" || + resolved.command?.name === "tickets" || + resolved.command?.name === "linear"; if (!isExtensionDispatcher) { // Unknown options (not registered anywhere in the CLI) const unionOptNames = collectUnionOptionNames(cli); @@ -125,7 +127,7 @@ function isExtensionDispatch(opts: { if (token.startsWith("-")) { continue; } - return token === "x" || token === "tickets"; + return token === "x" || token === "tickets" || token === "linear"; } return false; } diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 37468c8d7..1f8a1690b 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -11,6 +11,7 @@ import { gatewayCommand } from "../commands/gateway.ts"; import { globalCommand } from "../commands/global.ts"; import { helpCommand } from "../commands/help.ts"; import { internalCommand } from "../commands/internal.ts"; +import { linearCommand } from "../commands/linear.ts"; import { logPipeCommand } from "../commands/log-pipe.ts"; import { mcpCommand } from "../commands/mcp.ts"; import { nodeCommand } from "../commands/node.ts"; @@ -67,6 +68,7 @@ export const CLI_SPEC = defineCli({ openCommand, branchCommand, logPipeCommand, + linearCommand, doctorCommand, crashCaptureCommand, daemonCommand, diff --git a/src/commands/env.ts b/src/commands/env.ts index 4a613054d..844d0f8da 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -27,6 +27,7 @@ import { import { resolveRegisteredProjectByName } from "../lib/projects-registry.ts"; import { formatSecretStoreDescriptor, + provisionEncryptedFileKey, resolveSecretStore, } from "../lib/secret-store.ts"; import { display } from "../ui/display.ts"; @@ -89,6 +90,21 @@ const optStorePath = defineOption({ description: "Encrypted file path when backend is encrypted_file", } as const); +const optKeyPath = defineOption({ + name: "keyPath", + type: "string", + long: "--key-path", + valueHint: "", + description: "Stable key file path when backend is encrypted_file", +} as const); + +const optProvisionKey = defineOption({ + name: "provisionKey", + type: "boolean", + long: "--provision-key", + description: "Provision a stable key file for encrypted_file backend", +} as const); + const optSecretProject = defineOption({ name: "secretProject", type: "string", @@ -130,6 +146,8 @@ const backendUseSpec = defineCommand({ options: [ optProvider, optStorePath, + optKeyPath, + optProvisionKey, optSecretProject, optSecretPrefix, optJson, @@ -141,6 +159,7 @@ const backendUseSpec = defineCommand({ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; const SECRET_BACKEND_VALUES = ["keychain", "encrypted_file", "cloud"] as const; const CLOUD_PROVIDER_VALUES = ["aws", "gcp", "azure", "vault"] as const; +const ENCRYPTED_FILE_KEY_ENV = "HACK_SECRETS_FILE_KEY"; async function resolveProjectForEnv(opts: { readonly ctx: CliContext; @@ -366,6 +385,7 @@ const handleEnvBackendStatus: CommandHandlerFor< backend: secretsConfig.backend, allow_env_auth_refs: secretsConfig.allowEnvAuthRefs, encrypted_file: secretsConfig.encryptedFile, + encrypted_file_key_env: ENCRYPTED_FILE_KEY_ENV, cloud: secretsConfig.cloud, }, null, @@ -384,6 +404,8 @@ const handleEnvBackendStatus: CommandHandlerFor< secretsConfig.allowEnvAuthRefs ? "true" : "false", ], ["encrypted_file_path", secretsConfig.encryptedFile.path], + ["encrypted_file_key_path", secretsConfig.encryptedFile.keyPath], + ["encrypted_file_key_env", ENCRYPTED_FILE_KEY_ENV], ["cloud_provider", secretsConfig.cloud.provider ?? ""], ["cloud_project", secretsConfig.cloud.project ?? ""], ["cloud_secret_prefix", secretsConfig.cloud.secretPrefix], @@ -430,6 +452,13 @@ const handleEnvBackendUse: CommandHandlerFor = async ({ }); } + if (backend === "encrypted_file" && args.options.keyPath?.trim()) { + await updateGlobalConfig({ + path: "controlPlane.secrets.encryptedFile.keyPath", + value: args.options.keyPath.trim(), + }); + } + if (backend === "cloud") { if (!providerRaw) { throw new CliUsageError( @@ -456,6 +485,16 @@ const handleEnvBackendUse: CommandHandlerFor = async ({ const controlPlane = await readControlPlaneConfig({}); const secretsConfig = controlPlane.config.secrets; + let provisionedKey: { + readonly keyPath: string; + readonly source: "env" | "file" | "keychain" | "generated"; + } | null = null; + if (backend === "encrypted_file" && args.options.provisionKey) { + provisionedKey = await provisionEncryptedFileKey({ + keyPath: secretsConfig.encryptedFile.keyPath, + storePath: secretsConfig.encryptedFile.path, + }); + } if (args.options.json) { process.stdout.write( `${JSON.stringify( @@ -463,6 +502,8 @@ const handleEnvBackendUse: CommandHandlerFor = async ({ backend: secretsConfig.backend, allow_env_auth_refs: secretsConfig.allowEnvAuthRefs, encrypted_file: secretsConfig.encryptedFile, + encrypted_file_key_env: ENCRYPTED_FILE_KEY_ENV, + encrypted_file_key_provisioned: provisionedKey, cloud: secretsConfig.cloud, }, null, @@ -477,15 +518,26 @@ const handleEnvBackendUse: CommandHandlerFor = async ({ entries: [ ["backend", secretsConfig.backend], ["encrypted_file_path", secretsConfig.encryptedFile.path], + ["encrypted_file_key_path", secretsConfig.encryptedFile.keyPath], + ["encrypted_file_key_env", ENCRYPTED_FILE_KEY_ENV], + ["encrypted_file_key_source", provisionedKey?.source ?? ""], ["cloud_provider", secretsConfig.cloud.provider ?? ""], ["cloud_project", secretsConfig.cloud.project ?? ""], ["cloud_secret_prefix", secretsConfig.cloud.secretPrefix], ], }); - logger.info({ - message: - "Secret writes now use the configured backend for keychain-sourced env values.", - }); + if (backend === "encrypted_file") { + logger.info({ + message: provisionedKey + ? `Provisioned stable encrypted backend key at ${provisionedKey.keyPath} (${provisionedKey.source}).` + : `Set ${ENCRYPTED_FILE_KEY_ENV} or provision ${secretsConfig.encryptedFile.keyPath} to avoid repeated macOS keychain prompts for encrypted file backend key access.`, + }); + } else { + logger.info({ + message: + "Secret writes now use the configured backend for keychain-sourced env values.", + }); + } return 0; }; diff --git a/src/commands/linear.ts b/src/commands/linear.ts new file mode 100644 index 000000000..7c3426f9c --- /dev/null +++ b/src/commands/linear.ts @@ -0,0 +1,183 @@ +import type { CliContext, CommandArgs } from "../cli/command.ts"; +import { defineCommand, withHandler } from "../cli/command.ts"; +import { loadExtensionManagerForCli } from "../control-plane/extensions/cli.ts"; +import { + buildEnableInstructions, + maybeEnableExtension, +} from "../control-plane/extensions/enable.ts"; +import { display } from "../ui/display.ts"; +import { logger } from "../ui/logger.ts"; + +/** + * Top-level Linear command that delegates to the linear extension. + */ +const linearSpec = defineCommand({ + name: "linear", + summary: "Linear account connection and ticket sync", + description: [ + "Usage:", + " hack linear status", + " hack linear connect --profile work --set-default", + " hack linear connect --profile default --stdin", + " hack linear oauth-connect --profile work --set-default", + " hack linear project-bind --project-id ", + " hack linear sync-issue --from linear --issue ENG-123", + " hack linear sync-issue --from hack --ticket T-00001", + " hack linear sync-project --from linear --project-id ", + "", + "Alias for `hack x linear `. Requires extension enabled.", + ].join("\n"), + group: "Project", + options: [], + positionals: [{ name: "args", required: false, multiple: true }], + subcommands: [], +} as const); + +type LinearArgs = CommandArgs; + +export const linearCommand = withHandler(linearSpec, handleLinear); + +async function handleLinear({ + ctx, + args, +}: { + readonly ctx: CliContext; + readonly args: LinearArgs; +}): Promise { + const invocation = parseInvocation({ argv: args.raw.argv }); + const allowWhenDisabled = shouldAllowWhenExtensionDisabled({ + command: invocation.command, + }); + + const loaded = await loadExtensionManagerForCli({ cwd: ctx.cwd }); + if (loaded.configError) { + logger.warn({ + message: `Control plane config error: ${loaded.configError}`, + }); + } + for (const warning of loaded.warnings) { + logger.warn({ message: warning }); + } + + const extension = loaded.manager.getExtensionByNamespace({ + namespace: "linear", + }); + if (!extension) { + logger.error({ message: "Linear extension not found. Is it registered?" }); + return 1; + } + + if (!(extension.enabled || allowWhenDisabled)) { + const instructions = buildEnableInstructions({ + extension, + namespace: "linear", + command: invocation.command, + args: invocation.args, + }); + await display.panel({ + title: "Extension disabled", + tone: "warn", + lines: instructions.lines, + }); + + const didEnable = await maybeEnableExtension({ + extension, + namespace: "linear", + command: invocation.command, + args: invocation.args, + projectDir: loaded.context.project?.projectDir, + }); + if (!didEnable) { + return 1; + } + } + + const reloaded = await loadExtensionManagerForCli({ cwd: ctx.cwd }); + const reloadedExtension = reloaded.manager.getExtensionByNamespace({ + namespace: "linear", + }); + if (!reloadedExtension) { + logger.error({ message: "Linear extension not found after reload." }); + return 1; + } + + if (!invocation.command || invocation.command === "help") { + await renderHelp({ + commands: reloaded.manager.listCommands({ namespace: "linear" }), + }); + return 0; + } + + const resolved = reloaded.manager.resolveCommand({ + namespace: "linear", + commandName: invocation.command, + }); + if (!resolved) { + logger.error({ message: `Unknown linear command: ${invocation.command}` }); + await renderHelp({ + commands: reloaded.manager.listCommands({ namespace: "linear" }), + }); + return 1; + } + + return await resolved.command.handler({ + ctx: reloaded.context, + args: invocation.args, + }); +} + +function shouldAllowWhenExtensionDisabled(input: { + readonly command?: string; +}): boolean { + const command = input.command ?? ""; + return ( + command === "setup" || + command === "project-bind" || + command === "connect" || + command === "oauth-connect" || + command === "disconnect" || + command === "status" || + command === "profiles" || + command === "use" || + command === "projects" + ); +} + +type Invocation = { + readonly command?: string; + readonly args: readonly string[]; +}; + +function parseInvocation(input: { + readonly argv: readonly string[]; +}): Invocation { + const index = input.argv.indexOf("linear"); + if (index === -1) { + return { args: [] }; + } + const command = input.argv[index + 1]; + const rawArgs = input.argv.slice(index + 2); + const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + return { command, args }; +} + +async function renderHelp(input: { + readonly commands: readonly { + readonly name: string; + readonly summary: string; + readonly commandId: string; + }[]; +}): Promise { + if (input.commands.length === 0) { + await display.panel({ + title: "Linear", + tone: "info", + lines: ["No commands available."], + }); + return; + } + await display.table({ + columns: ["Command", "Summary"], + rows: input.commands.map((cmd) => [`hack linear ${cmd.name}`, cmd.summary]), + }); +} diff --git a/src/control-plane/extensions/builtins.ts b/src/control-plane/extensions/builtins.ts index 789a7291e..e312f91c1 100644 --- a/src/control-plane/extensions/builtins.ts +++ b/src/control-plane/extensions/builtins.ts @@ -1,6 +1,7 @@ import { CLOUDFLARE_EXTENSION } from "./cloudflare/extension.ts"; import { GATEWAY_EXTENSION } from "./gateway/extension.ts"; import { GITHUB_EXTENSION } from "./github/extension.ts"; +import { LINEAR_EXTENSION } from "./linear/extension.ts"; import { SUPERVISOR_EXTENSION } from "./supervisor/extension.ts"; import { TAILSCALE_EXTENSION } from "./tailscale/extension.ts"; import { TICKETS_EXTENSION } from "./tickets/extension.ts"; @@ -10,6 +11,7 @@ export const BUILTIN_EXTENSIONS = [ SUPERVISOR_EXTENSION, GATEWAY_EXTENSION, GITHUB_EXTENSION, + LINEAR_EXTENSION, CLOUDFLARE_EXTENSION, TAILSCALE_EXTENSION, ] as const; diff --git a/src/control-plane/extensions/linear/auth.ts b/src/control-plane/extensions/linear/auth.ts new file mode 100644 index 000000000..08c99873e --- /dev/null +++ b/src/control-plane/extensions/linear/auth.ts @@ -0,0 +1,925 @@ +import { secrets } from "bun"; + +import { isRecord } from "../../../lib/guards.ts"; +import type { ControlPlaneConfig } from "../../sdk/config.ts"; + +const LINEAR_EXTENSION_ID = "dance.hack.linear"; +const DEFAULT_LINEAR_PROFILE_ID = "default"; +const DEFAULT_LINEAR_TOKEN_ENV = "HACK_LINEAR_API_TOKEN"; +const DEFAULT_LINEAR_AUTH_REF = "linear.api.default"; +const DEFAULT_LINEAR_SECRET_SERVICE = "hack-linear-auth"; +const DEFAULT_LINEAR_API_URL = "https://api.linear.app/graphql"; +const TRAILING_SLASH_REGEX = /\/+$/; + +export type LinearProfileSelectionSource = + | "command_flags" + | "project_routing" + | "global_default" + | "implicit_default"; + +export type LinearAuthSettings = { + readonly profileId: string; + readonly profileSource: LinearProfileSelectionSource; + readonly tokenEnv: string; + readonly authRef: string; + readonly service: string; + readonly apiUrl: string; + readonly accountId?: string; + readonly accountName?: string; + readonly accountEmail?: string; +}; + +export type LinearAuthProfileSummary = { + readonly id: string; + readonly isDefault: boolean; + readonly authRef: string; + readonly service: string; + readonly tokenEnv: string; + readonly apiUrl: string; + readonly accountId?: string; + readonly accountName?: string; + readonly accountEmail?: string; +}; + +export type LinearAuthProfileCatalog = { + readonly defaultProfileId: string; + readonly selectedProfileId: string; + readonly selectedProfileSource: LinearProfileSelectionSource; + readonly selectedProfileMissing: boolean; + readonly projectProfileOverride?: string; + readonly profiles: readonly LinearAuthProfileSummary[]; +}; + +export type LinearAuthSettingsResult = + | { + readonly ok: true; + readonly settings: LinearAuthSettings; + readonly availableProfileIds: readonly string[]; + } + | { + readonly ok: false; + readonly selectedProfileId: string; + readonly selectedProfileSource: LinearProfileSelectionSource; + readonly availableProfileIds: readonly string[]; + readonly error: string; + }; + +export type LinearTokenResolution = + | { + readonly ok: true; + readonly token: string; + readonly source: "keychain" | "env" | "refreshed"; + readonly tokenEnv: string; + readonly authRef: string; + readonly service: string; + readonly profileId: string; + readonly profileSource: LinearProfileSelectionSource; + readonly expiresAt?: string; + } + | { + readonly ok: false; + readonly error: string; + readonly tokenEnv: string; + readonly authRef: string; + readonly service: string; + readonly profileId: string; + readonly profileSource: LinearProfileSelectionSource; + }; + +export type LinearRefreshConfig = { + readonly baseUrl: string; + readonly fetch?: typeof fetch; +}; + +export type SecretStore = { + readonly get: (input: { + readonly service: string; + readonly name: string; + }) => Promise; + readonly set: (input: { + readonly service: string; + readonly name: string; + readonly value: string; + }) => Promise; + readonly delete: (input: { + readonly service: string; + readonly name: string; + }) => Promise; +}; + +type LinearProfileSettings = { + readonly profileId: string; + readonly tokenEnv: string; + readonly authRef: string; + readonly service: string; + readonly apiUrl: string; + readonly accountId?: string; + readonly accountName?: string; + readonly accountEmail?: string; +}; + +type LinearTokenEnvelope = { + readonly token?: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; +}; + +type LinearProfileSelection = { + readonly selectedProfileId: string; + readonly selectedProfileSource: LinearProfileSelectionSource; + readonly selectedProfileExists: boolean; + readonly defaultProfileId: string; + readonly sortedProfileIds: readonly string[]; + readonly profilesById: Readonly>; + readonly projectProfileOverride?: string; +}; + +const DEFAULT_SECRET_STORE: SecretStore = { + get: async (input) => await secrets.get(input), + set: async (input) => { + await secrets.set(input); + }, + delete: async (input) => await secrets.delete(input), +}; + +const TOKEN_REFRESH_WINDOW_MS = 60_000; + +/** + * Resolve Linear profile catalog using command flags, project overrides, and global defaults. + */ +export function listLinearAuthProfiles(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly explicitProfileId?: string; + readonly allowProjectOverride?: boolean; +}): LinearAuthProfileCatalog { + const selected = resolveLinearProfileSelection({ + controlPlaneConfig: input.controlPlaneConfig, + explicitProfileId: input.explicitProfileId, + allowProjectOverride: input.allowProjectOverride, + }); + const profiles: LinearAuthProfileSummary[] = []; + for (const id of selected.sortedProfileIds) { + const profile = selected.profilesById[id]; + if (!profile) { + continue; + } + profiles.push({ + id: profile.profileId, + isDefault: id === selected.defaultProfileId, + authRef: profile.authRef, + service: profile.service, + tokenEnv: profile.tokenEnv, + apiUrl: profile.apiUrl, + ...(profile.accountId ? { accountId: profile.accountId } : {}), + ...(profile.accountName ? { accountName: profile.accountName } : {}), + ...(profile.accountEmail ? { accountEmail: profile.accountEmail } : {}), + }); + } + + return { + defaultProfileId: selected.defaultProfileId, + selectedProfileId: selected.selectedProfileId, + selectedProfileSource: selected.selectedProfileSource, + selectedProfileMissing: !selected.selectedProfileExists, + ...(selected.projectProfileOverride + ? { projectProfileOverride: selected.projectProfileOverride } + : {}), + profiles, + }; +} + +/** + * Resolve Linear auth settings from extension config + profile selection. + */ +export function resolveLinearAuthSettings(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; +}): LinearAuthSettings { + const selected = resolveLinearProfileSelection({ + controlPlaneConfig: input.controlPlaneConfig, + explicitProfileId: input.profileId, + allowProjectOverride: input.allowProjectOverride, + }); + const profile = selected.profilesById[selected.selectedProfileId]; + if (profile) { + return { + ...profile, + profileSource: selected.selectedProfileSource, + }; + } + + const extensionConfig = getLinearExtensionConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const fallback = buildLinearProfileSettings({ + profileId: selected.selectedProfileId, + profileConfig: null, + legacyConfig: extensionConfig, + includeLegacyFallback: true, + }); + return { + ...fallback, + profileSource: selected.selectedProfileSource, + }; +} + +/** + * Resolve Linear auth settings and fail when selected profile is missing. + */ +export function resolveLinearAuthSettingsResult(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; +}): LinearAuthSettingsResult { + const selected = resolveLinearProfileSelection({ + controlPlaneConfig: input.controlPlaneConfig, + explicitProfileId: input.profileId, + allowProjectOverride: input.allowProjectOverride, + }); + const availableProfileIds = selected.sortedProfileIds; + const selectedProfile = selected.profilesById[selected.selectedProfileId]; + if (!selectedProfile) { + const sourceLabel = (() => { + if (selected.selectedProfileSource === "command_flags") { + return "--profile"; + } + if (selected.selectedProfileSource === "project_routing") { + return "controlPlane.routing.overrides.linear.profile"; + } + if (selected.selectedProfileSource === "global_default") { + return `controlPlane.extensions["${LINEAR_EXTENSION_ID}"].config.defaultProfile`; + } + return "implicit default"; + })(); + const availableLabel = + availableProfileIds.length > 0 + ? availableProfileIds.join(", ") + : "(none configured)"; + return { + ok: false, + selectedProfileId: selected.selectedProfileId, + selectedProfileSource: selected.selectedProfileSource, + availableProfileIds, + error: `Linear profile "${selected.selectedProfileId}" (${sourceLabel}) was not found. Available profiles: ${availableLabel}.`, + }; + } + + return { + ok: true, + settings: { + ...selectedProfile, + profileSource: selected.selectedProfileSource, + }, + availableProfileIds, + }; +} + +/** + * Resolve a Linear API token using keychain first, then env fallback. + */ +export async function resolveLinearToken(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; + readonly env?: Record; + readonly store?: SecretStore; + readonly refreshConfig?: LinearRefreshConfig; + readonly nowMs?: number; +}): Promise { + const resolved = resolveLinearAuthSettingsResult({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + }); + + if (!resolved.ok) { + const fallback = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + }); + return { + ok: false, + error: resolved.error, + tokenEnv: fallback.tokenEnv, + authRef: fallback.authRef, + service: fallback.service, + profileId: fallback.profileId, + profileSource: fallback.profileSource, + }; + } + + const settings = resolved.settings; + const env = input.env ?? process.env; + const store = input.store ?? DEFAULT_SECRET_STORE; + const nowMs = input.nowMs ?? Date.now(); + + const stored = await store.get({ + service: settings.service, + name: settings.authRef, + }); + let storedEnvelope = parseStoredTokenEnvelope(stored); + let refreshError: string | null = null; + let refreshedToken = false; + if ( + storedEnvelope && + shouldRefreshStoredToken({ + envelope: storedEnvelope, + nowMs, + }) && + input.refreshConfig + ) { + const refreshed = await refreshLinearAccessToken({ + refreshConfig: input.refreshConfig, + refreshToken: storedEnvelope.refreshToken ?? "", + currentRefreshTokenExpiresAt: storedEnvelope.refreshTokenExpiresAt, + }); + if (refreshed.ok) { + refreshedToken = true; + const nextRefreshToken = + refreshed.refreshToken ?? storedEnvelope.refreshToken; + const nextRefreshTokenExpiresAt = + refreshed.refreshTokenExpiresAt ?? storedEnvelope.refreshTokenExpiresAt; + storedEnvelope = { + token: refreshed.token, + ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}), + ...(nextRefreshToken ? { refreshToken: nextRefreshToken } : {}), + ...(nextRefreshTokenExpiresAt + ? { refreshTokenExpiresAt: nextRefreshTokenExpiresAt } + : {}), + }; + try { + await store.set({ + service: settings.service, + name: settings.authRef, + value: serializeStoredToken(storedEnvelope), + }); + } catch { + // Best effort: use the fresh token for this command even if re-persist fails. + } + } else if ( + isUsableStoredAccessToken({ + envelope: storedEnvelope, + nowMs, + }) + ) { + return { + ok: true, + token: storedEnvelope.token ?? "", + source: "keychain", + tokenEnv: settings.tokenEnv, + authRef: settings.authRef, + service: settings.service, + profileId: settings.profileId, + profileSource: settings.profileSource, + ...(storedEnvelope.expiresAt + ? { expiresAt: storedEnvelope.expiresAt } + : {}), + }; + } else { + refreshError = refreshed.error; + } + } + + if ( + storedEnvelope?.token && + isUsableStoredAccessToken({ envelope: storedEnvelope, nowMs }) + ) { + return { + ok: true, + token: storedEnvelope.token, + source: refreshedToken ? "refreshed" : "keychain", + tokenEnv: settings.tokenEnv, + authRef: settings.authRef, + service: settings.service, + profileId: settings.profileId, + profileSource: settings.profileSource, + ...(storedEnvelope.expiresAt + ? { expiresAt: storedEnvelope.expiresAt } + : {}), + }; + } + + const envToken = (env[settings.tokenEnv] ?? "").trim(); + if (envToken) { + return { + ok: true, + token: envToken, + source: "env", + tokenEnv: settings.tokenEnv, + authRef: settings.authRef, + service: settings.service, + profileId: settings.profileId, + profileSource: settings.profileSource, + }; + } + + return { + ok: false, + error: + refreshError ?? + `Missing Linear token for profile "${settings.profileId}". Store one with \`hack x linear connect --profile ${settings.profileId}\`, or set ${settings.tokenEnv}.`, + tokenEnv: settings.tokenEnv, + authRef: settings.authRef, + service: settings.service, + profileId: settings.profileId, + profileSource: settings.profileSource, + }; +} + +/** + * Persist a Linear token in keychain under the selected profile auth reference. + */ +export async function saveLinearToken(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly authRef?: string; + readonly service?: string; + readonly store?: SecretStore; +}): Promise<{ + readonly profileId: string; + readonly authRef: string; + readonly service: string; +}> { + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + }); + const authRef = (input.authRef ?? settings.authRef).trim(); + const service = (input.service ?? settings.service).trim(); + const token = input.token.trim(); + const store = input.store ?? DEFAULT_SECRET_STORE; + + if (!authRef) { + throw new Error("Missing Linear authRef"); + } + if (!service) { + throw new Error("Missing Linear secret service"); + } + if (!token) { + throw new Error("Missing Linear token"); + } + + const serialized = serializeStoredToken({ + token, + ...(input.expiresAt ? { expiresAt: input.expiresAt.trim() } : {}), + ...(input.refreshToken ? { refreshToken: input.refreshToken.trim() } : {}), + ...(input.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: input.refreshTokenExpiresAt.trim() } + : {}), + }); + await store.set({ + service, + name: authRef, + value: serialized, + }); + + return { + profileId: settings.profileId, + authRef, + service, + }; +} + +/** + * Delete a Linear token from keychain for the selected profile. + */ +export async function deleteLinearToken(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; + readonly authRef?: string; + readonly service?: string; + readonly store?: SecretStore; +}): Promise<{ + readonly profileId: string; + readonly deleted: boolean; + readonly authRef: string; + readonly service: string; +}> { + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + }); + const authRef = (input.authRef ?? settings.authRef).trim(); + const service = (input.service ?? settings.service).trim(); + const store = input.store ?? DEFAULT_SECRET_STORE; + + if (!authRef) { + throw new Error("Missing Linear authRef"); + } + if (!service) { + throw new Error("Missing Linear secret service"); + } + + const deleted = await store.delete({ + service, + name: authRef, + }); + + return { + profileId: settings.profileId, + deleted, + authRef, + service, + }; +} + +function getLinearExtensionConfig(input: { + readonly controlPlaneConfig: ControlPlaneConfig; +}): Record | null { + const extension = input.controlPlaneConfig.extensions?.[LINEAR_EXTENSION_ID]; + if (!(isRecord(extension) && isRecord(extension.config))) { + return null; + } + return extension.config; +} + +function resolveProjectLinearProfileOverride(input: { + readonly controlPlaneConfig: ControlPlaneConfig; +}): string | null { + const routingOverrides = input.controlPlaneConfig.routing?.overrides; + if (!isRecord(routingOverrides)) { + return null; + } + const nested = routingOverrides.linear; + if (isRecord(nested)) { + const nestedProfile = normalizeConfigString(nested.profile); + if (nestedProfile) { + return nestedProfile; + } + } + return normalizeConfigString(routingOverrides.linearProfile); +} + +function resolveLinearProfileSelection(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly explicitProfileId?: string; + readonly allowProjectOverride?: boolean; +}): LinearProfileSelection { + const extensionConfig = getLinearExtensionConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const profilesById: Record = {}; + + const profilesValue = extensionConfig?.profiles; + if (isRecord(profilesValue)) { + for (const [profileIdRaw, profileRaw] of Object.entries(profilesValue)) { + const profileId = profileIdRaw.trim(); + if (!(profileId && isRecord(profileRaw))) { + continue; + } + profilesById[profileId] = buildLinearProfileSettings({ + profileId, + profileConfig: profileRaw, + legacyConfig: extensionConfig, + includeLegacyFallback: profileId === DEFAULT_LINEAR_PROFILE_ID, + }); + } + } + + if (Object.keys(profilesById).length === 0) { + profilesById[DEFAULT_LINEAR_PROFILE_ID] = buildLinearProfileSettings({ + profileId: DEFAULT_LINEAR_PROFILE_ID, + profileConfig: null, + legacyConfig: extensionConfig, + includeLegacyFallback: true, + }); + } + + const sortedProfileIds = Object.keys(profilesById).sort((left, right) => + left.localeCompare(right) + ); + + const defaultProfileFromConfig = normalizeConfigString( + extensionConfig?.defaultProfile + ); + const defaultProfileId = + (defaultProfileFromConfig && + Object.hasOwn(profilesById, defaultProfileFromConfig) + ? defaultProfileFromConfig + : null) ?? + (Object.hasOwn(profilesById, DEFAULT_LINEAR_PROFILE_ID) + ? DEFAULT_LINEAR_PROFILE_ID + : null) ?? + sortedProfileIds[0] ?? + DEFAULT_LINEAR_PROFILE_ID; + + const explicitProfileId = normalizeConfigString(input.explicitProfileId); + const projectProfileOverride = + input.allowProjectOverride === false + ? null + : resolveProjectLinearProfileOverride({ + controlPlaneConfig: input.controlPlaneConfig, + }); + + const selectedProfile = explicitProfileId ?? projectProfileOverride; + let selectedProfileId: string; + let selectedProfileSource: LinearProfileSelectionSource; + if (selectedProfile) { + selectedProfileId = selectedProfile; + selectedProfileSource = explicitProfileId + ? "command_flags" + : "project_routing"; + } else if (defaultProfileFromConfig) { + selectedProfileId = defaultProfileId; + selectedProfileSource = "global_default"; + } else { + selectedProfileId = defaultProfileId; + selectedProfileSource = "implicit_default"; + } + + const selectedProfileExists = Object.hasOwn(profilesById, selectedProfileId); + return { + selectedProfileId, + selectedProfileSource, + selectedProfileExists, + defaultProfileId, + sortedProfileIds, + profilesById, + ...(projectProfileOverride ? { projectProfileOverride } : {}), + }; +} + +function buildLinearProfileSettings(input: { + readonly profileId: string; + readonly profileConfig: Record | null; + readonly legacyConfig: Record | null; + readonly includeLegacyFallback: boolean; +}): LinearProfileSettings { + const fallback = input.includeLegacyFallback ? input.legacyConfig : null; + const tokenEnv = + normalizeConfigString(input.profileConfig?.tokenEnv) ?? + normalizeConfigString(fallback?.tokenEnv) ?? + DEFAULT_LINEAR_TOKEN_ENV; + const authRef = + normalizeConfigString(input.profileConfig?.authRef) ?? + normalizeConfigString(fallback?.authRef) ?? + resolveDefaultAuthRefForProfile({ profileId: input.profileId }); + const service = + normalizeConfigString(input.profileConfig?.service) ?? + normalizeConfigString(fallback?.service) ?? + DEFAULT_LINEAR_SECRET_SERVICE; + const apiUrl = normalizeApiUrl({ + value: + normalizeConfigString(input.profileConfig?.apiUrl) ?? + normalizeConfigString(fallback?.apiUrl) ?? + DEFAULT_LINEAR_API_URL, + }); + const accountId = + normalizeConfigString(input.profileConfig?.accountId) ?? + normalizeConfigString(fallback?.accountId); + const accountName = + normalizeConfigString(input.profileConfig?.accountName) ?? + normalizeConfigString(fallback?.accountName); + const accountEmail = + normalizeConfigString(input.profileConfig?.accountEmail) ?? + normalizeConfigString(fallback?.accountEmail); + + return { + profileId: input.profileId, + tokenEnv, + authRef, + service, + apiUrl, + ...(accountId ? { accountId } : {}), + ...(accountName ? { accountName } : {}), + ...(accountEmail ? { accountEmail } : {}), + }; +} + +function serializeStoredToken(input: LinearTokenEnvelope): string { + return JSON.stringify({ + ...(input.token ? { token: input.token } : {}), + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + ...(input.refreshToken ? { refreshToken: input.refreshToken } : {}), + ...(input.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: input.refreshTokenExpiresAt } + : {}), + }); +} + +function parseStoredTokenEnvelope( + value: string | null +): LinearTokenEnvelope | null { + const text = value?.trim() ?? ""; + if (!text) { + return null; + } + try { + const parsed = JSON.parse(text) as unknown; + if (!isRecord(parsed)) { + return { token: text }; + } + const token = + typeof parsed.token === "string" && parsed.token.trim().length > 0 + ? parsed.token.trim() + : undefined; + const refreshToken = + typeof parsed.refreshToken === "string" && + parsed.refreshToken.trim().length > 0 + ? parsed.refreshToken.trim() + : undefined; + if (!(token || refreshToken)) { + return null; + } + return { + ...(token ? { token } : {}), + ...(typeof parsed.expiresAt === "string" + ? { expiresAt: parsed.expiresAt } + : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(typeof parsed.refreshTokenExpiresAt === "string" + ? { refreshTokenExpiresAt: parsed.refreshTokenExpiresAt } + : {}), + }; + } catch { + return { token: text }; + } +} + +function shouldRefreshStoredToken(input: { + readonly envelope: LinearTokenEnvelope; + readonly nowMs: number; +}): boolean { + if (!input.envelope.refreshToken) { + return false; + } + if (!input.envelope.token) { + return true; + } + const expiresAtMs = parseTimestampMs(input.envelope.expiresAt); + if (expiresAtMs === null) { + return false; + } + return expiresAtMs <= input.nowMs + TOKEN_REFRESH_WINDOW_MS; +} + +function isUsableStoredAccessToken(input: { + readonly envelope: LinearTokenEnvelope; + readonly nowMs: number; +}): boolean { + if (!input.envelope.token) { + return false; + } + const expiresAtMs = parseTimestampMs(input.envelope.expiresAt); + if (expiresAtMs === null) { + return true; + } + return expiresAtMs > input.nowMs; +} + +async function refreshLinearAccessToken(input: { + readonly refreshConfig: LinearRefreshConfig; + readonly refreshToken: string; + readonly currentRefreshTokenExpiresAt?: string; +}): Promise< + | { + readonly ok: true; + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + } + | { readonly ok: false; readonly error: string } +> { + const fetchImpl = input.refreshConfig.fetch ?? fetch; + const endpoint = new URL( + "/v1/auth/linear/refresh", + `${trimTrailingSlash(input.refreshConfig.baseUrl)}/` + ).toString(); + let response: Response; + try { + response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + }, + body: JSON.stringify({ + refreshToken: input.refreshToken, + }), + }); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + + const payload = (await response.json().catch(() => null)) as unknown; + if (!isRecord(payload)) { + return { + ok: false, + error: `Linear refresh failed: invalid payload (${response.status})`, + }; + } + if (!response.ok || payload.ok === false) { + const message = + readOptionalString(payload.error) ?? + response.statusText ?? + "refresh_failed"; + return { + ok: false, + error: `Linear refresh failed: ${message}`, + }; + } + const token = readOptionalString(payload.token); + if (!token) { + return { + ok: false, + error: "Linear refresh failed: missing token", + }; + } + return { + ok: true, + token, + ...((readOptionalString(payload.tokenExpiresAt) ?? + readOptionalString(payload.expiresAt)) + ? { + expiresAt: + readOptionalString(payload.tokenExpiresAt) ?? + readOptionalString(payload.expiresAt) ?? + undefined, + } + : {}), + ...(readOptionalString(payload.refreshToken) + ? { refreshToken: readOptionalString(payload.refreshToken) ?? undefined } + : {}), + ...resolveRefreshTokenExpiryOverride({ + payloadRefreshTokenExpiresAt: readOptionalString( + payload.refreshTokenExpiresAt + ), + currentRefreshTokenExpiresAt: input.currentRefreshTokenExpiresAt, + }), + }; +} + +function parseTimestampMs(value: string | undefined): number | null { + if (!value) { + return null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function readOptionalString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function trimTrailingSlash(value: string): string { + return value.replace(TRAILING_SLASH_REGEX, ""); +} + +function resolveRefreshTokenExpiryOverride(input: { + readonly payloadRefreshTokenExpiresAt?: string | null; + readonly currentRefreshTokenExpiresAt?: string; +}): { readonly refreshTokenExpiresAt?: string } { + if (input.payloadRefreshTokenExpiresAt) { + return { + refreshTokenExpiresAt: input.payloadRefreshTokenExpiresAt, + }; + } + if (input.currentRefreshTokenExpiresAt) { + return { + refreshTokenExpiresAt: input.currentRefreshTokenExpiresAt, + }; + } + return {}; +} + +function normalizeConfigString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function resolveDefaultAuthRefForProfile(input: { + readonly profileId: string; +}): string { + if (input.profileId === DEFAULT_LINEAR_PROFILE_ID) { + return DEFAULT_LINEAR_AUTH_REF; + } + return `linear.api.${input.profileId}`; +} + +function normalizeApiUrl(input: { readonly value: string }): string { + const trimmed = input.value.trim(); + if (!trimmed) { + return DEFAULT_LINEAR_API_URL; + } + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} diff --git a/src/control-plane/extensions/linear/client.ts b/src/control-plane/extensions/linear/client.ts new file mode 100644 index 000000000..33e00119c --- /dev/null +++ b/src/control-plane/extensions/linear/client.ts @@ -0,0 +1,925 @@ +import { isRecord } from "../../../lib/guards.ts"; + +export type LinearWorkflowStateType = + | "triage" + | "backlog" + | "unstarted" + | "started" + | "completed" + | "canceled"; + +export type LinearWorkflowState = { + readonly id: string; + readonly name: string; + readonly type: LinearWorkflowStateType; +}; + +export type LinearLabel = { + readonly id: string; + readonly name: string; +}; + +export type LinearProject = { + readonly id: string; + readonly name: string; + readonly teamId: string; + readonly teamKey?: string; + readonly teamName?: string; +}; + +export type LinearIssue = { + readonly id: string; + readonly identifier: string; + readonly title: string; + readonly description?: string; + readonly url?: string; + readonly state: LinearWorkflowState; + readonly teamId: string; + readonly teamKey?: string; + readonly teamName?: string; + readonly projectId?: string; + readonly projectName?: string; + readonly labels: readonly LinearLabel[]; + readonly parentId?: string; + readonly parentIdentifier?: string; +}; + +type LinearGraphQlError = { + readonly message: string; +}; + +type LinearRequestResult = + | { readonly ok: true; readonly data: T } + | { + readonly ok: false; + readonly status: number; + readonly error: string; + readonly graphQLErrors?: readonly LinearGraphQlError[]; + }; + +type LinearProjectIssuePage = { + readonly project: LinearProject; + readonly issues: readonly LinearIssue[]; + readonly hasNextPage: boolean; + readonly endCursor?: string; +}; + +export type LinearClient = { + readonly getViewer: () => Promise< + LinearRequestResult<{ + readonly id: string; + readonly name?: string; + readonly email?: string; + readonly displayName?: string; + }> + >; + readonly listProjects: (input?: { + readonly first?: number; + }) => Promise>; + readonly getProject: (input: { + readonly projectId: string; + }) => Promise>; + readonly getIssueByIdentifier: (input: { + readonly identifier: string; + }) => Promise>; + readonly getIssueById: (input: { + readonly issueId: string; + }) => Promise>; + readonly createIssue: (input: { + readonly teamId: string; + readonly title: string; + readonly description?: string; + readonly projectId?: string; + readonly stateId?: string; + readonly labelIds?: readonly string[]; + readonly parentId?: string; + }) => Promise>; + readonly updateIssue: (input: { + readonly issueId: string; + readonly title?: string; + readonly description?: string; + readonly projectId?: string; + readonly stateId?: string; + readonly labelIds?: readonly string[]; + readonly parentId?: string; + }) => Promise>; + readonly listProjectIssuesPage: (input: { + readonly projectId: string; + readonly first?: number; + readonly after?: string; + }) => Promise>; + readonly listTeamStates: (input: { + readonly teamId: string; + }) => Promise>; + readonly listTeamLabels: (input: { + readonly teamId: string; + }) => Promise>; +}; + +const DEFAULT_LINEAR_API_URL = "https://api.linear.app/graphql"; +const DEFAULT_PAGE_SIZE = 50; + +/** + * Build a Linear GraphQL client for issue/project sync operations. + */ +export function createLinearClient(input: { + readonly token: string; + readonly apiUrl?: string; +}): LinearClient { + const token = input.token.trim(); + const apiUrl = normalizeApiUrl({ + value: input.apiUrl ?? DEFAULT_LINEAR_API_URL, + }); + + const request = async (req: { + readonly query: string; + readonly variables?: Record; + }): Promise> => { + const response = await fetch(apiUrl, { + method: "POST", + headers: { + Authorization: token, + "Content-Type": "application/json", + "User-Agent": "hack-cli", + }, + body: JSON.stringify({ + query: req.query, + ...(req.variables ? { variables: req.variables } : {}), + }), + }); + + const payload = (await response.json().catch(() => null)) as unknown; + if (!isRecord(payload)) { + return { + ok: false, + status: response.status, + error: `Linear request failed (${response.status}): invalid response payload.`, + }; + } + + const errors = parseGraphQlErrors(payload.errors); + if (!(response.ok && errors.length === 0)) { + const errorMessage = + errors[0]?.message ?? `Linear request failed (${response.status}).`; + return { + ok: false, + status: response.status, + error: errorMessage, + ...(errors.length > 0 ? { graphQLErrors: errors } : {}), + }; + } + + return { + ok: true, + data: payload.data as T, + }; + }; + + return { + getViewer: async () => { + const result = await request<{ + readonly viewer?: unknown; + }>({ + query: [ + "query LinearViewer {", + " viewer {", + " id", + " name", + " email", + " displayName", + " }", + "}", + ].join("\n"), + }); + if (!result.ok) { + return result; + } + const viewer = parseViewer(result.data.viewer); + if (!viewer) { + return { + ok: false, + status: 500, + error: "Linear viewer payload missing required fields.", + }; + } + return { + ok: true, + data: viewer, + }; + }, + + listProjects: async (input = {}) => { + const first = normalizePositiveInt({ + value: input.first, + fallback: DEFAULT_PAGE_SIZE, + }); + const result = await request<{ + readonly viewer?: unknown; + }>({ + query: [ + "query LinearProjects($first: Int!) {", + " viewer {", + " projects(first: $first) {", + " nodes {", + " id", + " name", + " team { id key name }", + " }", + " }", + " }", + "}", + ].join("\n"), + variables: { first }, + }); + if (!result.ok) { + return result; + } + const projects = parseViewerProjects(result.data.viewer); + return { ok: true, data: projects }; + }, + + getProject: async ({ projectId }) => { + const id = projectId.trim(); + if (!id) { + return { + ok: false, + status: 400, + error: "Missing Linear project id.", + }; + } + const result = await request<{ + readonly project?: unknown; + }>({ + query: [ + "query LinearProject($projectId: String!) {", + " project(id: $projectId) {", + " id", + " name", + " team { id key name }", + " }", + "}", + ].join("\n"), + variables: { + projectId: id, + }, + }); + if (!result.ok) { + return result; + } + const project = parseProject(result.data.project); + return { + ok: true, + data: project, + }; + }, + + getIssueByIdentifier: async ({ identifier }) => { + const normalized = identifier.trim(); + if (!normalized) { + return { + ok: false, + status: 400, + error: "Missing Linear issue identifier.", + }; + } + const result = await request<{ + readonly issue?: unknown; + }>({ + query: issueFieldsQuery({ + root: "issue(identifier: $identifier)", + operation: "IssueByIdentifier", + variablesDef: "($identifier: String!)", + }), + variables: { + identifier: normalized, + }, + }); + if (!result.ok) { + return result; + } + const issue = parseIssue(result.data.issue); + return { + ok: true, + data: issue, + }; + }, + + getIssueById: async ({ issueId }) => { + const normalized = issueId.trim(); + if (!normalized) { + return { + ok: false, + status: 400, + error: "Missing Linear issue id.", + }; + } + const result = await request<{ + readonly issue?: unknown; + }>({ + query: issueFieldsQuery({ + root: "issue(id: $issueId)", + operation: "IssueById", + variablesDef: "($issueId: String!)", + }), + variables: { + issueId: normalized, + }, + }); + if (!result.ok) { + return result; + } + const issue = parseIssue(result.data.issue); + return { + ok: true, + data: issue, + }; + }, + + createIssue: async (input) => { + const teamId = input.teamId.trim(); + const title = input.title.trim(); + if (!(teamId && title)) { + return { + ok: false, + status: 400, + error: "Linear issue creation requires teamId and title.", + }; + } + const mutationInput: Record = { + teamId, + title, + }; + if (input.description !== undefined) { + mutationInput.description = input.description; + } + if (input.projectId) { + mutationInput.projectId = input.projectId; + } + if (input.stateId) { + mutationInput.stateId = input.stateId; + } + if (input.labelIds && input.labelIds.length > 0) { + mutationInput.labelIds = [...input.labelIds]; + } + if (input.parentId) { + mutationInput.parentId = input.parentId; + } + + const result = await request<{ + readonly issueCreate?: unknown; + }>({ + query: [ + "mutation LinearIssueCreate($input: IssueCreateInput!) {", + " issueCreate(input: $input) {", + " success", + " issue {", + issueFieldsSelection(), + " }", + " }", + "}", + ].join("\n"), + variables: { + input: mutationInput, + }, + }); + if (!result.ok) { + return result; + } + const issue = parseIssueMutationIssue(result.data.issueCreate); + if (!issue) { + return { + ok: false, + status: 500, + error: "Linear issueCreate payload missing issue data.", + }; + } + return { + ok: true, + data: issue, + }; + }, + + updateIssue: async (input) => { + const issueId = input.issueId.trim(); + if (!issueId) { + return { + ok: false, + status: 400, + error: "Missing Linear issue id.", + }; + } + const mutationInput: Record = {}; + if (input.title !== undefined) { + mutationInput.title = input.title; + } + if (input.description !== undefined) { + mutationInput.description = input.description; + } + if (input.projectId !== undefined) { + mutationInput.projectId = input.projectId; + } + if (input.stateId !== undefined) { + mutationInput.stateId = input.stateId; + } + if (input.labelIds !== undefined) { + mutationInput.labelIds = [...input.labelIds]; + } + if (input.parentId !== undefined) { + mutationInput.parentId = input.parentId; + } + + const result = await request<{ + readonly issueUpdate?: unknown; + }>({ + query: [ + "mutation LinearIssueUpdate($id: String!, $input: IssueUpdateInput!) {", + " issueUpdate(id: $id, input: $input) {", + " success", + " issue {", + issueFieldsSelection(), + " }", + " }", + "}", + ].join("\n"), + variables: { + id: issueId, + input: mutationInput, + }, + }); + if (!result.ok) { + return result; + } + const issue = parseIssueMutationIssue(result.data.issueUpdate); + if (!issue) { + return { + ok: false, + status: 500, + error: "Linear issueUpdate payload missing issue data.", + }; + } + return { + ok: true, + data: issue, + }; + }, + + listProjectIssuesPage: async (input) => { + const projectId = input.projectId.trim(); + if (!projectId) { + return { + ok: false, + status: 400, + error: "Missing Linear project id.", + }; + } + const first = normalizePositiveInt({ + value: input.first, + fallback: DEFAULT_PAGE_SIZE, + }); + const result = await request<{ + readonly project?: unknown; + }>({ + query: [ + "query LinearProjectIssues($projectId: String!, $first: Int!, $after: String) {", + " project(id: $projectId) {", + " id", + " name", + " team { id key name }", + " issues(first: $first, after: $after) {", + " pageInfo {", + " hasNextPage", + " endCursor", + " }", + " nodes {", + issueFieldsSelection(), + " }", + " }", + " }", + "}", + ].join("\n"), + variables: { + projectId, + first, + after: input.after ?? null, + }, + }); + if (!result.ok) { + return result; + } + const page = parseProjectIssuePage(result.data.project); + if (!page) { + return { + ok: false, + status: 500, + error: "Linear project issue page payload is invalid.", + }; + } + return { + ok: true, + data: page, + }; + }, + + listTeamStates: async ({ teamId }) => { + const id = teamId.trim(); + if (!id) { + return { + ok: false, + status: 400, + error: "Missing team id.", + }; + } + const result = await request<{ + readonly team?: unknown; + }>({ + query: [ + "query LinearTeamStates($teamId: String!) {", + " team(id: $teamId) {", + " states {", + " nodes {", + " id", + " name", + " type", + " }", + " }", + " }", + "}", + ].join("\n"), + variables: { teamId: id }, + }); + if (!result.ok) { + return result; + } + const states = parseTeamStates(result.data.team); + return { + ok: true, + data: states, + }; + }, + + listTeamLabels: async ({ teamId }) => { + const id = teamId.trim(); + if (!id) { + return { + ok: false, + status: 400, + error: "Missing team id.", + }; + } + const result = await request<{ + readonly team?: unknown; + }>({ + query: [ + "query LinearTeamLabels($teamId: String!) {", + " team(id: $teamId) {", + " labels {", + " nodes {", + " id", + " name", + " }", + " }", + " }", + "}", + ].join("\n"), + variables: { teamId: id }, + }); + if (!result.ok) { + return result; + } + const labels = parseTeamLabels(result.data.team); + return { + ok: true, + data: labels, + }; + }, + }; +} + +function issueFieldsQuery(input: { + readonly operation: string; + readonly variablesDef: string; + readonly root: string; +}): string { + return [ + `query ${input.operation}${input.variablesDef} {`, + ` ${input.root} {`, + issueFieldsSelection(), + " }", + "}", + ].join("\n"); +} + +function issueFieldsSelection(): string { + return [ + "id", + "identifier", + "title", + "description", + "url", + "state {", + " id", + " name", + " type", + "}", + "team {", + " id", + " key", + " name", + "}", + "project {", + " id", + " name", + "}", + "labels {", + " nodes {", + " id", + " name", + " }", + "}", + "parent {", + " id", + " identifier", + "}", + ].join("\n"); +} + +function parseGraphQlErrors(value: unknown): LinearGraphQlError[] { + if (!Array.isArray(value)) { + return []; + } + const errors: LinearGraphQlError[] = []; + for (const item of value) { + if (!(isRecord(item) && typeof item.message === "string")) { + continue; + } + errors.push({ + message: item.message, + }); + } + return errors; +} + +function parseViewer(value: unknown): { + readonly id: string; + readonly name?: string; + readonly email?: string; + readonly displayName?: string; +} | null { + if (!(isRecord(value) && typeof value.id === "string")) { + return null; + } + return { + id: value.id, + ...(typeof value.name === "string" ? { name: value.name } : {}), + ...(typeof value.email === "string" ? { email: value.email } : {}), + ...(typeof value.displayName === "string" + ? { displayName: value.displayName } + : {}), + }; +} + +function parseViewerProjects(value: unknown): LinearProject[] { + if (!isRecord(value)) { + return []; + } + const projects = isRecord(value.projects) ? value.projects : null; + const nodes = Array.isArray(projects?.nodes) ? projects.nodes : []; + const out: LinearProject[] = []; + for (const node of nodes) { + const parsed = parseProject(node); + if (!parsed) { + continue; + } + out.push(parsed); + } + return out; +} + +function parseProject(value: unknown): LinearProject | null { + if (!isRecord(value)) { + return null; + } + if (!(typeof value.id === "string" && typeof value.name === "string")) { + return null; + } + const team = isRecord(value.team) ? value.team : null; + if (!(team && typeof team.id === "string")) { + return null; + } + return { + id: value.id, + name: value.name, + teamId: team.id, + ...(typeof team.key === "string" ? { teamKey: team.key } : {}), + ...(typeof team.name === "string" ? { teamName: team.name } : {}), + }; +} + +function parseIssue(value: unknown): LinearIssue | null { + if (!isRecord(value)) { + return null; + } + if ( + !( + typeof value.id === "string" && + typeof value.identifier === "string" && + typeof value.title === "string" + ) + ) { + return null; + } + + const state = parseWorkflowState(value.state); + const team = isRecord(value.team) ? value.team : null; + if (!(state && team && typeof team.id === "string")) { + return null; + } + const project = isRecord(value.project) ? value.project : null; + const labels = parseLabels(value.labels); + const parent = isRecord(value.parent) ? value.parent : null; + + return { + id: value.id, + identifier: value.identifier, + title: value.title, + ...(typeof value.description === "string" + ? { description: value.description } + : {}), + ...(typeof value.url === "string" ? { url: value.url } : {}), + state, + teamId: team.id, + ...(typeof team.key === "string" ? { teamKey: team.key } : {}), + ...(typeof team.name === "string" ? { teamName: team.name } : {}), + ...(project && typeof project.id === "string" + ? { + projectId: project.id, + ...(typeof project.name === "string" + ? { projectName: project.name } + : {}), + } + : {}), + labels, + ...(parent && typeof parent.id === "string" ? { parentId: parent.id } : {}), + ...(parent && typeof parent.identifier === "string" + ? { parentIdentifier: parent.identifier } + : {}), + }; +} + +function parseIssueMutationIssue(value: unknown): LinearIssue | null { + if (!isRecord(value)) { + return null; + } + const success = value.success; + if (typeof success === "boolean" && !success) { + return null; + } + return parseIssue(value.issue); +} + +function parseWorkflowState(value: unknown): LinearWorkflowState | null { + if (!isRecord(value)) { + return null; + } + if ( + !( + typeof value.id === "string" && + typeof value.name === "string" && + isLinearWorkflowStateType(value.type) + ) + ) { + return null; + } + return { + id: value.id, + name: value.name, + type: value.type, + }; +} + +function parseLabels(value: unknown): LinearLabel[] { + if (!isRecord(value)) { + return []; + } + const nodes = Array.isArray(value.nodes) ? value.nodes : []; + const labels: LinearLabel[] = []; + for (const node of nodes) { + if (!(isRecord(node) && typeof node.id === "string")) { + continue; + } + const name = typeof node.name === "string" ? node.name : ""; + labels.push({ + id: node.id, + name, + }); + } + return labels; +} + +function parseProjectIssuePage(value: unknown): LinearProjectIssuePage | null { + if (!isRecord(value)) { + return null; + } + const project = parseProject(value); + if (!project) { + return null; + } + const issuesConnection = isRecord(value.issues) ? value.issues : null; + const nodes = Array.isArray(issuesConnection?.nodes) + ? issuesConnection.nodes + : []; + const pageInfo = isRecord(issuesConnection?.pageInfo) + ? issuesConnection.pageInfo + : null; + if (!(pageInfo && typeof pageInfo.hasNextPage === "boolean")) { + return null; + } + const issues: LinearIssue[] = []; + for (const node of nodes) { + const issue = parseIssue(node); + if (!issue) { + continue; + } + issues.push(issue); + } + return { + project, + issues, + hasNextPage: pageInfo.hasNextPage, + ...(typeof pageInfo.endCursor === "string" + ? { endCursor: pageInfo.endCursor } + : {}), + }; +} + +function parseTeamStates(value: unknown): LinearWorkflowState[] { + if (!isRecord(value)) { + return []; + } + const states = isRecord(value.states) ? value.states : null; + const nodes = Array.isArray(states?.nodes) ? states.nodes : []; + const out: LinearWorkflowState[] = []; + for (const node of nodes) { + const parsed = parseWorkflowState(node); + if (!parsed) { + continue; + } + out.push(parsed); + } + return out; +} + +function parseTeamLabels(value: unknown): LinearLabel[] { + if (!isRecord(value)) { + return []; + } + const labels = isRecord(value.labels) ? value.labels : null; + const nodes = Array.isArray(labels?.nodes) ? labels.nodes : []; + const out: LinearLabel[] = []; + for (const node of nodes) { + if (!(isRecord(node) && typeof node.id === "string")) { + continue; + } + const name = typeof node.name === "string" ? node.name : ""; + out.push({ + id: node.id, + name, + }); + } + return out; +} + +function isLinearWorkflowStateType( + value: unknown +): value is LinearWorkflowStateType { + return ( + value === "triage" || + value === "backlog" || + value === "unstarted" || + value === "started" || + value === "completed" || + value === "canceled" + ); +} + +function normalizePositiveInt(input: { + readonly value: number | undefined; + readonly fallback: number; +}): number { + if ( + typeof input.value === "number" && + Number.isFinite(input.value) && + input.value > 0 + ) { + return Math.floor(input.value); + } + return input.fallback; +} + +function normalizeApiUrl(input: { readonly value: string }): string { + const trimmed = input.value.trim(); + if (!trimmed) { + return DEFAULT_LINEAR_API_URL; + } + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts new file mode 100644 index 000000000..402ad1e87 --- /dev/null +++ b/src/control-plane/extensions/linear/commands.ts @@ -0,0 +1,3834 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +import { secrets } from "bun"; + +import { + updateGlobalConfig, + updateProjectConfig, +} from "../../../lib/config.ts"; +import { isRecord } from "../../../lib/guards.ts"; +import { openUrl } from "../../../lib/os.ts"; +import { display } from "../../../ui/display.ts"; +import { + createTicketsStore, + type TicketStatus, + type TicketSummary, +} from "../tickets/store.ts"; +import { normalizeTicketRef } from "../tickets/util.ts"; +import type { ExtensionCommand, ExtensionCommandContext } from "../types.ts"; +import { + deleteLinearToken, + listLinearAuthProfiles, + resolveLinearAuthSettings, + resolveLinearAuthSettingsResult, + resolveLinearToken, + saveLinearToken, +} from "./auth.ts"; +import { + createLinearClient, + type LinearIssue, + type LinearWorkflowState, + type LinearWorkflowStateType, +} from "./client.ts"; + +const EXTENSION_ID = "dance.hack.linear"; +const DEFAULT_OAUTH_AUTHORIZE_URL = "https://linear.app/oauth/authorize"; +const DEFAULT_OAUTH_TOKEN_URL = "https://api.linear.app/oauth/token"; +const DEFAULT_OAUTH_SCOPES = "read,write,app:mentionable,app:assignable"; +const DEFAULT_OAUTH_ACTOR = "app"; +const DEFAULT_OAUTH_BROKER_URL = "https://auth.hack.broker"; +const DEFAULT_OAUTH_BROKER_URL_ENV = "HACK_AUTH_BROKER_URL"; +const DEFAULT_OAUTH_BROKER_URL_ENV_FALLBACK = "HACK_LINEAR_OAUTH_BROKER_URL"; +const DEFAULT_OAUTH_CLIENT_ID_ENV = "HACK_LINEAR_OAUTH_CLIENT_ID"; +const DEFAULT_OAUTH_CLIENT_ID_ENV_FALLBACK = "HACK_LINEAR_CLIENT_ID"; +const DEFAULT_OAUTH_CLIENT_SECRET_ENV = "HACK_LINEAR_SECRET"; +const DEFAULT_OAUTH_CLIENT_SECRET_ENV_FALLBACK = "LINEAR_CLIENT_SECRET"; +const DEFAULT_OAUTH_CLIENT_SECRET_AUTH_REF = "linear.oauth.client_secret"; +const DEFAULT_OAUTH_SECRET_SERVICE = "hack-linear-auth"; +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +const OAUTH_POLL_INTERVAL_MS = 1000; +const DEFAULT_PROJECT_SYNC_LIMIT = 100; +const TRAILING_SLASH_REGEX = /\/+$/; + +export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ + { + name: "setup", + summary: "Enable Linear extension and wire project defaults", + scope: "project", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + const parsed = parseSetupArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + await enableLinearProjectExtension({ + projectDir: ctx.project.projectDir, + }); + + if (parsed.value.profileId) { + await updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.profile", + value: parsed.value.profileId, + }); + } + if (parsed.value.projectId) { + await updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.projectId", + value: parsed.value.projectId, + }); + } + if (parsed.value.projectName !== undefined) { + await updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.projectName", + value: parsed.value.projectName, + }); + } + if (parsed.value.teamId !== undefined) { + await updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.teamId", + value: parsed.value.teamId, + }); + } + + const payload = { + ok: true, + extension: EXTENSION_ID, + projectProfile: parsed.value.profileId ?? null, + projectId: parsed.value.projectId ?? null, + projectName: parsed.value.projectName ?? null, + teamId: parsed.value.teamId ?? null, + }; + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear setup", + entries: [ + ["extension", EXTENSION_ID], + ["project_profile", parsed.value.profileId ?? ""], + ["linear_project_id", parsed.value.projectId ?? ""], + ["linear_project_name", parsed.value.projectName ?? ""], + ["linear_team_id", parsed.value.teamId ?? ""], + ], + }); + return 0; + }, + }, + { + name: "connect", + summary: + "Connect Linear profile (token mode, or browser OAuth fallback when no token is provided)", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseConnectArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const defaults = resolveLinearAuthSettings({ + controlPlaneConfig: ctx.controlPlaneConfig, + ...(parsed.value.profileId + ? { profileId: parsed.value.profileId } + : {}), + allowProjectOverride: false, + }); + + const profileId = parsed.value.profileId ?? defaults.profileId; + const tokenEnv = parsed.value.tokenEnv ?? defaults.tokenEnv; + const authRef = parsed.value.authRef ?? defaults.authRef; + const service = parsed.value.service ?? defaults.service; + const apiUrl = parsed.value.apiUrl ?? defaults.apiUrl; + + const tokenResolution = await resolveConnectToken({ + token: parsed.value.token, + stdin: parsed.value.stdin, + tokenEnv, + expiresAt: parsed.value.tokenExpiresAt, + refreshToken: parsed.value.refreshToken, + refreshTokenExpiresAt: parsed.value.refreshTokenExpiresAt, + }); + if (!tokenResolution.ok) { + const fallbackToOauth = shouldFallbackConnectToOAuth({ + parsed: parsed.value, + tokenEnv, + }); + if (!fallbackToOauth) { + ctx.logger.error({ message: tokenResolution.error }); + return 1; + } + + return await handleLinearOAuthConnectCommand({ + ctx, + args: buildOAuthArgsFromConnectArgs({ + parsed: parsed.value, + profileId, + }), + }); + } + + const client = createLinearClient({ + token: tokenResolution.token, + apiUrl, + }); + const viewer = await client.getViewer(); + if (!viewer.ok) { + ctx.logger.error({ + message: `Failed to validate Linear token: ${viewer.error}`, + }); + return 1; + } + + await saveLinearToken({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + allowProjectOverride: false, + token: tokenResolution.token, + ...(tokenResolution.expiresAt + ? { expiresAt: tokenResolution.expiresAt } + : {}), + ...(tokenResolution.refreshToken + ? { refreshToken: tokenResolution.refreshToken } + : {}), + ...(tokenResolution.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: tokenResolution.refreshTokenExpiresAt } + : {}), + authRef, + service, + }); + + const setAsDefault = shouldSetProfileAsDefault({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + setDefaultFlag: parsed.value.setDefault, + }); + + await persistLinearProfileDefaults({ + profileId, + tokenEnv, + authRef, + service, + apiUrl, + accountId: viewer.data.id, + accountName: viewer.data.displayName ?? viewer.data.name, + accountEmail: viewer.data.email, + setAsDefault, + }); + + await display.kv({ + title: "Linear connected", + entries: [ + ["profile", profileId], + ["set_default", setAsDefault ? "yes" : "no"], + ["auth_ref", authRef], + ["service", service], + ["token_env_fallback", tokenEnv], + ["api_url", apiUrl], + ["account_id", viewer.data.id], + ["account_name", viewer.data.displayName ?? viewer.data.name ?? ""], + ["account_email", viewer.data.email ?? ""], + ], + }); + + return 0; + }, + }, + { + name: "oauth-connect", + summary: "Run browser OAuth flow and bind token to a Linear profile", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => + await handleLinearOAuthConnectCommand({ + ctx, + args, + }), + }, + { + name: "disconnect", + summary: "Delete a stored Linear token for a profile", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseDisconnectArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + const deleted = await deleteLinearToken({ + controlPlaneConfig: ctx.controlPlaneConfig, + ...(parsed.value.profileId + ? { profileId: parsed.value.profileId } + : {}), + allowProjectOverride: false, + ...(parsed.value.authRef ? { authRef: parsed.value.authRef } : {}), + ...(parsed.value.service ? { service: parsed.value.service } : {}), + }); + + await display.kv({ + title: "Linear disconnected", + entries: [ + ["profile", deleted.profileId], + ["auth_ref", deleted.authRef], + ["service", deleted.service], + ["deleted", deleted.deleted ? "yes" : "no"], + ], + }); + return deleted.deleted ? 0 : 1; + }, + }, + { + name: "status", + summary: "Show Linear profile/config resolution and token status", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => + await handleLinearStatusCommand({ + ctx, + args, + }), + }, + { + name: "profiles", + summary: "List configured Linear auth profiles", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseProfilesArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + const catalog = listLinearAuthProfiles({ + controlPlaneConfig: ctx.controlPlaneConfig, + allowProjectOverride: true, + }); + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(catalog, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear profile selection", + entries: [ + ["selected_profile", catalog.selectedProfileId], + ["selected_source", catalog.selectedProfileSource], + ["default_profile", catalog.defaultProfileId], + ["project_override", catalog.projectProfileOverride ?? ""], + ["selected_missing", catalog.selectedProfileMissing ? "yes" : "no"], + ], + }); + await display.table({ + columns: [ + "Profile", + "Default", + "Auth Ref", + "Service", + "Token Env", + "Account", + ], + rows: catalog.profiles.map((profile) => [ + profile.id, + profile.isDefault ? "yes" : "", + profile.authRef, + profile.service, + profile.tokenEnv, + profile.accountName ?? + profile.accountEmail ?? + profile.accountId ?? + "", + ]), + }); + } + return catalog.selectedProfileMissing ? 1 : 0; + }, + }, + { + name: "use", + summary: "Set global default Linear profile", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseUseArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + const settings = resolveLinearAuthSettingsResult({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + allowProjectOverride: false, + }); + if (!settings.ok) { + ctx.logger.error({ message: settings.error }); + return 1; + } + await updateGlobalConfig({ + path: `controlPlane.extensions["${EXTENSION_ID}"].config.defaultProfile`, + value: parsed.value.profileId, + }); + await display.kv({ + title: "Linear default profile updated", + entries: [["profile", parsed.value.profileId]], + }); + return 0; + }, + }, + { + name: "projects", + summary: "List projects available to the selected Linear profile", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseProjectsArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + const token = await resolveLinearTokenWithBrokerRefresh({ + controlPlaneConfig: ctx.controlPlaneConfig, + ...(parsed.value.profileId + ? { profileId: parsed.value.profileId } + : {}), + allowProjectOverride: !parsed.value.profileId, + }); + if (!token.ok) { + ctx.logger.error({ message: token.error }); + return 1; + } + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: token.profileId, + allowProjectOverride: false, + }); + const client = createLinearClient({ + token: token.token, + apiUrl: settings.apiUrl, + }); + const projects = await client.listProjects({ + ...(parsed.value.limit ? { first: parsed.value.limit } : {}), + }); + if (!projects.ok) { + ctx.logger.error({ message: projects.error }); + return 1; + } + + const payload = { + profile: token.profileId, + projects: projects.data, + }; + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return 0; + } + + await display.table({ + columns: ["Project ID", "Project", "Team", "Team ID"], + rows: projects.data.map((project) => [ + project.id, + project.name, + project.teamKey ?? project.teamName ?? "", + project.teamId, + ]), + }); + return 0; + }, + }, + { + name: "project-bind", + summary: "Bind current hack project to a default Linear profile/project", + scope: "project", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + const parsed = parseProjectBindArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + await enableLinearProjectExtension({ + projectDir: ctx.project.projectDir, + }); + + if (parsed.value.clear) { + await Promise.all([ + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.profile", + value: "", + }), + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.projectId", + value: "", + }), + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.projectName", + value: "", + }), + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.teamId", + value: "", + }), + ]); + + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify({ ok: true, cleared: true }, null, 2)}\n` + ); + } else { + await display.panel({ + title: "Linear project binding", + tone: "success", + lines: ["Cleared project-level Linear routing overrides."], + }); + } + return 0; + } + + const boundProfile = + parsed.value.profileId ?? + resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }).profileId; + + if (boundProfile) { + await updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.profile", + value: boundProfile, + }); + } + + const projectId = parsed.value.projectId; + if (!projectId) { + ctx.logger.error({ + message: + "Missing --project-id. Use --clear to remove mapping or pass a Linear project id to bind.", + }); + return 1; + } + + const resolvedTeamAndName = await resolveProjectBindingDetails({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + projectId, + projectName: parsed.value.projectName, + teamId: parsed.value.teamId, + }); + if (!resolvedTeamAndName.ok) { + ctx.logger.error({ message: resolvedTeamAndName.error }); + return 1; + } + + await Promise.all([ + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.projectId", + value: projectId, + }), + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.projectName", + value: resolvedTeamAndName.projectName, + }), + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.teamId", + value: resolvedTeamAndName.teamId, + }), + ]); + + const payload = { + ok: true, + profileId: boundProfile ?? null, + projectId, + projectName: resolvedTeamAndName.projectName, + teamId: resolvedTeamAndName.teamId, + }; + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear project binding", + entries: [ + ["profile", payload.profileId ?? ""], + ["project_id", payload.projectId], + ["project_name", payload.projectName], + ["team_id", payload.teamId], + ], + }); + } + return 0; + }, + }, + { + name: "sync-issue", + summary: + "One-off manual sync for a single issue/ticket in either direction", + scope: "project", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + const parsed = parseSyncIssueArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const runtime = await createSyncRuntime({ + ctx, + profileId: parsed.value.profileId, + }); + if (!runtime.ok) { + ctx.logger.error({ message: runtime.error }); + return 1; + } + + const toggles = resolveSyncToggles({ + controlPlaneConfig: ctx.controlPlaneConfig, + labelsOverride: parsed.value.syncLabels, + }); + + if (parsed.value.from === "linear") { + const issueIdentifier = parsed.value.issueIdentifier; + if (!issueIdentifier) { + ctx.logger.error({ + message: "Missing --issue for --from linear.", + }); + return 1; + } + const synced = await syncIssueFromLinearToTicket({ + runtime: runtime.value, + issueIdentifier, + syncToggles: toggles, + }); + if (!synced.ok) { + ctx.logger.error({ message: synced.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(synced, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear -> ticket sync", + entries: [ + ["ticket_id", synced.ticketId], + ["linear_issue", synced.issueIdentifier], + ["operation", synced.operation], + ], + }); + } + return 0; + } + + const ticketInput = parsed.value.ticketId; + if (!ticketInput) { + ctx.logger.error({ + message: "Missing --ticket for --from hack.", + }); + return 1; + } + const normalizedTicketId = normalizeTicketRef(ticketInput); + if (!normalizedTicketId) { + ctx.logger.error({ message: `Invalid ticket id: ${ticketInput}` }); + return 1; + } + + const synced = await syncTicketToLinearIssue({ + runtime: runtime.value, + ticketId: normalizedTicketId, + explicitProjectId: parsed.value.projectId, + explicitTeamId: parsed.value.teamId, + syncToggles: toggles, + }); + if (!synced.ok) { + ctx.logger.error({ message: synced.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(synced, null, 2)}\n`); + } else { + await display.kv({ + title: "Ticket -> Linear sync", + entries: [ + ["ticket_id", synced.ticketId], + ["linear_issue", synced.issueIdentifier], + ["operation", synced.operation], + ], + }); + } + return 0; + }, + }, + { + name: "sync-project", + summary: + "Manual bulk sync for a bound/selected project in either direction", + scope: "project", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + const parsed = parseSyncProjectArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const runtime = await createSyncRuntime({ + ctx, + profileId: parsed.value.profileId, + }); + if (!runtime.ok) { + ctx.logger.error({ message: runtime.error }); + return 1; + } + + const toggles = resolveSyncToggles({ + controlPlaneConfig: ctx.controlPlaneConfig, + labelsOverride: parsed.value.syncLabels, + }); + + if (parsed.value.from === "linear") { + const bound = resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); + const projectId = parsed.value.projectId ?? bound.projectId; + if (!projectId) { + ctx.logger.error({ + message: + "Missing project id. Pass --project-id or run `hack x linear project-bind --project-id ...` first.", + }); + return 1; + } + + const syncResult = await syncProjectFromLinearToTickets({ + runtime: runtime.value, + projectId, + limit: parsed.value.limit, + syncToggles: toggles, + }); + if (!syncResult.ok) { + ctx.logger.error({ message: syncResult.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(syncResult, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear project -> tickets sync", + entries: [ + ["linear_project_id", projectId], + ["processed", String(syncResult.processed)], + ["created", String(syncResult.created)], + ["updated", String(syncResult.updated)], + ], + }); + } + return 0; + } + + const bound = resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); + const projectId = parsed.value.projectId ?? bound.projectId; + const syncResult = await syncProjectFromTicketsToLinear({ + runtime: runtime.value, + projectId, + explicitTeamId: parsed.value.teamId ?? bound.teamId, + limit: parsed.value.limit, + ownerMode: parsed.value.ownerMode, + syncToggles: toggles, + }); + if (!syncResult.ok) { + ctx.logger.error({ message: syncResult.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(syncResult, null, 2)}\n`); + } else { + await display.kv({ + title: "Tickets -> Linear project sync", + entries: [ + ["linear_project_id", projectId ?? ""], + ["processed", String(syncResult.processed)], + ["created", String(syncResult.created)], + ["updated", String(syncResult.updated)], + ], + }); + } + return 0; + }, + }, +]; + +type LinearCommandContext = ExtensionCommandContext; + +async function handleLinearOAuthConnectCommand(input: { + readonly ctx: LinearCommandContext; + readonly args: readonly string[]; +}): Promise { + const parsed = parseOAuthConnectArgs({ args: input.args }); + if (!parsed.ok) { + input.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const defaults = resolveLinearAuthSettings({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + ...(parsed.value.profileId ? { profileId: parsed.value.profileId } : {}), + allowProjectOverride: false, + }); + const resolved = { + profileId: parsed.value.profileId ?? defaults.profileId, + apiUrl: parsed.value.apiUrl ?? defaults.apiUrl, + tokenEnv: parsed.value.tokenEnv ?? defaults.tokenEnv, + authRef: parsed.value.authRef ?? defaults.authRef, + service: parsed.value.service ?? defaults.service, + }; + + let oauthFlow: + | { + readonly ok: true; + readonly tokenExchange: { + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + }; + } + | { readonly ok: false; readonly error: string }; + + if (shouldUseBrokerOAuthFlow({ parsed: parsed.value })) { + oauthFlow = await runLinearBrokerOAuthFlow({ + parsed: parsed.value, + brokerConfig: resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + }), + profileId: resolved.profileId, + }); + if (!oauthFlow.ok) { + const oauthConfig = await resolveOAuthRuntimeConfig({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + parsed: parsed.value, + }); + if (!oauthConfig.ok) { + input.ctx.logger.error({ + message: `${oauthFlow.error} Local fallback also is not configured: ${oauthConfig.error}`, + }); + return 1; + } + oauthFlow = await runLinearOAuthFlow({ + parsed: parsed.value, + oauthConfig: oauthConfig.value, + }); + } + } else { + const oauthConfig = await resolveOAuthRuntimeConfig({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + parsed: parsed.value, + }); + if (!oauthConfig.ok) { + input.ctx.logger.error({ message: oauthConfig.error }); + return 1; + } + + oauthFlow = await runLinearOAuthFlow({ + parsed: parsed.value, + oauthConfig: oauthConfig.value, + }); + } + if (!oauthFlow.ok) { + input.ctx.logger.error({ message: oauthFlow.error }); + return 1; + } + + const client = createLinearClient({ + token: oauthFlow.tokenExchange.token, + apiUrl: resolved.apiUrl, + }); + const viewer = await client.getViewer(); + if (!viewer.ok) { + input.ctx.logger.error({ + message: `OAuth succeeded but token verification failed: ${viewer.error}`, + }); + return 1; + } + + await saveLinearToken({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + profileId: resolved.profileId, + allowProjectOverride: false, + token: oauthFlow.tokenExchange.token, + ...(oauthFlow.tokenExchange.expiresAt + ? { expiresAt: oauthFlow.tokenExchange.expiresAt } + : {}), + ...(oauthFlow.tokenExchange.refreshToken + ? { refreshToken: oauthFlow.tokenExchange.refreshToken } + : {}), + ...(oauthFlow.tokenExchange.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: oauthFlow.tokenExchange.refreshTokenExpiresAt } + : {}), + authRef: resolved.authRef, + service: resolved.service, + }); + + const setAsDefault = shouldSetProfileAsDefault({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + profileId: resolved.profileId, + setDefaultFlag: parsed.value.setDefault, + }); + + await persistLinearProfileDefaults({ + profileId: resolved.profileId, + tokenEnv: resolved.tokenEnv, + authRef: resolved.authRef, + service: resolved.service, + apiUrl: resolved.apiUrl, + accountId: viewer.data.id, + accountName: viewer.data.displayName ?? viewer.data.name, + accountEmail: viewer.data.email, + setAsDefault, + }); + + await display.kv({ + title: "Linear OAuth connected", + entries: [ + ["profile", resolved.profileId], + ["set_default", setAsDefault ? "yes" : "no"], + ["auth_ref", resolved.authRef], + ["service", resolved.service], + ["token_env_fallback", resolved.tokenEnv], + ["api_url", resolved.apiUrl], + ["account_id", viewer.data.id], + ["account_name", viewer.data.displayName ?? viewer.data.name ?? ""], + ["account_email", viewer.data.email ?? ""], + ], + }); + return 0; +} + +async function handleLinearStatusCommand(input: { + readonly ctx: LinearCommandContext; + readonly args: readonly string[]; +}): Promise { + const parsed = parseStatusArgs({ args: input.args }); + if (!parsed.ok) { + input.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const payload = await resolveLinearStatusPayload({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + }); + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else { + await renderLinearStatusPayload({ + payload, + logger: input.ctx.logger, + }); + } + + return payload.ok ? 0 : 1; +} + +type LinearStatusPayload = { + readonly extensionId: string; + readonly selectedProfile: string; + readonly selectedSource: string; + readonly defaultProfile: string; + readonly selectedMissing: boolean; + readonly authRef: string; + readonly service: string; + readonly tokenEnvFallback: string; + readonly apiUrl: string; + readonly accountId: string | null; + readonly accountName: string | null; + readonly accountEmail: string | null; + readonly tokenResolved: boolean; + readonly tokenSource: string | null; + readonly tokenExpiresAt: string | null; + readonly error: string | null; + readonly profileError: string | null; + readonly ok: boolean; +}; + +async function resolveLinearStatusPayload(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; +}): Promise { + const allowProjectOverride = !input.profileId; + const profileFlags = input.profileId ? { profileId: input.profileId } : {}; + + const settingsResult = resolveLinearAuthSettingsResult({ + controlPlaneConfig: input.controlPlaneConfig, + ...profileFlags, + allowProjectOverride, + }); + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...profileFlags, + allowProjectOverride, + }); + const token = await resolveLinearTokenWithBrokerRefresh({ + controlPlaneConfig: input.controlPlaneConfig, + ...profileFlags, + allowProjectOverride, + }); + const catalog = listLinearAuthProfiles({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { explicitProfileId: input.profileId } : {}), + allowProjectOverride, + }); + + return { + extensionId: EXTENSION_ID, + selectedProfile: settings.profileId, + selectedSource: settings.profileSource, + defaultProfile: catalog.defaultProfileId, + selectedMissing: catalog.selectedProfileMissing, + authRef: settings.authRef, + service: settings.service, + tokenEnvFallback: settings.tokenEnv, + apiUrl: settings.apiUrl, + accountId: settings.accountId ?? null, + accountName: settings.accountName ?? null, + accountEmail: settings.accountEmail ?? null, + tokenResolved: token.ok, + tokenSource: token.ok ? token.source : null, + tokenExpiresAt: token.ok ? (token.expiresAt ?? null) : null, + error: token.ok ? null : token.error, + profileError: settingsResult.ok ? null : settingsResult.error, + ok: settingsResult.ok && token.ok, + }; +} + +async function renderLinearStatusPayload(input: { + readonly payload: LinearStatusPayload; + readonly logger: ExtensionCommandContext["logger"]; +}): Promise { + await display.kv({ + title: "Linear status", + entries: [ + ["selected_profile", input.payload.selectedProfile], + ["selected_source", input.payload.selectedSource], + ["default_profile", input.payload.defaultProfile], + ["selected_missing", input.payload.selectedMissing ? "yes" : "no"], + ["auth_ref", input.payload.authRef], + ["service", input.payload.service], + ["token_env_fallback", input.payload.tokenEnvFallback], + ["api_url", input.payload.apiUrl], + ["account_id", input.payload.accountId ?? ""], + ["account_name", input.payload.accountName ?? ""], + ["account_email", input.payload.accountEmail ?? ""], + ["token_resolved", input.payload.tokenResolved ? "yes" : "no"], + ["token_source", input.payload.tokenSource ?? ""], + ["token_expires_at", input.payload.tokenExpiresAt ?? ""], + ], + }); + if (input.payload.profileError) { + input.logger.warn({ message: input.payload.profileError }); + } + if (input.payload.error) { + input.logger.warn({ message: input.payload.error }); + } +} + +type OAuthConnectRuntime = { + readonly parsed: OAuthConnectArgs; + readonly oauthConfig: OAuthRuntimeConfig; +}; + +type OAuthBrokerConnectRuntime = { + readonly parsed: OAuthConnectArgs; + readonly brokerConfig: OAuthBrokerRuntimeConfig; + readonly profileId: string; +}; + +async function runLinearOAuthFlow(input: OAuthConnectRuntime): Promise< + | { + readonly ok: true; + readonly tokenExchange: { + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + }; + } + | { readonly ok: false; readonly error: string } +> { + const pkce = createLinearPkcePair(); + const callbackServer = startLinearOAuthCallbackServer({ + timeoutMs: OAUTH_TIMEOUT_MS, + }); + const state = randomUUID(); + const authorizeUrl = buildAuthorizeUrl({ + authorizeUrl: input.oauthConfig.authorizeUrl, + clientId: input.oauthConfig.clientId, + redirectUri: callbackServer.redirectUri, + actor: input.oauthConfig.actor, + scopes: input.oauthConfig.scopes, + state, + codeChallenge: pkce.codeChallenge, + codeChallengeMethod: "S256", + }); + + const launch = await launchLinearOAuthBrowser({ + parsed: input.parsed, + authorizeUrl, + redirectUri: callbackServer.redirectUri, + }); + if (!launch.ok) { + callbackServer.stop(); + return launch; + } + + const callback = await callbackServer.waitForCallback({ + expectedState: state, + }); + callbackServer.stop(); + if (!callback.ok) { + return callback; + } + + const tokenExchange = await exchangeLinearOAuthCode({ + tokenUrl: input.oauthConfig.tokenUrl, + clientId: input.oauthConfig.clientId, + ...(input.oauthConfig.clientSecret + ? { clientSecret: input.oauthConfig.clientSecret } + : {}), + code: callback.code, + redirectUri: callbackServer.redirectUri, + codeVerifier: pkce.codeVerifier, + }); + if (!tokenExchange.ok) { + return { ok: false, error: tokenExchange.error }; + } + + return { + ok: true, + tokenExchange, + }; +} + +async function runLinearBrokerOAuthFlow( + input: OAuthBrokerConnectRuntime +): Promise< + | { + readonly ok: true; + readonly tokenExchange: { + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + }; + } + | { readonly ok: false; readonly error: string } +> { + const startUrl = new URL( + "/v1/auth/linear/start", + `${input.brokerConfig.baseUrl}/` + ); + startUrl.searchParams.set("profile", input.profileId); + startUrl.searchParams.set("setDefault", input.parsed.setDefault ? "1" : "0"); + + const start = await fetchJson({ + url: startUrl.toString(), + }); + if (!start.ok) { + return { + ok: false, + error: `Auth broker Linear start failed: ${start.error}`, + }; + } + + const launch = await launchLinearOAuthBrowser({ + parsed: input.parsed, + authorizeUrl: start.value.flow.authorizeUrl, + pollUrl: start.value.flow.pollUrl, + }); + if (!launch.ok) { + return launch; + } + + const expiresAtMs = Date.parse(start.value.flow.expiresAt); + const statusUrl = new URL(start.value.flow.pollUrl); + statusUrl.searchParams.set("deviceCode", start.value.flow.deviceCode); + statusUrl.searchParams.set("claim", "1"); + + while (true) { + if (Number.isFinite(expiresAtMs) && Date.now() >= expiresAtMs) { + return { + ok: false, + error: "OAuth callback timed out. Retry the flow.", + }; + } + + const status = await fetchJson({ + url: statusUrl.toString(), + }); + if (!status.ok) { + return { + ok: false, + error: `Auth broker Linear status failed: ${status.error}`, + }; + } + if (!status.value.ok) { + return { + ok: false, + error: status.value.error, + }; + } + + const flowStatus = status.value.status; + if (flowStatus.status === "pending" || flowStatus.status === "complete") { + await Bun.sleep(OAUTH_POLL_INTERVAL_MS); + continue; + } + if (flowStatus.status === "claimed") { + const token = readOptionalString(flowStatus.token); + if (!token) { + return { + ok: false, + error: + "Linear OAuth completed remotely, but the broker did not return a token.", + }; + } + return { + ok: true, + tokenExchange: { + token, + ...(flowStatus.tokenExpiresAt + ? { expiresAt: flowStatus.tokenExpiresAt } + : {}), + ...(flowStatus.refreshToken + ? { refreshToken: flowStatus.refreshToken } + : {}), + ...(flowStatus.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: flowStatus.refreshTokenExpiresAt } + : {}), + }, + }; + } + if (flowStatus.status === "error") { + return { + ok: false, + error: flowStatus.error ?? "Linear OAuth failed.", + }; + } + if (flowStatus.status === "expired") { + return { + ok: false, + error: "OAuth callback timed out. Retry the flow.", + }; + } + + await Bun.sleep(OAUTH_POLL_INTERVAL_MS); + } +} + +async function launchLinearOAuthBrowser(input: { + readonly parsed: OAuthConnectArgs; + readonly authorizeUrl: string; + readonly redirectUri?: string; + readonly pollUrl?: string; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + if (!input.parsed.noOpen) { + const openExitCode = await openUrl(input.authorizeUrl); + if (openExitCode !== 0) { + return { + ok: false, + error: + "Failed to open browser for OAuth flow. Re-run with --no-open and open the URL manually.", + }; + } + } + + if (input.parsed.json) { + const payload = { + authorizeUrl: input.authorizeUrl, + ...(input.redirectUri ? { redirectUri: input.redirectUri } : {}), + ...(input.pollUrl ? { pollUrl: input.pollUrl } : {}), + }; + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return { ok: true }; + } + + if (input.parsed.noOpen) { + await display.panel({ + title: "Linear OAuth", + tone: "info", + lines: [ + "Open this URL in your browser to continue:", + input.authorizeUrl, + "", + ...(input.redirectUri ? [`Redirect URI: ${input.redirectUri}`] : []), + ...(input.pollUrl ? [`Status URL: ${input.pollUrl}`] : []), + ], + }); + } + + return { ok: true }; +} + +type SyncRuntime = { + readonly tickets: ReturnType; + readonly linear: ReturnType; + readonly profileId: string; + readonly apiUrl: string; + readonly projectBinding: ReturnType; +}; + +type SyncToggles = { + readonly labels: boolean; + readonly statuses: boolean; + readonly dependencies: boolean; + readonly projects: boolean; +}; + +async function createSyncRuntime(input: { + readonly ctx: ExtensionCommandContext; + readonly profileId?: string; +}): Promise< + | { readonly ok: true; readonly value: SyncRuntime } + | { readonly ok: false; readonly error: string } +> { + if (!input.ctx.project) { + return { ok: false, error: "No project found. Run inside a repo." }; + } + const token = await resolveLinearTokenWithBrokerRefresh({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: !input.profileId, + }); + if (!token.ok) { + return { ok: false, error: token.error }; + } + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + profileId: token.profileId, + allowProjectOverride: false, + }); + + const tickets = createTicketsStore({ + projectRoot: input.ctx.project.projectRoot, + projectId: input.ctx.projectId, + projectName: input.ctx.projectName, + controlPlaneConfig: input.ctx.controlPlaneConfig, + logger: input.ctx.logger, + }); + const linear = createLinearClient({ + token: token.token, + apiUrl: settings.apiUrl, + }); + return { + ok: true, + value: { + tickets, + linear, + profileId: token.profileId, + apiUrl: settings.apiUrl, + projectBinding: resolveProjectLinearBinding({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + }), + }, + }; +} + +async function syncIssueFromLinearToTicket(input: { + readonly runtime: SyncRuntime; + readonly issueIdentifier: string; + readonly syncToggles: SyncToggles; +}): Promise< + | { + readonly ok: true; + readonly operation: "created" | "updated"; + readonly ticketId: string; + readonly issueIdentifier: string; + } + | { readonly ok: false; readonly error: string } +> { + const issueResult = await input.runtime.linear.getIssueByIdentifier({ + identifier: input.issueIdentifier, + }); + if (!issueResult.ok) { + return { ok: false, error: issueResult.error }; + } + if (!issueResult.data) { + return { + ok: false, + error: `Linear issue not found: ${input.issueIdentifier}`, + }; + } + + return await upsertTicketFromLinearIssue({ + runtime: input.runtime, + issue: issueResult.data, + syncToggles: input.syncToggles, + dependencyIndex: await buildLinearDependencyIndex({ + tickets: input.runtime.tickets, + }), + }); +} + +async function syncTicketToLinearIssue(input: { + readonly runtime: SyncRuntime; + readonly ticketId: string; + readonly explicitProjectId?: string; + readonly explicitTeamId?: string; + readonly syncToggles: SyncToggles; +}): Promise< + | { + readonly ok: true; + readonly operation: "created" | "updated"; + readonly ticketId: string; + readonly issueIdentifier: string; + readonly issueId: string; + } + | { readonly ok: false; readonly error: string } +> { + const ticket = await input.runtime.tickets.getTicket({ + ticketId: input.ticketId, + }); + if (!ticket) { + return { ok: false, error: `Ticket not found: ${input.ticketId}` }; + } + + const existingIssue = await resolveLinkedLinearIssue({ + runtime: input.runtime, + ticket, + }); + if (!existingIssue.ok) { + return existingIssue; + } + + const target = await resolveLinearTargetForTicketSync({ + runtime: input.runtime, + ticket, + existingIssue: existingIssue.issue, + explicitProjectId: input.explicitProjectId, + explicitTeamId: input.explicitTeamId, + }); + if (!target.ok) { + return target; + } + + const fields = await resolveLinearMutationFieldsForTicketSync({ + runtime: input.runtime, + ticket, + teamId: target.value.teamId, + syncToggles: input.syncToggles, + }); + if (!fields.ok) { + return fields; + } + + const syncedIssue = await upsertLinearIssueForTicketSync({ + runtime: input.runtime, + ticket, + existingIssue: existingIssue.issue, + fields: fields.value, + teamId: target.value.teamId, + projectId: target.value.projectId, + syncToggles: input.syncToggles, + }); + + if (!syncedIssue.ok) { + return { ok: false, error: syncedIssue.error }; + } + + const updated = await input.runtime.tickets.updateTicket({ + ticketId: ticket.ticketId, + externalSystem: "linear", + externalId: syncedIssue.data.id, + externalKey: syncedIssue.data.identifier, + externalUrl: syncedIssue.data.url, + externalProjectId: syncedIssue.data.projectId, + externalProjectName: syncedIssue.data.projectName, + externalTeamId: syncedIssue.data.teamId, + }); + if (!updated.ok) { + return { ok: false, error: updated.error }; + } + + return { + ok: true, + operation: existingIssue.issue ? "updated" : "created", + ticketId: ticket.ticketId, + issueIdentifier: syncedIssue.data.identifier, + issueId: syncedIssue.data.id, + }; +} + +async function resolveLinearTargetForTicketSync(input: { + readonly runtime: SyncRuntime; + readonly ticket: TicketSummary; + readonly existingIssue: LinearIssue | null; + readonly explicitProjectId?: string; + readonly explicitTeamId?: string; +}): Promise< + | { + readonly ok: true; + readonly value: { readonly teamId: string; readonly projectId?: string }; + } + | { readonly ok: false; readonly error: string } +> { + let projectId = + input.explicitProjectId ?? + input.existingIssue?.projectId ?? + input.ticket.externalProjectId ?? + input.runtime.projectBinding.projectId; + let teamId = + input.explicitTeamId ?? + input.existingIssue?.teamId ?? + input.ticket.externalTeamId ?? + input.runtime.projectBinding.teamId; + + if (!(teamId || !projectId)) { + const projectResult = await input.runtime.linear.getProject({ projectId }); + if (!projectResult.ok) { + return { ok: false, error: projectResult.error }; + } + if (!projectResult.data) { + return { ok: false, error: `Linear project not found: ${projectId}` }; + } + teamId = projectResult.data.teamId; + projectId = projectResult.data.id; + } + + if (!teamId) { + return { + ok: false, + error: + "Cannot resolve Linear team for ticket sync. Pass --team-id or bind a project with `hack x linear project-bind`.", + }; + } + + return { + ok: true, + value: { teamId, ...(projectId ? { projectId } : {}) }, + }; +} + +type LinearTicketMutationFields = { + readonly title: string; + readonly description: string; + readonly stateId?: string; + readonly labelIds?: readonly string[]; + readonly parentId?: string; +}; + +async function resolveLinearMutationFieldsForTicketSync(input: { + readonly runtime: SyncRuntime; + readonly ticket: TicketSummary; + readonly teamId: string; + readonly syncToggles: SyncToggles; +}): Promise< + | { readonly ok: true; readonly value: LinearTicketMutationFields } + | { readonly ok: false; readonly error: string } +> { + const stateId = await resolveLinearStateIdForTicketStatus({ + runtime: input.runtime, + teamId: input.teamId, + status: input.ticket.status, + enabled: input.syncToggles.statuses, + }); + if (!stateId.ok) { + return { ok: false, error: stateId.error }; + } + + const labelIds = await resolveLinearLabelIds({ + runtime: input.runtime, + teamId: input.teamId, + tags: input.ticket.tags, + enabled: input.syncToggles.labels, + }); + if (!labelIds.ok) { + return { ok: false, error: labelIds.error }; + } + + const parentId = await resolveLinearParentIssueId({ + runtime: input.runtime, + ticket: input.ticket, + enabled: input.syncToggles.dependencies, + }); + if (!parentId.ok) { + return { ok: false, error: parentId.error }; + } + + const title = input.ticket.title.trim(); + if (!title) { + return { + ok: false, + error: `Ticket ${input.ticket.ticketId} has an empty title and cannot be synced.`, + }; + } + + return { + ok: true, + value: { + title, + description: input.ticket.body ?? "", + ...(stateId.value ? { stateId: stateId.value } : {}), + ...(labelIds.value ? { labelIds: labelIds.value } : {}), + ...(parentId.value !== undefined ? { parentId: parentId.value } : {}), + }, + }; +} + +async function upsertLinearIssueForTicketSync(input: { + readonly runtime: SyncRuntime; + readonly ticket: TicketSummary; + readonly existingIssue: LinearIssue | null; + readonly fields: LinearTicketMutationFields; + readonly teamId: string; + readonly projectId?: string; + readonly syncToggles: SyncToggles; +}) { + if (input.existingIssue) { + return await input.runtime.linear.updateIssue({ + issueId: input.existingIssue.id, + title: input.fields.title, + description: input.fields.description, + ...(input.syncToggles.projects ? { projectId: input.projectId } : {}), + ...(input.fields.stateId ? { stateId: input.fields.stateId } : {}), + ...(input.fields.labelIds ? { labelIds: input.fields.labelIds } : {}), + ...(input.fields.parentId !== undefined + ? { parentId: input.fields.parentId } + : {}), + }); + } + + return await input.runtime.linear.createIssue({ + teamId: input.teamId, + title: input.fields.title, + description: input.fields.description, + ...(input.syncToggles.projects ? { projectId: input.projectId } : {}), + ...(input.fields.stateId ? { stateId: input.fields.stateId } : {}), + ...(input.fields.labelIds ? { labelIds: input.fields.labelIds } : {}), + ...(input.fields.parentId !== undefined + ? { parentId: input.fields.parentId } + : {}), + }); +} + +async function syncProjectFromLinearToTickets(input: { + readonly runtime: SyncRuntime; + readonly projectId: string; + readonly limit?: number; + readonly syncToggles: SyncToggles; +}): Promise< + | { + readonly ok: true; + readonly processed: number; + readonly created: number; + readonly updated: number; + } + | { readonly ok: false; readonly error: string } +> { + const limit = normalizePositiveInteger({ + value: input.limit, + fallback: DEFAULT_PROJECT_SYNC_LIMIT, + }); + + const issuesResult = await collectLinearProjectIssuesForSync({ + runtime: input.runtime, + projectId: input.projectId, + limit, + }); + if (!issuesResult.ok) { + return issuesResult; + } + + const dependencyIndex = await buildLinearDependencyIndex({ + tickets: input.runtime.tickets, + }); + + const upserted = await upsertLinearProjectIssuesToTickets({ + runtime: input.runtime, + issues: issuesResult.issues, + syncToggles: input.syncToggles, + dependencyIndex, + }); + if (!upserted.ok) { + return upserted; + } + + if (input.syncToggles.dependencies) { + const dependencies = await applyLinearProjectDependenciesToTickets({ + runtime: input.runtime, + issues: issuesResult.issues, + dependencyIndex, + }); + if (!dependencies.ok) { + return dependencies; + } + } + + return { + ok: true, + processed: issuesResult.processed, + created: upserted.created, + updated: upserted.updated, + }; +} + +async function collectLinearProjectIssuesForSync(input: { + readonly runtime: SyncRuntime; + readonly projectId: string; + readonly limit: number; +}): Promise< + | { + readonly ok: true; + readonly issues: readonly LinearIssue[]; + readonly processed: number; + } + | { readonly ok: false; readonly error: string } +> { + let cursor: string | undefined; + let processed = 0; + const issues: LinearIssue[] = []; + + while (processed < input.limit) { + const page = await input.runtime.linear.listProjectIssuesPage({ + projectId: input.projectId, + first: Math.min(50, input.limit - processed), + ...(cursor ? { after: cursor } : {}), + }); + if (!page.ok) { + return { ok: false, error: page.error }; + } + + issues.push(...page.data.issues); + processed += page.data.issues.length; + if ( + !(page.data.hasNextPage && page.data.endCursor && processed < input.limit) + ) { + break; + } + cursor = page.data.endCursor; + } + + return { ok: true, issues, processed }; +} + +async function upsertLinearProjectIssuesToTickets(input: { + readonly runtime: SyncRuntime; + readonly issues: readonly LinearIssue[]; + readonly syncToggles: SyncToggles; + readonly dependencyIndex: { + readonly byLinearId: Map; + readonly byLinearIdentifier: Map; + }; +}): Promise< + | { readonly ok: true; readonly created: number; readonly updated: number } + | { readonly ok: false; readonly error: string } +> { + let created = 0; + let updated = 0; + + for (const issue of input.issues) { + const synced = await upsertTicketFromLinearIssue({ + runtime: input.runtime, + issue, + syncToggles: input.syncToggles, + dependencyIndex: input.dependencyIndex, + }); + if (!synced.ok) { + return synced; + } + if (synced.operation === "created") { + created += 1; + } else { + updated += 1; + } + input.dependencyIndex.byLinearId.set(issue.id, synced.ticketId); + input.dependencyIndex.byLinearIdentifier.set( + issue.identifier, + synced.ticketId + ); + } + + return { + ok: true, + created, + updated, + }; +} + +async function applyLinearProjectDependenciesToTickets(input: { + readonly runtime: SyncRuntime; + readonly issues: readonly LinearIssue[]; + readonly dependencyIndex: { + readonly byLinearId: Map; + readonly byLinearIdentifier: Map; + }; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + for (const issue of input.issues) { + if (!issue.parentId) { + continue; + } + const childTicketId = input.dependencyIndex.byLinearId.get(issue.id); + const parentTicketId = input.dependencyIndex.byLinearId.get(issue.parentId); + if (!(childTicketId && parentTicketId)) { + continue; + } + const updatedDependency = await input.runtime.tickets.updateTicket({ + ticketId: childTicketId, + dependsOn: [parentTicketId], + }); + if (!updatedDependency.ok) { + return { ok: false, error: updatedDependency.error }; + } + } + + return { ok: true }; +} + +async function syncProjectFromTicketsToLinear(input: { + readonly runtime: SyncRuntime; + readonly projectId?: string; + readonly explicitTeamId?: string; + readonly ownerMode: "hack" | "linear" | "both"; + readonly limit?: number; + readonly syncToggles: SyncToggles; +}): Promise< + | { + readonly ok: true; + readonly processed: number; + readonly created: number; + readonly updated: number; + } + | { readonly ok: false; readonly error: string } +> { + const limit = normalizePositiveInteger({ + value: input.limit, + fallback: DEFAULT_PROJECT_SYNC_LIMIT, + }); + const allTickets = await input.runtime.tickets.listTickets(); + const candidates = allTickets + .filter((ticket) => { + if (input.ownerMode === "both") { + return true; + } + return ticket.owner.toLowerCase() === input.ownerMode; + }) + .slice(0, limit); + + let created = 0; + let updated = 0; + + for (const ticket of candidates) { + const synced = await syncTicketToLinearIssue({ + runtime: input.runtime, + ticketId: ticket.ticketId, + explicitProjectId: input.projectId, + explicitTeamId: input.explicitTeamId, + syncToggles: input.syncToggles, + }); + if (!synced.ok) { + return synced; + } + if (synced.operation === "created") { + created += 1; + } else { + updated += 1; + } + } + + return { + ok: true, + processed: candidates.length, + created, + updated, + }; +} + +async function upsertTicketFromLinearIssue(input: { + readonly runtime: SyncRuntime; + readonly issue: LinearIssue; + readonly syncToggles: SyncToggles; + readonly dependencyIndex: { + readonly byLinearId: Map; + readonly byLinearIdentifier: Map; + }; +}): Promise< + | { + readonly ok: true; + readonly operation: "created" | "updated"; + readonly ticketId: string; + readonly issueIdentifier: string; + } + | { readonly ok: false; readonly error: string } +> { + const snapshot = await input.runtime.tickets.listTickets(); + const existing = snapshot.find((ticket) => + isLinearLinkedTicket({ ticket, issue: input.issue }) + ); + + const projection = buildTicketProjectionFromLinearIssue({ + issue: input.issue, + existingTicket: existing, + syncToggles: input.syncToggles, + dependencyIndex: input.dependencyIndex, + }); + + return existing + ? await applyLinearIssueToExistingTicket({ + runtime: input.runtime, + issue: input.issue, + existingTicket: existing, + projection, + syncToggles: input.syncToggles, + }) + : await createTicketFromLinearIssueProjection({ + runtime: input.runtime, + issue: input.issue, + projection, + syncToggles: input.syncToggles, + }); +} + +type TicketProjectionFromLinearIssue = { + readonly body: string; + readonly tags: readonly string[]; + readonly dependsOn?: readonly string[]; + readonly status: TicketStatus; +}; + +function buildTicketProjectionFromLinearIssue(input: { + readonly issue: LinearIssue; + readonly existingTicket?: TicketSummary; + readonly syncToggles: SyncToggles; + readonly dependencyIndex: { + readonly byLinearId: Map; + readonly byLinearIdentifier: Map; + }; +}): TicketProjectionFromLinearIssue { + const mappedParentTicketId = resolveLinearParentTicketId({ + issue: input.issue, + dependencyIndex: input.dependencyIndex, + }); + const body = renderTicketBodyFromLinearIssue({ issue: input.issue }); + const tags = input.syncToggles.labels + ? input.issue.labels.map((label) => label.name) + : (input.existingTicket?.tags ?? []); + + let dependsOn: readonly string[] | undefined; + if (input.syncToggles.dependencies) { + dependsOn = mappedParentTicketId ? [mappedParentTicketId] : []; + } else { + dependsOn = input.existingTicket?.dependsOn; + } + + return { + body, + tags, + ...(dependsOn !== undefined ? { dependsOn } : {}), + status: mapLinearStateToTicketStatus({ state: input.issue.state }), + }; +} + +function resolveLinearParentTicketId(input: { + readonly issue: LinearIssue; + readonly dependencyIndex: { + readonly byLinearId: Map; + readonly byLinearIdentifier: Map; + }; +}): string | undefined { + if (input.issue.parentId) { + return input.dependencyIndex.byLinearId.get(input.issue.parentId); + } + if (input.issue.parentIdentifier) { + return input.dependencyIndex.byLinearIdentifier.get( + input.issue.parentIdentifier + ); + } + return undefined; +} + +async function applyLinearIssueToExistingTicket(input: { + readonly runtime: SyncRuntime; + readonly issue: LinearIssue; + readonly existingTicket: TicketSummary; + readonly projection: TicketProjectionFromLinearIssue; + readonly syncToggles: SyncToggles; +}): Promise< + | { + readonly ok: true; + readonly operation: "updated"; + readonly ticketId: string; + readonly issueIdentifier: string; + } + | { readonly ok: false; readonly error: string } +> { + const updated = await input.runtime.tickets.updateTicket({ + ticketId: input.existingTicket.ticketId, + title: input.issue.title, + body: input.projection.body, + owner: "linear", + source: "linear", + tags: input.projection.tags, + ...(input.projection.dependsOn !== undefined + ? { dependsOn: input.projection.dependsOn } + : {}), + externalSystem: "linear", + externalId: input.issue.id, + externalKey: input.issue.identifier, + externalUrl: input.issue.url, + externalProjectId: input.issue.projectId, + externalProjectName: input.issue.projectName, + externalTeamId: input.issue.teamId, + }); + if (!updated.ok) { + return { ok: false, error: updated.error }; + } + + if (input.syncToggles.statuses) { + const setStatus = await input.runtime.tickets.setStatus({ + ticketId: input.existingTicket.ticketId, + status: input.projection.status, + }); + if (!setStatus.ok) { + return { ok: false, error: setStatus.error }; + } + } + + return { + ok: true, + operation: "updated", + ticketId: input.existingTicket.ticketId, + issueIdentifier: input.issue.identifier, + }; +} + +async function createTicketFromLinearIssueProjection(input: { + readonly runtime: SyncRuntime; + readonly issue: LinearIssue; + readonly projection: TicketProjectionFromLinearIssue; + readonly syncToggles: SyncToggles; +}): Promise< + | { + readonly ok: true; + readonly operation: "created"; + readonly ticketId: string; + readonly issueIdentifier: string; + } + | { readonly ok: false; readonly error: string } +> { + const created = await input.runtime.tickets.createTicket({ + title: input.issue.title, + body: input.projection.body, + owner: "linear", + source: "linear", + tags: input.projection.tags, + ...(input.projection.dependsOn !== undefined + ? { dependsOn: input.projection.dependsOn } + : {}), + externalSystem: "linear", + externalId: input.issue.id, + externalKey: input.issue.identifier, + externalUrl: input.issue.url, + externalProjectId: input.issue.projectId, + externalProjectName: input.issue.projectName, + externalTeamId: input.issue.teamId, + }); + if (!created.ok) { + return created; + } + + if (input.syncToggles.statuses && input.projection.status !== "open") { + const statusResult = await input.runtime.tickets.setStatus({ + ticketId: created.ticket.ticketId, + status: input.projection.status, + }); + if (!statusResult.ok) { + return { ok: false, error: statusResult.error }; + } + } + + return { + ok: true, + operation: "created", + ticketId: created.ticket.ticketId, + issueIdentifier: input.issue.identifier, + }; +} + +async function buildLinearDependencyIndex(input: { + readonly tickets: ReturnType; +}): Promise<{ + readonly byLinearId: Map; + readonly byLinearIdentifier: Map; +}> { + const tickets = await input.tickets.listTickets(); + const byLinearId = new Map(); + const byLinearIdentifier = new Map(); + for (const ticket of tickets) { + if (ticket.externalSystem !== "linear") { + continue; + } + if (ticket.externalId) { + byLinearId.set(ticket.externalId, ticket.ticketId); + } + if (ticket.externalKey) { + byLinearIdentifier.set(ticket.externalKey, ticket.ticketId); + } + } + return { + byLinearId, + byLinearIdentifier, + }; +} + +async function resolveLinkedLinearIssue(input: { + readonly runtime: SyncRuntime; + readonly ticket: TicketSummary; +}): Promise< + | { readonly ok: true; readonly issue: LinearIssue | null } + | { readonly ok: false; readonly error: string } +> { + if (input.ticket.externalSystem !== "linear") { + return { ok: true, issue: null }; + } + if (input.ticket.externalId) { + const byId = await input.runtime.linear.getIssueById({ + issueId: input.ticket.externalId, + }); + if (!byId.ok) { + return { ok: false, error: byId.error }; + } + if (byId.data) { + return { ok: true, issue: byId.data }; + } + } + if (input.ticket.externalKey) { + const byKey = await input.runtime.linear.getIssueByIdentifier({ + identifier: input.ticket.externalKey, + }); + if (!byKey.ok) { + return { ok: false, error: byKey.error }; + } + return { ok: true, issue: byKey.data }; + } + return { ok: true, issue: null }; +} + +async function resolveLinearStateIdForTicketStatus(input: { + readonly runtime: SyncRuntime; + readonly teamId: string; + readonly status: TicketStatus; + readonly enabled: boolean; +}): Promise< + | { readonly ok: true; readonly value?: string } + | { readonly ok: false; readonly error: string } +> { + if (!input.enabled) { + return { ok: true }; + } + + const states = await input.runtime.linear.listTeamStates({ + teamId: input.teamId, + }); + if (!states.ok) { + return { ok: false, error: states.error }; + } + const desiredType = mapTicketStatusToLinearStateType({ + status: input.status, + }); + const selected = + states.data.find((state) => state.type === desiredType) ?? + pickFallbackState({ states: states.data, desiredType }); + return { ok: true, ...(selected ? { value: selected.id } : {}) }; +} + +async function resolveLinearLabelIds(input: { + readonly runtime: SyncRuntime; + readonly teamId: string; + readonly tags: readonly string[]; + readonly enabled: boolean; +}): Promise< + | { readonly ok: true; readonly value?: readonly string[] } + | { readonly ok: false; readonly error: string } +> { + if (!input.enabled) { + return { ok: true }; + } + if (input.tags.length === 0) { + return { ok: true, value: [] }; + } + + const labels = await input.runtime.linear.listTeamLabels({ + teamId: input.teamId, + }); + if (!labels.ok) { + return { ok: false, error: labels.error }; + } + + const labelByName = new Map(); + for (const label of labels.data) { + labelByName.set(label.name.toLowerCase(), label.id); + } + + const resolved: string[] = []; + for (const tag of input.tags) { + const match = labelByName.get(tag.toLowerCase()); + if (match) { + resolved.push(match); + } + } + + const deduped = [...new Set(resolved)]; + return { ok: true, value: deduped }; +} + +async function resolveLinearParentIssueId(input: { + readonly runtime: SyncRuntime; + readonly ticket: TicketSummary; + readonly enabled: boolean; +}): Promise< + | { readonly ok: true; readonly value?: string } + | { readonly ok: false; readonly error: string } +> { + if (!input.enabled) { + return { ok: true }; + } + const parentTicketRef = input.ticket.dependsOn[0]; + if (!parentTicketRef) { + return { ok: true, value: undefined }; + } + const parentTicket = await input.runtime.tickets.getTicket({ + ticketId: parentTicketRef, + }); + if (!parentTicket) { + return { ok: true }; + } + if (parentTicket.externalSystem === "linear" && parentTicket.externalId) { + return { ok: true, value: parentTicket.externalId }; + } + return { ok: true }; +} + +function renderTicketBodyFromLinearIssue(input: { + readonly issue: LinearIssue; +}): string { + const chunks: string[] = []; + const description = (input.issue.description ?? "").trim(); + if (description) { + chunks.push(description); + } + const metadataLines = [ + `Linear Issue: ${input.issue.identifier}`, + ...(input.issue.url ? [`Linear URL: ${input.issue.url}`] : []), + ]; + if (chunks.length > 0) { + chunks.push("---"); + } + chunks.push(...metadataLines); + return chunks.join("\n\n").trim(); +} + +function isLinearLinkedTicket(input: { + readonly ticket: TicketSummary; + readonly issue: LinearIssue; +}): boolean { + if (input.ticket.externalSystem !== "linear") { + return false; + } + if (input.ticket.externalId && input.ticket.externalId === input.issue.id) { + return true; + } + if ( + input.ticket.externalKey && + input.ticket.externalKey === input.issue.identifier + ) { + return true; + } + return false; +} + +function mapLinearStateToTicketStatus(input: { + readonly state: LinearWorkflowState; +}): TicketStatus { + if (input.state.type === "completed" || input.state.type === "canceled") { + return "done"; + } + if (input.state.type === "started") { + return "in_progress"; + } + return "open"; +} + +function mapTicketStatusToLinearStateType(input: { + readonly status: TicketStatus; +}): LinearWorkflowStateType { + if (input.status === "done") { + return "completed"; + } + if (input.status === "in_progress" || input.status === "blocked") { + return "started"; + } + return "unstarted"; +} + +function pickFallbackState(input: { + readonly states: readonly LinearWorkflowState[]; + readonly desiredType: LinearWorkflowStateType; +}): LinearWorkflowState | undefined { + return ( + input.states.find((state) => state.type === input.desiredType) ?? + input.states.find((state) => state.type === "started") ?? + input.states.find((state) => state.type === "unstarted") ?? + input.states.find((state) => state.type === "completed") ?? + input.states[0] + ); +} + +function resolveSyncToggles(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly labelsOverride?: boolean; +}): SyncToggles { + const extension = input.controlPlaneConfig.extensions?.[EXTENSION_ID]; + const config = + isRecord(extension) && isRecord(extension.config) ? extension.config : null; + const sync = isRecord(config?.sync) ? config.sync : null; + + const labelsFromConfig = + typeof sync?.labels === "boolean" ? sync.labels : false; + const statuses = typeof sync?.statuses === "boolean" ? sync.statuses : true; + const dependencies = + typeof sync?.dependencies === "boolean" ? sync.dependencies : true; + const projects = typeof sync?.projects === "boolean" ? sync.projects : true; + + return { + labels: + typeof input.labelsOverride === "boolean" + ? input.labelsOverride + : labelsFromConfig, + statuses, + dependencies, + projects, + }; +} + +function shouldSetProfileAsDefault(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId: string; + readonly setDefaultFlag?: boolean; +}): boolean { + if (input.setDefaultFlag === true) { + return true; + } + const catalog = listLinearAuthProfiles({ + controlPlaneConfig: input.controlPlaneConfig, + allowProjectOverride: false, + }); + if (catalog.profiles.length === 0) { + return true; + } + return ( + catalog.profiles.length === 1 && catalog.profiles[0]?.id === input.profileId + ); +} + +async function persistLinearProfileDefaults(input: { + readonly profileId: string; + readonly tokenEnv: string; + readonly authRef: string; + readonly service: string; + readonly apiUrl: string; + readonly accountId?: string; + readonly accountName?: string; + readonly accountEmail?: string; + readonly setAsDefault: boolean; +}): Promise { + const profilePath = linearProfilePath({ + profileId: input.profileId, + }); + await Promise.all([ + updateGlobalConfig({ + path: `controlPlane.extensions["${EXTENSION_ID}"].enabled`, + value: true, + }), + updateGlobalConfig({ + path: `${profilePath}.tokenEnv`, + value: input.tokenEnv, + }), + updateGlobalConfig({ + path: `${profilePath}.authRef`, + value: input.authRef, + }), + updateGlobalConfig({ + path: `${profilePath}.service`, + value: input.service, + }), + updateGlobalConfig({ + path: `${profilePath}.apiUrl`, + value: input.apiUrl, + }), + updateGlobalConfig({ + path: `${profilePath}.accountId`, + value: input.accountId ?? "", + }), + updateGlobalConfig({ + path: `${profilePath}.accountName`, + value: input.accountName ?? "", + }), + updateGlobalConfig({ + path: `${profilePath}.accountEmail`, + value: input.accountEmail ?? "", + }), + ...(input.setAsDefault + ? [ + updateGlobalConfig({ + path: `controlPlane.extensions["${EXTENSION_ID}"].config.defaultProfile`, + value: input.profileId, + }), + ] + : []), + ]); +} + +function linearProfilePath(input: { readonly profileId: string }): string { + const escapedProfileId = input.profileId + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"'); + return `controlPlane.extensions["${EXTENSION_ID}"].config.profiles["${escapedProfileId}"]`; +} + +function resolveProjectLinearBinding(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; +}): { + readonly profileId?: string; + readonly projectId?: string; + readonly projectName?: string; + readonly teamId?: string; +} { + const overrides = input.controlPlaneConfig.routing?.overrides; + if (!isRecord(overrides)) { + return {}; + } + const nested = isRecord(overrides.linear) ? overrides.linear : null; + + const profileId = + readOptionalString(nested?.profile) ?? + readOptionalString(overrides.linearProfile); + const projectId = + readOptionalString(nested?.projectId) ?? + readOptionalString(overrides.linearProjectId); + const projectName = + readOptionalString(nested?.projectName) ?? + readOptionalString(overrides.linearProjectName); + const teamId = + readOptionalString(nested?.teamId) ?? + readOptionalString(overrides.linearTeamId); + + return { + ...(profileId ? { profileId } : {}), + ...(projectId ? { projectId } : {}), + ...(projectName ? { projectName } : {}), + ...(teamId ? { teamId } : {}), + }; +} + +async function resolveProjectBindingDetails(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; + readonly projectId: string; + readonly projectName?: string; + readonly teamId?: string; +}): Promise< + | { + readonly ok: true; + readonly projectName: string; + readonly teamId: string; + } + | { readonly ok: false; readonly error: string } +> { + const providedProjectName = (input.projectName ?? "").trim(); + const providedTeamId = (input.teamId ?? "").trim(); + if (providedProjectName && providedTeamId) { + return { + ok: true, + projectName: providedProjectName, + teamId: providedTeamId, + }; + } + + const token = await resolveLinearTokenWithBrokerRefresh({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: !input.profileId, + }); + if (!token.ok) { + return { ok: false, error: token.error }; + } + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: token.profileId, + allowProjectOverride: false, + }); + const client = createLinearClient({ + token: token.token, + apiUrl: settings.apiUrl, + }); + const project = await client.getProject({ projectId: input.projectId }); + if (!project.ok) { + return { ok: false, error: project.error }; + } + if (!project.data) { + return { + ok: false, + error: `Linear project not found: ${input.projectId}`, + }; + } + return { + ok: true, + projectName: providedProjectName || project.data.name, + teamId: providedTeamId || project.data.teamId, + }; +} + +async function enableLinearProjectExtension(input: { + readonly projectDir: string; +}): Promise { + await updateProjectConfig({ + projectDir: input.projectDir, + path: `controlPlane.extensions["${EXTENSION_ID}"].enabled`, + value: true, + }); +} + +type OAuthRuntimeConfig = { + readonly clientId: string; + readonly clientSecret?: string; + readonly actor?: "user" | "app"; + readonly scopes: string; + readonly authorizeUrl: string; + readonly tokenUrl: string; +}; + +type OAuthBrokerRuntimeConfig = { + readonly baseUrl: string; +}; + +type BrokerStartFlowPayload = { + readonly flowId: string; + readonly profileId: string; + readonly setDefault: boolean; + readonly authorizeUrl: string; + readonly deviceCode: string; + readonly pollUrl: string; + readonly expiresAt: string; +}; + +type BrokerStartFlowEnvelope = { + readonly ok: true; + readonly flow: BrokerStartFlowPayload; +}; + +type BrokerFlowStatusPayload = { + readonly id: string; + readonly status: string; + readonly profileId: string; + readonly setDefault: boolean; + readonly createdAt: string; + readonly expiresAt: string; + readonly completedAt?: string; + readonly claimedAt?: string; + readonly accountHandle?: string; + readonly accountLogin?: string; + readonly accountName?: string; + readonly accountId?: string; + readonly accountEmail?: string; + readonly token?: string; + readonly tokenExpiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly error?: string; +}; + +type BrokerFlowStatusEnvelope = + | { + readonly ok: true; + readonly status: BrokerFlowStatusPayload; + } + | { + readonly ok: false; + readonly error: string; + }; + +async function resolveOAuthRuntimeConfig(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly parsed: OAuthConnectArgs; +}): Promise< + | { readonly ok: true; readonly value: OAuthRuntimeConfig } + | { readonly ok: false; readonly error: string } +> { + const extension = input.controlPlaneConfig.extensions?.[EXTENSION_ID]; + const config = + isRecord(extension) && isRecord(extension.config) ? extension.config : null; + + const clientId = + input.parsed.clientId ?? + readOptionalString(config?.oauthClientId) ?? + readOptionalString(process.env[DEFAULT_OAUTH_CLIENT_ID_ENV]) ?? + readOptionalString(process.env[DEFAULT_OAUTH_CLIENT_ID_ENV_FALLBACK]); + if (!clientId) { + return { + ok: false, + error: `Missing Linear OAuth client id. Set \`controlPlane.extensions["${EXTENSION_ID}"].config.oauthClientId\`, set ${DEFAULT_OAUTH_CLIENT_ID_ENV} or ${DEFAULT_OAUTH_CLIENT_ID_ENV_FALLBACK}, or pass --client-id.`, + }; + } + + const secretAuthRef = + input.parsed.clientSecretAuthRef ?? + readOptionalString(config?.oauthClientSecretAuthRef) ?? + DEFAULT_OAUTH_CLIENT_SECRET_AUTH_REF; + const secretService = + input.parsed.clientSecretService ?? + readOptionalString(config?.oauthClientSecretService) ?? + DEFAULT_OAUTH_SECRET_SERVICE; + + const secretResolution = await resolveOptionalOAuthClientSecret({ + parsed: input.parsed, + authRef: secretAuthRef, + service: secretService, + }); + if (!secretResolution.ok) { + return secretResolution; + } + + if (secretResolution.shouldPersist && secretResolution.clientSecret) { + await secrets.set({ + service: secretService, + name: secretAuthRef, + value: secretResolution.clientSecret, + }); + } + + const scopes = + input.parsed.scopes ?? + readOptionalString(config?.oauthScopes) ?? + DEFAULT_OAUTH_SCOPES; + const actor = normalizeOAuthActor( + readOptionalString(config?.oauthActor) ?? DEFAULT_OAUTH_ACTOR + ); + const authorizeUrl = + input.parsed.authorizeUrl ?? + readOptionalString(config?.oauthAuthorizeUrl) ?? + DEFAULT_OAUTH_AUTHORIZE_URL; + const tokenUrl = + input.parsed.tokenUrl ?? + readOptionalString(config?.oauthTokenUrl) ?? + DEFAULT_OAUTH_TOKEN_URL; + + return { + ok: true, + value: { + clientId, + ...(secretResolution.clientSecret + ? { clientSecret: secretResolution.clientSecret } + : {}), + ...(actor ? { actor } : {}), + scopes, + authorizeUrl, + tokenUrl, + }, + }; +} + +function resolveOAuthBrokerRuntimeConfig(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; +}): OAuthBrokerRuntimeConfig { + const extension = input.controlPlaneConfig.extensions?.[EXTENSION_ID]; + const config = + isRecord(extension) && isRecord(extension.config) ? extension.config : null; + const baseUrl = + readOptionalString(config?.oauthBrokerUrl) ?? + readOptionalString(process.env[DEFAULT_OAUTH_BROKER_URL_ENV]) ?? + readOptionalString(process.env[DEFAULT_OAUTH_BROKER_URL_ENV_FALLBACK]) ?? + DEFAULT_OAUTH_BROKER_URL; + return { + baseUrl: trimTrailingSlash({ value: baseUrl }), + }; +} + +async function resolveLinearTokenWithBrokerRefresh(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; +}): Promise>> { + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + return await resolveLinearToken({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + refreshConfig: { + baseUrl: brokerConfig.baseUrl, + }, + }); +} + +function shouldUseBrokerOAuthFlow(input: { + readonly parsed: OAuthConnectArgs; +}): boolean { + return !( + input.parsed.clientId || + input.parsed.clientSecret || + input.parsed.clientSecretStdin || + input.parsed.clientSecretAuthRef || + input.parsed.clientSecretService || + input.parsed.scopes || + input.parsed.authorizeUrl || + input.parsed.tokenUrl + ); +} + +async function resolveOptionalOAuthClientSecret(input: { + readonly parsed: OAuthConnectArgs; + readonly authRef: string; + readonly service: string; +}): Promise< + | { + readonly ok: true; + readonly clientSecret?: string; + readonly shouldPersist: boolean; + } + | { readonly ok: false; readonly error: string } +> { + const direct = (input.parsed.clientSecret ?? "").trim(); + if (direct) { + return { ok: true, clientSecret: direct, shouldPersist: true }; + } + + if (input.parsed.clientSecretStdin) { + const text = (await Bun.stdin.text()).trim(); + if (!text) { + return { ok: false, error: "Missing client secret from stdin." }; + } + return { ok: true, clientSecret: text, shouldPersist: true }; + } + + const fromEnv = + readOptionalString(process.env[DEFAULT_OAUTH_CLIENT_SECRET_ENV]) ?? + readOptionalString(process.env[DEFAULT_OAUTH_CLIENT_SECRET_ENV_FALLBACK]); + if (fromEnv) { + return { ok: true, clientSecret: fromEnv, shouldPersist: false }; + } + + const stored = await secrets.get({ + service: input.service, + name: input.authRef, + }); + if (stored?.trim()) { + return { ok: true, clientSecret: stored.trim(), shouldPersist: false }; + } + + return { ok: true, shouldPersist: false }; +} + +type LinearOAuthCallbackOutcome = + | { readonly ok: true; readonly code: string } + | { readonly ok: false; readonly error: string }; + +type LinearOAuthCallbackServer = { + readonly redirectUri: string; + readonly waitForCallback: (input: { + readonly expectedState: string; + }) => Promise; + readonly stop: () => void; +}; + +function startLinearOAuthCallbackServer(input: { + readonly timeoutMs: number; +}): LinearOAuthCallbackServer { + let resolver: ((result: LinearOAuthCallbackOutcome) => void) | null = null; + let receivedState: string | undefined; + const outcome = new Promise((resolve) => { + resolver = resolve; + }); + + const timeout = setTimeout(() => { + resolver?.({ + ok: false, + error: "OAuth callback timed out. Retry the flow.", + }); + }, input.timeoutMs); + + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: (req) => { + const url = new URL(req.url); + if (url.pathname !== "/linear/callback") { + return new Response("Not found", { status: 404 }); + } + + const code = readOptionalString(url.searchParams.get("code")); + const state = readOptionalString(url.searchParams.get("state")); + const error = readOptionalString(url.searchParams.get("error")); + const errorDescription = readOptionalString( + url.searchParams.get("error_description") + ); + + if (error) { + resolver?.({ + ok: false, + error: errorDescription ?? error, + }); + return renderOAuthCallbackHtml({ + title: "Linear auth denied", + body: errorDescription ?? error, + }); + } + if (!(state && code)) { + resolver?.({ + ok: false, + error: "Missing OAuth code/state in callback.", + }); + return renderOAuthCallbackHtml({ + title: "Linear auth failed", + body: "Missing OAuth code/state in callback.", + }); + } + + receivedState = state; + resolver?.({ ok: true, code }); + return renderOAuthCallbackHtml({ + title: "Linear account connected", + body: "You can close this tab and return to hack.", + }); + }, + }); + + const redirectUri = `http://127.0.0.1:${server.port}/linear/callback`; + + return { + redirectUri, + waitForCallback: async (input) => { + const result = await outcome; + clearTimeout(timeout); + if (!result.ok) { + return result; + } + if (receivedState !== input.expectedState) { + return { + ok: false, + error: "OAuth callback state mismatch.", + }; + } + return result; + }, + stop: () => { + clearTimeout(timeout); + server.stop(); + }, + }; +} + +function renderOAuthCallbackHtml(input: { + readonly title: string; + readonly body: string; +}): Response { + const html = [ + "", + '', + "", + ' ', + ` ${escapeHtml({ text: input.title })}`, + " ", + "", + "", + '
', + `

${escapeHtml({ text: input.title })}

`, + `

${escapeHtml({ text: input.body })}

`, + "
", + "", + "", + ].join("\n"); + + return new Response(html, { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +function buildAuthorizeUrl(input: { + readonly authorizeUrl: string; + readonly clientId: string; + readonly redirectUri: string; + readonly actor?: "user" | "app"; + readonly scopes: string; + readonly state: string; + readonly codeChallenge?: string; + readonly codeChallengeMethod?: string; +}): string { + const url = new URL(input.authorizeUrl); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + if (input.actor) { + url.searchParams.set("actor", input.actor); + } + url.searchParams.set("scope", input.scopes); + url.searchParams.set("state", input.state); + url.searchParams.set("prompt", "consent"); + if (input.codeChallenge) { + url.searchParams.set("code_challenge", input.codeChallenge); + url.searchParams.set( + "code_challenge_method", + input.codeChallengeMethod ?? "S256" + ); + } + return url.toString(); +} + +async function fetchJson(input: { + readonly url: string; +}): Promise< + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string } +> { + try { + const response = await fetch(input.url, { + headers: { + accept: "application/json", + "user-agent": "hack-cli", + }, + }); + const bodyText = await response.text(); + const payload = bodyText ? (JSON.parse(bodyText) as unknown) : null; + if (!response.ok) { + const error = + isRecord(payload) && typeof payload.error === "string" + ? payload.error + : bodyText.trim() || `HTTP ${response.status}`; + return { ok: false, error }; + } + return { + ok: true, + value: payload as T, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function exchangeLinearOAuthCode(input: { + readonly tokenUrl: string; + readonly clientId: string; + readonly clientSecret?: string; + readonly code: string; + readonly redirectUri: string; + readonly codeVerifier?: string; +}): Promise< + | { + readonly ok: true; + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + } + | { readonly ok: false; readonly error: string } +> { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: input.clientId, + code: input.code, + redirect_uri: input.redirectUri, + ...(input.clientSecret ? { client_secret: input.clientSecret } : {}), + ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}), + }); + const response = await fetch(input.tokenUrl, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + "user-agent": "hack-cli", + }, + body: body.toString(), + }); + + const payload = (await response.json().catch(() => null)) as unknown; + if (!isRecord(payload)) { + return { + ok: false, + error: `Linear token exchange failed (${response.status}): invalid payload.`, + }; + } + + if (!response.ok) { + const message = + readOptionalString(payload.error_description) ?? + readOptionalString(payload.error) ?? + response.statusText; + return { + ok: false, + error: `Linear token exchange failed (${response.status}): ${message}`, + }; + } + + const token = readOptionalString(payload.access_token); + if (!token) { + return { + ok: false, + error: "Linear token exchange failed: missing access_token.", + }; + } + + const expiresIn = + typeof payload.expires_in === "number" && + Number.isFinite(payload.expires_in) + ? Math.max(0, Math.floor(payload.expires_in)) + : null; + const refreshToken = readOptionalString(payload.refresh_token); + const refreshTokenExpiresIn = + typeof payload.refresh_token_expires_in === "number" && + Number.isFinite(payload.refresh_token_expires_in) + ? Math.max(0, Math.floor(payload.refresh_token_expires_in)) + : null; + const expiresAt = + expiresIn === null + ? undefined + : new Date(Date.now() + expiresIn * 1000).toISOString(); + const refreshTokenExpiresAt = + refreshTokenExpiresIn === null + ? undefined + : new Date(Date.now() + refreshTokenExpiresIn * 1000).toISOString(); + + return { + ok: true, + token, + ...(expiresAt ? { expiresAt } : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(refreshTokenExpiresAt ? { refreshTokenExpiresAt } : {}), + }; +} + +function createLinearPkcePair(): { + readonly codeVerifier: string; + readonly codeChallenge: string; +} { + const codeVerifier = randomBytes(64).toString("base64url"); + const codeChallenge = createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + return { codeVerifier, codeChallenge }; +} + +function escapeHtml(input: { readonly text: string }): string { + return input.text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function resolveConnectToken(input: { + readonly token?: string; + readonly stdin: boolean; + readonly tokenEnv: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; +}): Promise< + | { + readonly ok: true; + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + } + | { readonly ok: false; readonly error: string } +> { + const direct = (input.token ?? "").trim(); + if (direct) { + return { + ok: true, + token: direct, + ...(readOptionalString(input.expiresAt) + ? { expiresAt: readOptionalString(input.expiresAt) } + : {}), + ...(readOptionalString(input.refreshToken) + ? { refreshToken: readOptionalString(input.refreshToken) } + : {}), + ...(readOptionalString(input.refreshTokenExpiresAt) + ? { + refreshTokenExpiresAt: readOptionalString( + input.refreshTokenExpiresAt + ), + } + : {}), + }; + } + if (input.stdin) { + const text = (await Bun.stdin.text()).trim(); + if (!text) { + return { ok: false, error: "Missing token from stdin." }; + } + const envelope = parseLinearTokenEnvelope(text); + if (!envelope?.token) { + return { ok: false, error: "Missing token from stdin." }; + } + return { + ok: true, + token: envelope.token, + ...(envelope.expiresAt ? { expiresAt: envelope.expiresAt } : {}), + ...(envelope.refreshToken ? { refreshToken: envelope.refreshToken } : {}), + ...(envelope.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: envelope.refreshTokenExpiresAt } + : {}), + }; + } + const envToken = (process.env[input.tokenEnv] ?? "").trim(); + if (!envToken) { + return { + ok: false, + error: `Missing token. Provide --token, --stdin, or set ${input.tokenEnv}.`, + }; + } + return { ok: true, token: envToken }; +} + +function parseLinearTokenEnvelope(value: string): { + readonly token?: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; +} | null { + const text = value.trim(); + if (!text) { + return null; + } + try { + const parsed = JSON.parse(text) as unknown; + if (!isRecord(parsed)) { + return { token: text }; + } + const token = readOptionalString(parsed.token) ?? undefined; + const refreshToken = readOptionalString(parsed.refreshToken) ?? undefined; + if (!(token || refreshToken)) { + return null; + } + return { + ...(token ? { token } : {}), + ...(readOptionalString(parsed.expiresAt) + ? { expiresAt: readOptionalString(parsed.expiresAt) ?? undefined } + : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(readOptionalString(parsed.refreshTokenExpiresAt) + ? { + refreshTokenExpiresAt: + readOptionalString(parsed.refreshTokenExpiresAt) ?? undefined, + } + : {}), + }; + } catch { + return { token: text }; + } +} + +function shouldFallbackConnectToOAuth(input: { + readonly parsed: ConnectArgs; + readonly tokenEnv: string; +}): boolean { + if (input.parsed.token || input.parsed.stdin) { + return false; + } + const envToken = (process.env[input.tokenEnv] ?? "").trim(); + return envToken.length === 0; +} + +function buildOAuthArgsFromConnectArgs(input: { + readonly parsed: ConnectArgs; + readonly profileId: string; +}): string[] { + return [ + "--profile", + input.profileId, + ...(input.parsed.setDefault ? ["--set-default"] : []), + ...(input.parsed.apiUrl ? ["--api-url", input.parsed.apiUrl] : []), + ...(input.parsed.tokenEnv ? ["--token-env", input.parsed.tokenEnv] : []), + ...(input.parsed.authRef ? ["--auth-ref", input.parsed.authRef] : []), + ...(input.parsed.service ? ["--service", input.parsed.service] : []), + ]; +} + +function normalizePositiveInteger(input: { + readonly value: number | undefined; + readonly fallback: number; +}): number { + if ( + typeof input.value === "number" && + Number.isFinite(input.value) && + input.value > 0 + ) { + return Math.floor(input.value); + } + return input.fallback; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +type SetupArgs = { + profileId?: string; + projectId?: string; + projectName?: string; + teamId?: string; + json: boolean; +}; + +type ConnectArgs = { + profileId?: string; + token?: string; + stdin: boolean; + tokenEnv?: string; + authRef?: string; + service?: string; + apiUrl?: string; + refreshToken?: string; + tokenExpiresAt?: string; + refreshTokenExpiresAt?: string; + setDefault: boolean; +}; + +type OAuthConnectArgs = { + profileId?: string; + setDefault: boolean; + clientId?: string; + clientSecret?: string; + clientSecretStdin: boolean; + clientSecretAuthRef?: string; + clientSecretService?: string; + scopes?: string; + authorizeUrl?: string; + tokenUrl?: string; + apiUrl?: string; + tokenEnv?: string; + authRef?: string; + service?: string; + noOpen: boolean; + json: boolean; +}; + +type DisconnectArgs = { + profileId?: string; + authRef?: string; + service?: string; +}; + +type StatusArgs = { + profileId?: string; + json: boolean; +}; + +type ProfilesArgs = { + json: boolean; +}; + +type UseArgs = { + profileId: string; +}; + +type ProjectsArgs = { + profileId?: string; + limit?: number; + json: boolean; +}; + +type ProjectBindArgs = { + profileId?: string; + projectId?: string; + projectName?: string; + teamId?: string; + clear: boolean; + json: boolean; +}; + +type SyncIssueArgs = { + from: "linear" | "hack"; + issueIdentifier?: string; + ticketId?: string; + profileId?: string; + projectId?: string; + teamId?: string; + syncLabels?: boolean; + json: boolean; +}; + +type SyncProjectArgs = { + from: "linear" | "hack"; + profileId?: string; + projectId?: string; + teamId?: string; + ownerMode: "hack" | "linear" | "both"; + limit?: number; + syncLabels?: boolean; + json: boolean; +}; + +function parseSetupArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: SetupArgs } + | { readonly ok: false; readonly error: string } { + const value: SetupArgs = { json: false }; + + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "project-id": "projectId", + "project-name": "projectName", + "team-id": "teamId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + + return { ok: true, value }; +} + +function parseConnectArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ConnectArgs } + | { readonly ok: false; readonly error: string } { + const value: ConnectArgs = { + stdin: false, + setDefault: false, + }; + + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--stdin") { + value.stdin = true; + continue; + } + if (token === "--set-default") { + value.setDefault = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + token: "token", + "token-env": "tokenEnv", + "auth-ref": "authRef", + service: "service", + "api-url": "apiUrl", + "refresh-token": "refreshToken", + "token-expires-at": "tokenExpiresAt", + "refresh-token-expires-at": "refreshTokenExpiresAt", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + + return { ok: true, value }; +} + +function parseOAuthConnectArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: OAuthConnectArgs } + | { readonly ok: false; readonly error: string } { + const value: OAuthConnectArgs = { + setDefault: false, + clientSecretStdin: false, + noOpen: false, + json: false, + }; + + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--set-default") { + value.setDefault = true; + continue; + } + if (token === "--client-secret-stdin") { + value.clientSecretStdin = true; + continue; + } + if (token === "--no-open") { + value.noOpen = true; + continue; + } + if (token === "--json") { + value.json = true; + continue; + } + + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "client-id": "clientId", + "client-secret": "clientSecret", + "client-secret-auth-ref": "clientSecretAuthRef", + "client-secret-service": "clientSecretService", + scopes: "scopes", + "authorize-url": "authorizeUrl", + "token-url": "tokenUrl", + "api-url": "apiUrl", + "token-env": "tokenEnv", + "auth-ref": "authRef", + service: "service", + }, + }); + if (handled.ok) { + i = handled.nextIndex; + continue; + } + return { ok: false, error: handled.error }; + } + + return { ok: true, value }; +} + +function parseDisconnectArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: DisconnectArgs } + | { readonly ok: false; readonly error: string } { + const value: DisconnectArgs = {}; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "auth-ref": "authRef", + service: "service", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + +function parseStatusArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: StatusArgs } + | { readonly ok: false; readonly error: string } { + const value: StatusArgs = { json: false }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + +function parseProfilesArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ProfilesArgs } + | { readonly ok: false; readonly error: string } { + const value: ProfilesArgs = { json: false }; + for (const token of input.args) { + if (token === "--json") { + value.json = true; + continue; + } + return { ok: false, error: `Unknown option: ${token}` }; + } + return { ok: true, value }; +} + +function parseUseArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: UseArgs } + | { readonly ok: false; readonly error: string } { + let profileId: string | undefined; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: { profileId }, + keys: { + profile: "profileId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + profileId = (handled.out as { profileId?: string }).profileId; + i = handled.nextIndex; + } + + if (!profileId) { + return { ok: false, error: "Usage: hack x linear use --profile " }; + } + return { + ok: true, + value: { profileId }, + }; +} + +function parseProjectsArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ProjectsArgs } + | { readonly ok: false; readonly error: string } { + const value: ProjectsArgs = { json: false }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + if (token.startsWith("--limit=")) { + value.limit = Number.parseInt(token.slice("--limit=".length), 10); + continue; + } + if (token === "--limit") { + const next = input.args[i + 1]; + if (!next || next.startsWith("-")) { + return { ok: false, error: "--limit requires a value." }; + } + value.limit = Number.parseInt(next, 10); + i += 1; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + +function parseProjectBindArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ProjectBindArgs } + | { readonly ok: false; readonly error: string } { + const value: ProjectBindArgs = { + clear: false, + json: false, + }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--clear") { + value.clear = true; + continue; + } + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "project-id": "projectId", + "project-name": "projectName", + "team-id": "teamId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + +function parseSyncIssueArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: SyncIssueArgs } + | { readonly ok: false; readonly error: string } { + const value: SyncIssueArgs = { + from: "linear", + json: false, + }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + if (token === "--sync-labels") { + value.syncLabels = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + from: "from", + issue: "issueIdentifier", + ticket: "ticketId", + profile: "profileId", + "project-id": "projectId", + "team-id": "teamId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + + if (!(value.from === "linear" || value.from === "hack")) { + return { + ok: false, + error: `Invalid --from value: ${String(value.from)}. Expected linear|hack.`, + }; + } + return { ok: true, value }; +} + +function parseSyncProjectArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: SyncProjectArgs } + | { readonly ok: false; readonly error: string } { + const value: SyncProjectArgs = { + from: "linear", + ownerMode: "hack", + json: false, + }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + if (token === "--sync-labels") { + value.syncLabels = true; + continue; + } + if (token.startsWith("--limit=")) { + value.limit = Number.parseInt(token.slice("--limit=".length), 10); + continue; + } + if (token === "--limit") { + const next = input.args[i + 1]; + if (!next || next.startsWith("-")) { + return { ok: false, error: "--limit requires a value." }; + } + value.limit = Number.parseInt(next, 10); + i += 1; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + from: "from", + profile: "profileId", + "project-id": "projectId", + "team-id": "teamId", + owner: "ownerMode", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + if (!(value.from === "linear" || value.from === "hack")) { + return { + ok: false, + error: `Invalid --from value: ${String(value.from)}. Expected linear|hack.`, + }; + } + if ( + !( + value.ownerMode === "hack" || + value.ownerMode === "linear" || + value.ownerMode === "both" + ) + ) { + return { + ok: false, + error: `Invalid --owner value: ${String(value.ownerMode)}. Expected hack|linear|both.`, + }; + } + return { ok: true, value }; +} + +function assignKeyValueFlag>(input: { + readonly token: string; + readonly args: readonly string[]; + readonly index: number; + readonly out: T; + readonly keys: Record; +}): + | { readonly ok: true; readonly nextIndex: number; readonly out: T } + | { readonly ok: false; readonly error: string } { + if (!input.token.startsWith("--")) { + return { ok: false, error: `Unknown argument: ${input.token}` }; + } + const equalsIndex = input.token.indexOf("="); + if (equalsIndex !== -1) { + const rawKey = input.token.slice(2, equalsIndex); + const key = input.keys[rawKey]; + if (!key) { + return { ok: false, error: `Unknown option: --${rawKey}` }; + } + input.out[key] = input.token.slice(equalsIndex + 1) as T[keyof T]; + return { ok: true, nextIndex: input.index, out: input.out }; + } + + const rawKey = input.token.slice(2); + const key = input.keys[rawKey]; + if (!key) { + return { ok: false, error: `Unknown option: ${input.token}` }; + } + const next = input.args[input.index + 1]; + if (!next || next.startsWith("-")) { + return { ok: false, error: `${input.token} requires a value.` }; + } + input.out[key] = next as T[keyof T]; + return { ok: true, nextIndex: input.index + 1, out: input.out }; +} + +function trimTrailingSlash(input: { readonly value: string }): string { + return input.value.replace(TRAILING_SLASH_REGEX, ""); +} + +function normalizeOAuthActor(value: string | undefined): "user" | "app" | null { + const normalized = readOptionalString(value)?.toLowerCase(); + if (normalized === "user" || normalized === "app") { + return normalized; + } + return null; +} + +export const __testOnly = { + buildOAuthArgsFromConnectArgs, + parseConnectArgs, + parseProjectBindArgs, + parseProjectsArgs, + resolveOAuthBrokerRuntimeConfig, + parseSetupArgs, + parseStatusArgs, + parseSyncIssueArgs, + parseSyncProjectArgs, + shouldFallbackConnectToOAuth, + shouldUseBrokerOAuthFlow, +} as const; diff --git a/src/control-plane/extensions/linear/extension.ts b/src/control-plane/extensions/linear/extension.ts new file mode 100644 index 000000000..b52fc48a0 --- /dev/null +++ b/src/control-plane/extensions/linear/extension.ts @@ -0,0 +1,13 @@ +import type { ExtensionDefinition } from "../types.ts"; +import { LINEAR_COMMANDS } from "./commands.ts"; + +export const LINEAR_EXTENSION: ExtensionDefinition = { + manifest: { + id: "dance.hack.linear", + version: "0.1.0", + scopes: ["global", "project"], + cliNamespace: "linear", + summary: "Linear OAuth and issue sync for hack tickets", + }, + commands: LINEAR_COMMANDS, +}; diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts index f8b65d942..7ebfd04d3 100644 --- a/src/control-plane/extensions/tickets/commands.ts +++ b/src/control-plane/extensions/tickets/commands.ts @@ -262,6 +262,30 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ ? { dependsOn: dependsOnResult.refs } : {}), ...(blocksResult.refs.length > 0 ? { blocks: blocksResult.refs } : {}), + ...(parsed.value.owner ? { owner: parsed.value.owner } : {}), + ...(parsed.value.source ? { source: parsed.value.source } : {}), + ...(parsed.value.tags.length > 0 ? { tags: parsed.value.tags } : {}), + ...(parsed.value.externalSystem + ? { externalSystem: parsed.value.externalSystem } + : {}), + ...(parsed.value.externalId + ? { externalId: parsed.value.externalId } + : {}), + ...(parsed.value.externalKey + ? { externalKey: parsed.value.externalKey } + : {}), + ...(parsed.value.externalUrl + ? { externalUrl: parsed.value.externalUrl } + : {}), + ...(parsed.value.externalProjectId + ? { externalProjectId: parsed.value.externalProjectId } + : {}), + ...(parsed.value.externalProjectName + ? { externalProjectName: parsed.value.externalProjectName } + : {}), + ...(parsed.value.externalTeamId + ? { externalTeamId: parsed.value.externalTeamId } + : {}), actor: parsed.value.actor, }); @@ -283,6 +307,8 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ ["ticket_id", created.ticket.ticketId], ["title", created.ticket.title], ["status", created.ticket.status], + ["owner", created.ticket.owner], + ["source", created.ticket.source], ["created_at", created.ticket.createdAt], ["updated_at", created.ticket.updatedAt], ], @@ -334,6 +360,12 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ }); return 1; } + if (parsed.value.clearTags && parsed.value.tags.length > 0) { + ctx.logger.error({ + message: "--clear-tags cannot be combined with --tags/--tag.", + }); + return 1; + } const bodyRequested = parsed.value.body !== undefined || @@ -393,11 +425,30 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ blocks = undefined; } + let tags: string[] | undefined; + if (parsed.value.clearTags) { + tags = []; + } else if (parsed.value.tags.length > 0) { + tags = [...parsed.value.tags]; + } else { + tags = undefined; + } + const hasUpdates = title !== undefined || bodyRequested || dependsOn !== undefined || - blocks !== undefined; + blocks !== undefined || + tags !== undefined || + parsed.value.owner !== undefined || + parsed.value.source !== undefined || + parsed.value.externalSystem !== undefined || + parsed.value.externalId !== undefined || + parsed.value.externalKey !== undefined || + parsed.value.externalUrl !== undefined || + parsed.value.externalProjectId !== undefined || + parsed.value.externalProjectName !== undefined || + parsed.value.externalTeamId !== undefined; if (!hasUpdates) { ctx.logger.error({ message: "No updates provided." }); @@ -420,6 +471,34 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ ...(bodyRequested ? { body } : {}), ...(dependsOn !== undefined ? { dependsOn } : {}), ...(blocks !== undefined ? { blocks } : {}), + ...(tags !== undefined ? { tags } : {}), + ...(parsed.value.owner !== undefined + ? { owner: parsed.value.owner } + : {}), + ...(parsed.value.source !== undefined + ? { source: parsed.value.source } + : {}), + ...(parsed.value.externalSystem !== undefined + ? { externalSystem: parsed.value.externalSystem } + : {}), + ...(parsed.value.externalId !== undefined + ? { externalId: parsed.value.externalId } + : {}), + ...(parsed.value.externalKey !== undefined + ? { externalKey: parsed.value.externalKey } + : {}), + ...(parsed.value.externalUrl !== undefined + ? { externalUrl: parsed.value.externalUrl } + : {}), + ...(parsed.value.externalProjectId !== undefined + ? { externalProjectId: parsed.value.externalProjectId } + : {}), + ...(parsed.value.externalProjectName !== undefined + ? { externalProjectName: parsed.value.externalProjectName } + : {}), + ...(parsed.value.externalTeamId !== undefined + ? { externalTeamId: parsed.value.externalTeamId } + : {}), actor: parsed.value.actor, }); @@ -470,7 +549,28 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ logger: ctx.logger, }); - const tickets = await store.listTickets(); + const tickets = (await store.listTickets()).filter((ticket) => { + if ( + parsed.value.owner && + ticket.owner.toLowerCase() !== parsed.value.owner.toLowerCase() + ) { + return false; + } + if ( + parsed.value.source && + ticket.source.toLowerCase() !== parsed.value.source.toLowerCase() + ) { + return false; + } + if ( + parsed.value.externalSystem && + (ticket.externalSystem ?? "").toLowerCase() !== + parsed.value.externalSystem.toLowerCase() + ) { + return false; + } + return true; + }); if (parsed.value.json) { process.stdout.write(`${JSON.stringify({ tickets }, null, 2)}\n`); @@ -487,11 +587,13 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ } await display.table({ - columns: ["Id", "Title", "Status", "Updated"], + columns: ["Id", "Title", "Status", "Owner", "Source", "Updated"], rows: tickets.map((ticket) => [ ticket.ticketId, ticket.title, ticket.status, + ticket.owner, + ticket.source, ticket.updatedAt, ]), }); @@ -576,6 +678,16 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ entries: [ ["title", ticket.title], ["status", ticket.status], + ["owner", ticket.owner], + ["source", ticket.source], + ["tags", ticket.tags.join(", ")], + ["external_system", ticket.externalSystem ?? ""], + ["external_id", ticket.externalId ?? ""], + ["external_key", ticket.externalKey ?? ""], + ["external_url", ticket.externalUrl ?? ""], + ["external_project_id", ticket.externalProjectId ?? ""], + ["external_project_name", ticket.externalProjectName ?? ""], + ["external_team_id", ticket.externalTeamId ?? ""], ["depends_on", ticket.dependsOn.join(", ")], ["blocks", ticket.blocks.join(", ")], ["created_at", ticket.createdAt], @@ -734,6 +846,17 @@ type TicketsArgs = { readonly blocks: readonly string[]; readonly clearDependsOn: boolean; readonly clearBlocks: boolean; + readonly owner?: string; + readonly source?: string; + readonly tags: readonly string[]; + readonly clearTags: boolean; + readonly externalSystem?: string; + readonly externalId?: string; + readonly externalKey?: string; + readonly externalUrl?: string; + readonly externalProjectId?: string; + readonly externalProjectName?: string; + readonly externalTeamId?: string; readonly actor?: string; readonly json: boolean; readonly rest: readonly string[]; @@ -769,6 +892,17 @@ function parseTicketsArgs(opts: { const blocks: string[] = []; let clearDependsOn = false; let clearBlocks = false; + let owner: string | undefined; + let source: string | undefined; + const tags: string[] = []; + let clearTags = false; + let externalSystem: string | undefined; + let externalId: string | undefined; + let externalKey: string | undefined; + let externalUrl: string | undefined; + let externalProjectId: string | undefined; + let externalProjectName: string | undefined; + let externalTeamId: string | undefined; let actor: string | undefined; let json = false; @@ -854,6 +988,10 @@ function parseTicketsArgs(opts: { clearBlocks = true; continue; } + if (token === "--clear-tags") { + clearTags = true; + continue; + } if (token.startsWith("--depends-on=")) { dependsOn.push(...splitTicketRefs(token.slice("--depends-on=".length))); @@ -890,6 +1028,153 @@ function parseTicketsArgs(opts: { continue; } + if (token.startsWith("--owner=")) { + owner = token.slice("--owner=".length); + continue; + } + if (token === "--owner") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--owner requires a value." }; + } + owner = value; + i += 1; + continue; + } + if (token.startsWith("--source=")) { + source = token.slice("--source=".length); + continue; + } + if (token === "--source") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--source requires a value." }; + } + source = value; + i += 1; + continue; + } + if (token.startsWith("--tags=")) { + tags.push(...splitTags(token.slice("--tags=".length))); + continue; + } + if (token === "--tags") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--tags requires a value." }; + } + tags.push(...splitTags(value)); + i += 1; + continue; + } + if (token.startsWith("--tag=")) { + tags.push(token.slice("--tag=".length)); + continue; + } + if (token === "--tag") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--tag requires a value." }; + } + tags.push(value); + i += 1; + continue; + } + if (token.startsWith("--external-system=")) { + externalSystem = token.slice("--external-system=".length); + continue; + } + if (token === "--external-system") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--external-system requires a value." }; + } + externalSystem = value; + i += 1; + continue; + } + if (token.startsWith("--external-id=")) { + externalId = token.slice("--external-id=".length); + continue; + } + if (token === "--external-id") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--external-id requires a value." }; + } + externalId = value; + i += 1; + continue; + } + if (token.startsWith("--external-key=")) { + externalKey = token.slice("--external-key=".length); + continue; + } + if (token === "--external-key") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--external-key requires a value." }; + } + externalKey = value; + i += 1; + continue; + } + if (token.startsWith("--external-url=")) { + externalUrl = token.slice("--external-url=".length); + continue; + } + if (token === "--external-url") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--external-url requires a value." }; + } + externalUrl = value; + i += 1; + continue; + } + if (token.startsWith("--external-project-id=")) { + externalProjectId = token.slice("--external-project-id=".length); + continue; + } + if (token === "--external-project-id") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--external-project-id requires a value." }; + } + externalProjectId = value; + i += 1; + continue; + } + if (token.startsWith("--external-project-name=")) { + externalProjectName = token.slice("--external-project-name=".length); + continue; + } + if (token === "--external-project-name") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { + ok: false, + error: "--external-project-name requires a value.", + }; + } + externalProjectName = value; + i += 1; + continue; + } + if (token.startsWith("--external-team-id=")) { + externalTeamId = token.slice("--external-team-id=".length); + continue; + } + if (token === "--external-team-id") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--external-team-id requires a value." }; + } + externalTeamId = value; + i += 1; + continue; + } + if (token === "--actor") { const value = takeValue(token, opts.args[i + 1]); if (!value) { @@ -918,6 +1203,17 @@ function parseTicketsArgs(opts: { blocks, clearDependsOn, clearBlocks, + ...(owner ? { owner } : {}), + ...(source ? { source } : {}), + tags: normalizeTags(tags), + clearTags, + ...(externalSystem !== undefined ? { externalSystem } : {}), + ...(externalId !== undefined ? { externalId } : {}), + ...(externalKey !== undefined ? { externalKey } : {}), + ...(externalUrl !== undefined ? { externalUrl } : {}), + ...(externalProjectId !== undefined ? { externalProjectId } : {}), + ...(externalProjectName !== undefined ? { externalProjectName } : {}), + ...(externalTeamId !== undefined ? { externalTeamId } : {}), ...(actor ? { actor } : {}), json, rest, @@ -965,6 +1261,28 @@ function splitTicketRefs(value: string): string[] { .filter((part) => part.length > 0); } +function splitTags(value: string): string[] { + return value + .split(TICKET_REF_SEPARATOR_PATTERN) + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +function normalizeTags(values: readonly string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + for (const value of values) { + const tag = value.trim(); + if (!(tag && !seen.has(tag))) { + continue; + } + seen.add(tag); + normalized.push(tag); + } + normalized.sort((left, right) => left.localeCompare(right)); + return normalized; +} + function resolveTicketRefs(opts: { readonly values: readonly string[]; readonly label: string; diff --git a/src/control-plane/extensions/tickets/store.ts b/src/control-plane/extensions/tickets/store.ts index afb008af6..0de63b10f 100644 --- a/src/control-plane/extensions/tickets/store.ts +++ b/src/control-plane/extensions/tickets/store.ts @@ -24,6 +24,16 @@ export type TicketSummary = { readonly updatedAt: string; readonly dependsOn: readonly string[]; readonly blocks: readonly string[]; + readonly owner: string; + readonly source: string; + readonly tags: readonly string[]; + readonly externalSystem?: string; + readonly externalId?: string; + readonly externalKey?: string; + readonly externalUrl?: string; + readonly externalProjectId?: string; + readonly externalProjectName?: string; + readonly externalTeamId?: string; readonly projectId?: string; readonly projectName?: string; }; @@ -69,6 +79,16 @@ export function createTicketsStore(opts: { readonly body?: string; readonly dependsOn?: readonly string[]; readonly blocks?: readonly string[]; + readonly owner?: string; + readonly source?: string; + readonly tags?: readonly string[]; + readonly externalSystem?: string; + readonly externalId?: string; + readonly externalKey?: string; + readonly externalUrl?: string; + readonly externalProjectId?: string; + readonly externalProjectName?: string; + readonly externalTeamId?: string; readonly actor?: string; }) => Promise; readonly updateTicket: (input: { @@ -77,6 +97,16 @@ export function createTicketsStore(opts: { readonly body?: string; readonly dependsOn?: readonly string[]; readonly blocks?: readonly string[]; + readonly owner?: string; + readonly source?: string; + readonly tags?: readonly string[]; + readonly externalSystem?: string; + readonly externalId?: string; + readonly externalKey?: string; + readonly externalUrl?: string; + readonly externalProjectId?: string; + readonly externalProjectName?: string; + readonly externalTeamId?: string; readonly actor?: string; }) => Promise< { readonly ok: true } | { readonly ok: false; readonly error: string } @@ -257,6 +287,36 @@ export function createTicketsStore(opts: { const ticketId = await computeNextTicketId(); const dependsOn = normalizeTicketRefs(input.dependsOn ?? []); const blocks = normalizeTicketRefs(input.blocks ?? []); + const owner = normalizeOwnerOrSource({ + value: input.owner, + fallback: "hack", + }); + const source = normalizeOwnerOrSource({ + value: input.source, + fallback: "hack", + }); + const tags = normalizeTags(input.tags ?? []); + const externalSystem = normalizeOptionalMetadataString({ + value: input.externalSystem, + }); + const externalId = normalizeOptionalMetadataString({ + value: input.externalId, + }); + const externalKey = normalizeOptionalMetadataString({ + value: input.externalKey, + }); + const externalUrl = normalizeOptionalMetadataString({ + value: input.externalUrl, + }); + const externalProjectId = normalizeOptionalMetadataString({ + value: input.externalProjectId, + }); + const externalProjectName = normalizeOptionalMetadataString({ + value: input.externalProjectName, + }); + const externalTeamId = normalizeOptionalMetadataString({ + value: input.externalTeamId, + }); const event = buildEvent({ ticketId, type: "ticket.created", @@ -265,6 +325,16 @@ export function createTicketsStore(opts: { ...(input.body ? { body: input.body } : {}), ...(dependsOn.length > 0 ? { dependsOn } : {}), ...(blocks.length > 0 ? { blocks } : {}), + owner, + source, + ...(tags.length > 0 ? { tags } : {}), + ...(externalSystem ? { externalSystem } : {}), + ...(externalId ? { externalId } : {}), + ...(externalKey ? { externalKey } : {}), + ...(externalUrl ? { externalUrl } : {}), + ...(externalProjectId ? { externalProjectId } : {}), + ...(externalProjectName ? { externalProjectName } : {}), + ...(externalTeamId ? { externalTeamId } : {}), status: "open", }, actor: input.actor, @@ -286,6 +356,16 @@ export function createTicketsStore(opts: { updatedAt: event.tsIso, dependsOn, blocks, + owner, + source, + tags, + ...(externalSystem ? { externalSystem } : {}), + ...(externalId ? { externalId } : {}), + ...(externalKey ? { externalKey } : {}), + ...(externalUrl ? { externalUrl } : {}), + ...(externalProjectId ? { externalProjectId } : {}), + ...(externalProjectName ? { externalProjectName } : {}), + ...(externalTeamId ? { externalTeamId } : {}), ...(opts.projectId ? { projectId: opts.projectId } : {}), ...(opts.projectName ? { projectName: opts.projectName } : {}), }, @@ -316,6 +396,56 @@ export function createTicketsStore(opts: { if (input.blocks !== undefined) { payload.blocks = normalizeTicketRefs(input.blocks); } + if (input.owner !== undefined) { + payload.owner = normalizeOwnerOrSource({ + value: input.owner, + fallback: "hack", + }); + } + if (input.source !== undefined) { + payload.source = normalizeOwnerOrSource({ + value: input.source, + fallback: "hack", + }); + } + if (input.tags !== undefined) { + payload.tags = normalizeTags(input.tags); + } + if (input.externalSystem !== undefined) { + payload.externalSystem = normalizeOptionalMetadataString({ + value: input.externalSystem, + }); + } + if (input.externalId !== undefined) { + payload.externalId = normalizeOptionalMetadataString({ + value: input.externalId, + }); + } + if (input.externalKey !== undefined) { + payload.externalKey = normalizeOptionalMetadataString({ + value: input.externalKey, + }); + } + if (input.externalUrl !== undefined) { + payload.externalUrl = normalizeOptionalMetadataString({ + value: input.externalUrl, + }); + } + if (input.externalProjectId !== undefined) { + payload.externalProjectId = normalizeOptionalMetadataString({ + value: input.externalProjectId, + }); + } + if (input.externalProjectName !== undefined) { + payload.externalProjectName = normalizeOptionalMetadataString({ + value: input.externalProjectName, + }); + } + if (input.externalTeamId !== undefined) { + payload.externalTeamId = normalizeOptionalMetadataString({ + value: input.externalTeamId, + }); + } if (Object.keys(payload).length === 0) { return { ok: false, error: "No updates provided." }; @@ -416,6 +546,36 @@ function applyTicketCreatedEvent(opts: { const blocks = parseDependencyList({ value: opts.event.payload.blocks, }); + const owner = normalizeOwnerOrSource({ + value: readOptionalStringPayload({ value: opts.event.payload.owner }), + fallback: "hack", + }); + const source = normalizeOwnerOrSource({ + value: readOptionalStringPayload({ value: opts.event.payload.source }), + fallback: "hack", + }); + const tags = parseTags({ value: opts.event.payload.tags }); + const externalSystem = readOptionalStringPayload({ + value: opts.event.payload.externalSystem, + }); + const externalId = readOptionalStringPayload({ + value: opts.event.payload.externalId, + }); + const externalKey = readOptionalStringPayload({ + value: opts.event.payload.externalKey, + }); + const externalUrl = readOptionalStringPayload({ + value: opts.event.payload.externalUrl, + }); + const externalProjectId = readOptionalStringPayload({ + value: opts.event.payload.externalProjectId, + }); + const externalProjectName = readOptionalStringPayload({ + value: opts.event.payload.externalProjectName, + }); + const externalTeamId = readOptionalStringPayload({ + value: opts.event.payload.externalTeamId, + }); opts.tickets.set(opts.event.ticketId, { ticketId: opts.event.ticketId, @@ -426,6 +586,16 @@ function applyTicketCreatedEvent(opts: { updatedAt: opts.event.tsIso, dependsOn, blocks, + owner, + source, + tags, + ...(externalSystem ? { externalSystem } : {}), + ...(externalId ? { externalId } : {}), + ...(externalKey ? { externalKey } : {}), + ...(externalUrl ? { externalUrl } : {}), + ...(externalProjectId ? { externalProjectId } : {}), + ...(externalProjectName ? { externalProjectName } : {}), + ...(externalTeamId ? { externalTeamId } : {}), ...(opts.event.projectId ? { projectId: opts.event.projectId } : {}), ...(opts.event.projectName ? { projectName: opts.event.projectName } : {}), }); @@ -477,6 +647,46 @@ function applyTicketUpdatedEvent(opts: { payload: opts.event.payload, key: "blocks", }); + const owner = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "owner", + }); + const source = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "source", + }); + const tags = readTagsUpdate({ + payload: opts.event.payload, + key: "tags", + }); + const externalSystem = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "externalSystem", + }); + const externalId = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "externalId", + }); + const externalKey = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "externalKey", + }); + const externalUrl = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "externalUrl", + }); + const externalProjectId = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "externalProjectId", + }); + const externalProjectName = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "externalProjectName", + }); + const externalTeamId = readOptionalStringUpdate({ + payload: opts.event.payload, + key: "externalTeamId", + }); opts.tickets.set(opts.event.ticketId, { ...current, @@ -484,6 +694,38 @@ function applyTicketUpdatedEvent(opts: { ...(body !== undefined ? { body } : {}), ...(dependsOn !== null ? { dependsOn } : {}), ...(blocks !== null ? { blocks } : {}), + ...(owner !== null + ? { + owner: normalizeOwnerOrSource({ + value: owner, + fallback: current.owner, + }), + } + : {}), + ...(source !== null + ? { + source: normalizeOwnerOrSource({ + value: source, + fallback: current.source, + }), + } + : {}), + ...(tags !== null ? { tags } : {}), + ...(externalSystem !== null + ? { externalSystem: externalSystem || undefined } + : {}), + ...(externalId !== null ? { externalId: externalId || undefined } : {}), + ...(externalKey !== null ? { externalKey: externalKey || undefined } : {}), + ...(externalUrl !== null ? { externalUrl: externalUrl || undefined } : {}), + ...(externalProjectId !== null + ? { externalProjectId: externalProjectId || undefined } + : {}), + ...(externalProjectName !== null + ? { externalProjectName: externalProjectName || undefined } + : {}), + ...(externalTeamId !== null + ? { externalTeamId: externalTeamId || undefined } + : {}), updatedAt: opts.event.tsIso, }); } @@ -512,6 +754,16 @@ function parseDependencyList(opts: { readonly value: unknown }): string[] { return normalizeTicketRefs(values); } +function parseTags(opts: { readonly value: unknown }): string[] { + if (!Array.isArray(opts.value)) { + return []; + } + const tags = opts.value.filter( + (item): item is string => typeof item === "string" + ); + return normalizeTags(tags); +} + function readDependencyUpdate(opts: { readonly payload: Record; readonly key: "dependsOn" | "blocks"; @@ -522,6 +774,35 @@ function readDependencyUpdate(opts: { return parseDependencyList({ value: opts.payload[opts.key] }); } +function readTagsUpdate(opts: { + readonly payload: Record; + readonly key: "tags"; +}): string[] | null { + if (!Object.hasOwn(opts.payload, opts.key)) { + return null; + } + return parseTags({ value: opts.payload[opts.key] }); +} + +function readOptionalStringUpdate(opts: { + readonly payload: Record; + readonly key: + | "owner" + | "source" + | "externalSystem" + | "externalId" + | "externalKey" + | "externalUrl" + | "externalProjectId" + | "externalProjectName" + | "externalTeamId"; +}): string | null { + if (!Object.hasOwn(opts.payload, opts.key)) { + return null; + } + return readOptionalStringPayload({ value: opts.payload[opts.key] }) ?? ""; +} + function applyDerivedBlocks( tickets: Map ): Map { @@ -549,6 +830,46 @@ function applyDerivedBlocks( return tickets; } +function readOptionalStringPayload(opts: { + readonly value: unknown; +}): string | undefined { + if (typeof opts.value !== "string") { + return undefined; + } + const trimmed = opts.value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeOwnerOrSource(opts: { + readonly value: string | undefined; + readonly fallback: string; +}): string { + const trimmed = (opts.value ?? "").trim(); + return trimmed || opts.fallback; +} + +function normalizeOptionalMetadataString(opts: { + readonly value: string | undefined; +}): string | undefined { + const trimmed = (opts.value ?? "").trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeTags(tags: readonly string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + for (const tag of tags) { + const trimmed = tag.trim(); + if (!(trimmed && !seen.has(trimmed))) { + continue; + } + seen.add(trimmed); + normalized.push(trimmed); + } + normalized.sort((left, right) => left.localeCompare(right)); + return normalized; +} + function safeJsonParse(text: string): unknown { try { return JSON.parse(text); diff --git a/src/control-plane/sdk/config.ts b/src/control-plane/sdk/config.ts index e1c7c52d9..834611e8a 100644 --- a/src/control-plane/sdk/config.ts +++ b/src/control-plane/sdk/config.ts @@ -229,10 +229,12 @@ const CloudSecretProviderSchema = z.enum(["aws", "gcp", "azure", "vault"]); const SecretsEncryptedFileConfigInputSchema = z.object({ path: z.string().optional(), + keyPath: z.string().optional(), }); const SecretsEncryptedFileConfigSchema = z.object({ path: z.string().default("~/.hack/secrets.enc.json"), + keyPath: z.string().default("~/.hack/secrets-file.key"), }); const SecretsCloudConfigInputSchema = z.object({ diff --git a/src/lib/config.ts b/src/lib/config.ts index 249f7b193..189075b48 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,5 @@ -import { dirname } from "node:path"; +import { dirname, resolve } from "node:path"; +import { PROJECT_CONFIG_FILENAME } from "../constants.ts"; import { resolveGlobalConfigPath } from "./config-paths.ts"; import { ensureDir, readTextFile, writeTextFileIfChanged } from "./fs.ts"; import { isRecord } from "./guards.ts"; @@ -18,6 +19,47 @@ export async function updateGlobalConfig({ readonly value: unknown; }): Promise<{ readonly changed: boolean }> { const configPath = resolveGlobalConfigPath(); + return await updateConfigFileAtPath({ + configPath, + path, + value, + }); +} + +/** + * Updates a value in the project config file at /.hack/hack.config.json. + * + * @param opts.projectDir - Project `.hack` directory path + * @param opts.path - Dot-separated path to the config key + * @param opts.value - The value to set + * @returns Whether the config was changed + */ +export async function updateProjectConfig({ + projectDir, + path, + value, +}: { + readonly projectDir: string; + readonly path: string; + readonly value: unknown; +}): Promise<{ readonly changed: boolean }> { + const configPath = resolve(projectDir, PROJECT_CONFIG_FILENAME); + return await updateConfigFileAtPath({ + configPath, + path, + value, + }); +} + +async function updateConfigFileAtPath({ + configPath, + path, + value, +}: { + readonly configPath: string; + readonly path: string; + readonly value: unknown; +}): Promise<{ readonly changed: boolean }> { const parsedPath = parseKeyPath({ raw: path }); if (parsedPath.length === 0) { diff --git a/src/lib/nodes-registry.ts b/src/lib/nodes-registry.ts index 7f5ff1a7e..4115474f6 100644 --- a/src/lib/nodes-registry.ts +++ b/src/lib/nodes-registry.ts @@ -472,9 +472,12 @@ async function resolveAllowedEnvAuthRef( } async function resolveNodeAuthSecretStore() { - const securityStore = resolveMacOsNodeAuthSecretStore(); - if (securityStore) { - return securityStore; + const controlPlane = await readControlPlaneConfig({}); + if (controlPlane.config.secrets.backend === "keychain") { + const securityStore = resolveMacOsNodeAuthSecretStore(); + if (securityStore) { + return securityStore; + } } return await resolveSecretStore({ projectName: NODE_SECRET_STORE_PROJECT_NAME, diff --git a/src/lib/secret-store.ts b/src/lib/secret-store.ts index fddb6648e..f9d8fd821 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes, } from "node:crypto"; +import { chmod, stat, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { secrets } from "bun"; import type { @@ -15,12 +16,16 @@ import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { ensureDir, readTextFile } from "./fs.ts"; const ENCRYPTED_FILE_KEY_ENV = "HACK_SECRETS_FILE_KEY"; +const DEFAULT_ENCRYPTED_FILE_KEY_PATH = "~/.hack/secrets-file.key"; const ENCRYPTED_FILE_KEY_SERVICE = "hack-secrets-backend"; const ENCRYPTED_FILE_KEY_NAME = "encrypted-file-key"; +const ENCRYPTED_FILE_KEY_FAILURE_COOLDOWN_MS = 60_000; const ENCRYPTED_STORE_VERSION = 1 as const; const PLAINTEXT_STORE_VERSION = 1 as const; const ENCRYPTED_ALGORITHM = "aes-256-gcm"; const ENCRYPTED_IV_BYTES = 12; +let cachedEncryptedFileKey: string | null = null; +let encryptedFileKeyFailureCooldownUntilMs = 0; type EncryptedStorePayload = { readonly version: typeof ENCRYPTED_STORE_VERSION; @@ -129,10 +134,20 @@ async function createEncryptedFileSecretStore(input: { readonly projectName: string; readonly secretsConfig: SecretsConfig; }): Promise { - const encryptionKey = await resolveEncryptedFileKeyMaterial(); const filePath = resolveConfiguredPath({ path: input.secretsConfig.encryptedFile.path, }); + const keyPath = resolveConfiguredPath({ + path: + input.secretsConfig.encryptedFile.keyPath ?? + DEFAULT_ENCRYPTED_FILE_KEY_PATH, + }); + const encryptionKey = ( + await resolveEncryptedFileKeyMaterial({ + keyPath, + storePath: filePath, + }) + ).key; return createEncryptedScopedStore({ backend: "encrypted_file", scope: input.projectName, @@ -215,10 +230,20 @@ async function createCloudShimAdapter(input: { > { const project = input.secretsConfig.cloud.project?.trim() || "default"; const prefix = input.secretsConfig.cloud.secretPrefix.trim() || "hack"; - const encryptionKey = await resolveEncryptedFileKeyMaterial(); const filePath = resolveConfiguredPath({ path: input.secretsConfig.encryptedFile.path, }); + const keyPath = resolveConfiguredPath({ + path: + input.secretsConfig.encryptedFile.keyPath ?? + DEFAULT_ENCRYPTED_FILE_KEY_PATH, + }); + const encryptionKey = ( + await resolveEncryptedFileKeyMaterial({ + keyPath, + storePath: filePath, + }) + ).key; const scope = [ "cloud", input.provider, @@ -332,19 +357,143 @@ function resolveConfiguredPath(input: { readonly path: string }): string { return resolve(home, raw); } -async function resolveEncryptedFileKeyMaterial(): Promise { +export async function provisionEncryptedFileKey(input: { + readonly keyPath?: string; + readonly storePath?: string; +}): Promise<{ + readonly keyPath: string; + readonly source: "env" | "file" | "keychain" | "generated"; +}> { + const keyPath = resolveConfiguredPath({ + path: input.keyPath ?? DEFAULT_ENCRYPTED_FILE_KEY_PATH, + }); + const storePath = input.storePath + ? resolveConfiguredPath({ + path: input.storePath, + }) + : undefined; + const resolved = await resolveEncryptedFileKeyMaterial({ + keyPath, + storePath, + persistResolvedKey: true, + }); + return { + keyPath, + source: resolved.source, + }; +} + +async function resolveEncryptedFileKeyMaterial(input?: { + readonly keyPath?: string; + readonly storePath?: string; + readonly persistResolvedKey?: boolean; +}): Promise<{ + readonly key: string; + readonly source: "env" | "file" | "keychain" | "generated"; +}> { const envValue = (process.env[ENCRYPTED_FILE_KEY_ENV] ?? "").trim(); if (envValue.length > 0) { - return envValue; + cachedEncryptedFileKey = envValue; + encryptedFileKeyFailureCooldownUntilMs = 0; + if (input?.persistResolvedKey && input.keyPath) { + await writeEncryptedFileKey({ + keyPath: input.keyPath, + key: envValue, + }); + } + return { + key: envValue, + source: "env", + }; + } + + const configuredKeyPath = input?.keyPath?.trim(); + if (configuredKeyPath) { + const fileKey = await readEncryptedFileKey({ + keyPath: configuredKeyPath, + }); + if (fileKey) { + cachedEncryptedFileKey = fileKey; + encryptedFileKeyFailureCooldownUntilMs = 0; + return { + key: fileKey, + source: "file", + }; + } + } + if (cachedEncryptedFileKey) { + if (input?.persistResolvedKey && input.keyPath) { + await writeEncryptedFileKey({ + keyPath: input.keyPath, + key: cachedEncryptedFileKey, + }); + } + return { + key: cachedEncryptedFileKey, + source: "keychain", + }; + } + + const shouldBootstrapFileKey = + Boolean(configuredKeyPath) && + (await canBootstrapEncryptedFileKey({ + storePath: input?.storePath, + })); + if (configuredKeyPath && shouldBootstrapFileKey) { + const generated = randomBytes(32).toString("base64url"); + await writeEncryptedFileKey({ + keyPath: configuredKeyPath, + key: generated, + }); + cachedEncryptedFileKey = generated; + encryptedFileKeyFailureCooldownUntilMs = 0; + return { + key: generated, + source: "generated", + }; + } + + const nowMs = Date.now(); + if (nowMs < encryptedFileKeyFailureCooldownUntilMs) { + throw new Error( + `Encrypted backend key access is cooling down after a failed keychain lookup. Set ${ENCRYPTED_FILE_KEY_ENV} to bypass keychain prompts.` + ); + } + + let existing: string | undefined; + try { + existing = ( + await secrets.get({ + service: ENCRYPTED_FILE_KEY_SERVICE, + name: ENCRYPTED_FILE_KEY_NAME, + }) + )?.trim(); + } catch { + encryptedFileKeyFailureCooldownUntilMs = + Date.now() + ENCRYPTED_FILE_KEY_FAILURE_COOLDOWN_MS; + throw new Error( + `Failed to access encrypted backend key in keychain service "${ENCRYPTED_FILE_KEY_SERVICE}". Set ${ENCRYPTED_FILE_KEY_ENV} to bypass keychain prompts.` + ); } - const existing = ( - await secrets.get({ - service: ENCRYPTED_FILE_KEY_SERVICE, - name: ENCRYPTED_FILE_KEY_NAME, - }) - )?.trim(); if (existing) { - return existing; + if (configuredKeyPath) { + await writeEncryptedFileKey({ + keyPath: configuredKeyPath, + key: existing, + }); + } + cachedEncryptedFileKey = existing; + encryptedFileKeyFailureCooldownUntilMs = 0; + return { + key: existing, + source: "keychain", + }; + } + + if (configuredKeyPath) { + throw new Error( + `Missing encrypted backend key. Provision ${configuredKeyPath} or set ${ENCRYPTED_FILE_KEY_ENV}.` + ); } const generated = randomBytes(32).toString("base64url"); @@ -355,11 +504,52 @@ async function resolveEncryptedFileKeyMaterial(): Promise { value: generated, }); } catch { + encryptedFileKeyFailureCooldownUntilMs = + Date.now() + ENCRYPTED_FILE_KEY_FAILURE_COOLDOWN_MS; throw new Error( - `Missing encrypted backend key. Set ${ENCRYPTED_FILE_KEY_ENV} or configure keychain access.` + `Missing encrypted backend key. Set ${ENCRYPTED_FILE_KEY_ENV} or configure keychain access for service "${ENCRYPTED_FILE_KEY_SERVICE}".` ); } - return generated; + cachedEncryptedFileKey = generated; + encryptedFileKeyFailureCooldownUntilMs = 0; + return { + key: generated, + source: "generated", + }; +} + +async function canBootstrapEncryptedFileKey(input: { + readonly storePath?: string; +}): Promise { + if (!input.storePath) { + return true; + } + try { + const info = await stat(input.storePath); + return info.size === 0; + } catch { + return true; + } +} + +async function readEncryptedFileKey(input: { + readonly keyPath: string; +}): Promise { + const raw = await readTextFile(input.keyPath); + const key = raw?.trim() ?? ""; + return key.length > 0 ? key : null; +} + +async function writeEncryptedFileKey(input: { + readonly keyPath: string; + readonly key: string; +}): Promise { + await ensureDir(dirname(input.keyPath)); + await writeFile(input.keyPath, `${input.key}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(input.keyPath, 0o600).catch(() => undefined); } async function readEncryptedEntries(input: { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index aea89529f..2ebafd1f0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -597,6 +597,339 @@ function registerTools(opts: { readonly server: McpServer }): void { return buildToolResult({ result }); } ); + + const linearCommonInput = { + profile: z.string().describe("Linear auth profile id").optional(), + }; + + const linearIssueDirectionInput = z + .enum(["linear", "hack"]) + .describe("Sync direction"); + + const linearOwnerModeInput = z + .enum(["hack", "linear", "both"]) + .describe("Ticket owner filter when syncing hack -> Linear") + .optional(); + + opts.server.registerTool( + "hack.linear.setup", + { + title: "Setup Linear extension", + description: + "Enable Linear extension for a project and optionally set profile/project defaults.", + inputSchema: { + ...projectSelectorInput, + ...linearCommonInput, + projectId: z.string().describe("Default Linear project id").optional(), + projectName: z + .string() + .describe("Default Linear project name") + .optional(), + teamId: z.string().describe("Default Linear team id").optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const projectCwd = await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: true, + }); + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const args = [ + "linear", + "setup", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.projectId ? ["--project-id", input.projectId] : []), + ...(input.projectName ? ["--project-name", input.projectName] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.setup", + args, + cwd: projectCwd.cwd, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.status", + { + title: "Linear profile status", + description: + "Show selected Linear profile resolution and token status (supports project override context).", + inputSchema: { + ...projectSelectorInput, + ...linearCommonInput, + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const hasProjectSelection = + !!input.projectName || !!input.repoRoot || !!input.path; + + const projectCwd = hasProjectSelection + ? await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: false, + }) + : { ok: true as const, cwd: undefined }; + + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const args = [ + "linear", + "status", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.status", + args, + ...(projectCwd.cwd ? { cwd: projectCwd.cwd } : {}), + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.projects.list", + { + title: "List Linear projects", + description: "List projects visible to the selected Linear profile.", + inputSchema: { + ...projectSelectorInput, + ...linearCommonInput, + limit: z.number().describe("Max projects to return").optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const hasProjectSelection = + !!input.projectName || !!input.repoRoot || !!input.path; + + const projectCwd = hasProjectSelection + ? await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: false, + }) + : { ok: true as const, cwd: undefined }; + + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const args = [ + "linear", + "projects", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.limit ? ["--limit", String(input.limit)] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.projects.list", + args, + ...(projectCwd.cwd ? { cwd: projectCwd.cwd } : {}), + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.project.bind", + { + title: "Bind hack project to Linear project", + description: + "Set or clear the default Linear profile/project mapping for a hack project.", + inputSchema: { + ...projectSelectorInput, + ...linearCommonInput, + projectId: z.string().describe("Linear project id to bind").optional(), + projectName: z + .string() + .describe("Linear project name override") + .optional(), + teamId: z.string().describe("Linear team id override").optional(), + clear: z.boolean().describe("Clear binding for the project").optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const projectCwd = await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: true, + }); + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const args = [ + "linear", + "project-bind", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.projectId ? ["--project-id", input.projectId] : []), + ...(input.projectName ? ["--project-name", input.projectName] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ...(input.clear ? ["--clear"] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.project.bind", + args, + cwd: projectCwd.cwd, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.sync.issue", + { + title: "Manual one-off issue sync", + description: + "Sync one issue/ticket between Linear and hack tickets in a chosen direction.", + inputSchema: { + ...projectSelectorInput, + from: linearIssueDirectionInput, + issue: z + .string() + .describe("Linear issue identifier (e.g. ENG-123)") + .optional(), + ticket: z.string().describe("Hack ticket id (e.g. T-00001)").optional(), + ...linearCommonInput, + projectId: z.string().describe("Linear project id override").optional(), + teamId: z.string().describe("Linear team id override").optional(), + syncLabels: z + .boolean() + .describe("Include label/tag mapping in this sync") + .optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const projectCwd = await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: true, + }); + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const args = [ + "linear", + "sync-issue", + "--json", + "--from", + input.from, + ...(input.issue ? ["--issue", input.issue] : []), + ...(input.ticket ? ["--ticket", input.ticket] : []), + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.projectId ? ["--project-id", input.projectId] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ...(input.syncLabels ? ["--sync-labels"] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.sync.issue", + args, + cwd: projectCwd.cwd, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.sync.project", + { + title: "Manual project bulk sync", + description: + "Bulk sync project issues/tickets between Linear and hack (manual by default).", + inputSchema: { + ...projectSelectorInput, + from: linearIssueDirectionInput, + ...linearCommonInput, + projectId: z.string().describe("Linear project id override").optional(), + teamId: z.string().describe("Linear team id override").optional(), + owner: linearOwnerModeInput, + limit: z.number().describe("Max items to sync").optional(), + syncLabels: z + .boolean() + .describe("Include label/tag mapping in this sync") + .optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const projectCwd = await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: true, + }); + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const args = [ + "linear", + "sync-project", + "--json", + "--from", + input.from, + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.projectId ? ["--project-id", input.projectId] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ...(input.owner ? ["--owner", input.owner] : []), + ...(input.limit ? ["--limit", String(input.limit)] : []), + ...(input.syncLabels ? ["--sync-labels"] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.sync.project", + args, + cwd: projectCwd.cwd, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); } function buildToolResult(opts: { @@ -672,6 +1005,82 @@ function toProjectSelection(input: ProjectSelection): ProjectSelection { }; } +async function resolveProjectCwd(opts: { + readonly selection: ProjectSelection; + readonly cwd: string; + readonly requireProjectContext: boolean; + readonly allowUnregistered?: boolean; +}): Promise< + | { readonly ok: true; readonly cwd: string } + | { readonly ok: false; readonly message: string } +> { + const selection = opts.selection; + const picked = [ + selection.projectName, + selection.repoRoot, + selection.path, + ].filter(Boolean); + + if (picked.length > 1) { + return { + ok: false, + message: "Use only one of projectName, repoRoot, or path.", + }; + } + + if (selection.projectName) { + const project = await resolveRegisteredProjectByName({ + name: selection.projectName, + }); + if (!project) { + return { + ok: false, + message: `Unknown project "${selection.projectName}". Run 'hack init' or 'hack projects' first.`, + }; + } + return { ok: true, cwd: project.projectRoot }; + } + + const pathLike = selection.repoRoot ?? selection.path; + if (pathLike) { + const absPath = resolve(opts.cwd, pathLike); + if (!(await pathExists(absPath))) { + return { ok: false, message: `Path not found: ${absPath}` }; + } + if (!opts.allowUnregistered) { + const allowed = await isPathAllowed({ absPath }); + if (!allowed) { + return { + ok: false, + message: "Path is outside registered hack projects.", + }; + } + } + return { ok: true, cwd: absPath }; + } + + const context = await findProjectContext(opts.cwd); + if (context) { + return { ok: true, cwd: context.projectRoot }; + } + + if (!opts.requireProjectContext) { + return { ok: true, cwd: opts.cwd }; + } + + if (!opts.allowUnregistered) { + const allowed = await isPathAllowed({ absPath: opts.cwd }); + if (!allowed) { + return { + ok: false, + message: "No project context found. Provide projectName or path.", + }; + } + } + + return { ok: true, cwd: opts.cwd }; +} + async function resolveProjectArgs(opts: { readonly selection: ProjectSelection; readonly cwd: string; diff --git a/tests/env-backend-command.test.ts b/tests/env-backend-command.test.ts index ead39c3cd..bd67629e3 100644 --- a/tests/env-backend-command.test.ts +++ b/tests/env-backend-command.test.ts @@ -86,6 +86,50 @@ test("env backend use encrypted_file persists selection", async () => { expect(configText).toContain('"/tmp/custom-secrets.enc.json"'); }); +test("env backend use encrypted_file can provision a stable key file", async () => { + if (!(tempDir && tempGlobalConfigPath)) { + throw new Error("Missing temp global config state"); + } + + const keyPath = resolve(tempDir, "secrets-file.key"); + const result = await runHack({ + args: [ + "env", + "backend", + "use", + "encrypted_file", + "--store-path", + "/tmp/custom-secrets.enc.json", + "--key-path", + keyPath, + "--provision-key", + "--json", + ], + env: { + ...process.env, + HACK_GLOBAL_CONFIG_PATH: tempGlobalConfigPath, + }, + }); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout) as { + readonly backend: string; + readonly encrypted_file: { + readonly path: string; + readonly keyPath?: string; + }; + }; + expect(json.backend).toBe("encrypted_file"); + expect(json.encrypted_file.keyPath).toBe(keyPath); + + const keyText = await readFile(keyPath, "utf8"); + expect(keyText.trim().length).toBeGreaterThan(10); + + const configText = await readFile(tempGlobalConfigPath, "utf8"); + expect(configText).toContain('"keyPath"'); + expect(configText).toContain(keyPath); +}); + test("env backend use cloud requires provider and stores cloud settings", async () => { const invalid = await runHack({ args: ["env", "backend", "use", "cloud"], diff --git a/tests/github-auth.test.ts b/tests/github-auth.test.ts index b39093c3e..3f570d09e 100644 --- a/tests/github-auth.test.ts +++ b/tests/github-auth.test.ts @@ -130,6 +130,7 @@ function createControlPlaneConfig(overrides?: { allowEnvAuthRefs: true, encryptedFile: { path: "~/.hack/secrets.enc.json", + keyPath: "~/.hack/secrets-file.key", }, cloud: { secretPrefix: "hack", diff --git a/tests/linear-auth.test.ts b/tests/linear-auth.test.ts new file mode 100644 index 000000000..4ccdb0d0e --- /dev/null +++ b/tests/linear-auth.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; + +import { + resolveLinearToken, + type SecretStore, + saveLinearToken, +} from "../src/control-plane/extensions/linear/auth.ts"; +import type { ControlPlaneConfig } from "../src/control-plane/sdk/config.ts"; + +function createControlPlaneConfig(overrides?: { + readonly defaultProfile?: string; + readonly profiles?: Record>; + readonly projectLinearProfile?: string; +}): ControlPlaneConfig { + return { + extensions: { + "dance.hack.linear": { + enabled: true, + config: { + ...(overrides?.defaultProfile + ? { defaultProfile: overrides.defaultProfile } + : {}), + ...(overrides?.profiles ? { profiles: overrides.profiles } : {}), + }, + }, + }, + ...(overrides?.projectLinearProfile + ? { + routing: { + overrides: { + linear: { + profile: overrides.projectLinearProfile, + }, + }, + }, + } + : {}), + } as unknown as ControlPlaneConfig; +} + +function createMemoryStore(): { + readonly store: SecretStore; + readonly values: Map; +} { + const values = new Map(); + return { + values, + store: { + get: async (input) => + values.get(`${input.service}:${input.name}`) ?? null, + set: async (input) => { + values.set(`${input.service}:${input.name}`, input.value); + }, + delete: async (input) => values.delete(`${input.service}:${input.name}`), + }, + }; +} + +test("saveLinearToken persists refresh metadata in the stored envelope", async () => { + const config = createControlPlaneConfig({ + profiles: { + work: { + authRef: "linear.api.work", + service: "hack-linear-work", + }, + }, + }); + const memory = createMemoryStore(); + + await saveLinearToken({ + controlPlaneConfig: config, + profileId: "work", + token: "lin_access_token", + expiresAt: "2026-03-05T12:00:00.000Z", + refreshToken: "lin_refresh_token", + refreshTokenExpiresAt: "2026-04-05T12:00:00.000Z", + store: memory.store, + }); + + const raw = memory.values.get("hack-linear-work:linear.api.work"); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw ?? "{}") as Record; + expect(parsed.token).toBe("lin_access_token"); + expect(parsed.expiresAt).toBe("2026-03-05T12:00:00.000Z"); + expect(parsed.refreshToken).toBe("lin_refresh_token"); + expect(parsed.refreshTokenExpiresAt).toBe("2026-04-05T12:00:00.000Z"); +}); + +test("resolveLinearToken refreshes expired stored credentials via auth broker", async () => { + const config = createControlPlaneConfig({ + profiles: { + default: { + authRef: "linear.api.default", + service: "hack-linear-auth", + }, + }, + }); + const memory = createMemoryStore(); + memory.values.set( + "hack-linear-auth:linear.api.default", + JSON.stringify({ + token: "expired_access_token", + expiresAt: "2025-03-05T12:00:00.000Z", + refreshToken: "lin_refresh_token", + refreshTokenExpiresAt: "2026-04-05T12:00:00.000Z", + }) + ); + + let requestBody = ""; + const resolved = await resolveLinearToken({ + controlPlaneConfig: config, + store: memory.store, + refreshConfig: { + baseUrl: "https://auth.hack.broker", + fetch: (async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof Request + ? input.url + : input.toString(); + expect(url).toBe("https://auth.hack.broker/v1/auth/linear/refresh"); + expect(init?.method).toBe("POST"); + requestBody = typeof init?.body === "string" ? init.body : ""; + return new Response( + JSON.stringify({ + ok: true, + token: "fresh_access_token", + tokenExpiresAt: "2026-03-06T12:00:00.000Z", + refreshToken: "fresh_refresh_token", + refreshTokenExpiresAt: "2026-04-06T12:00:00.000Z", + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + }) as typeof fetch, + }, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) { + return; + } + expect(resolved.token).toBe("fresh_access_token"); + expect(resolved.expiresAt).toBe("2026-03-06T12:00:00.000Z"); + expect(requestBody).toContain('"refreshToken":"lin_refresh_token"'); + + const raw = memory.values.get("hack-linear-auth:linear.api.default"); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw ?? "{}") as Record; + expect(parsed.token).toBe("fresh_access_token"); + expect(parsed.refreshToken).toBe("fresh_refresh_token"); + expect(parsed.refreshTokenExpiresAt).toBe("2026-04-06T12:00:00.000Z"); +}); diff --git a/tests/linear-command-alias.test.ts b/tests/linear-command-alias.test.ts new file mode 100644 index 000000000..6e693b40f --- /dev/null +++ b/tests/linear-command-alias.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; + +test("hack linear alias forwards extension options like --json", async () => { + const proc = Bun.spawn( + [ + "bun", + resolve(import.meta.dir, "../index.ts"), + "linear", + "status", + "--json", + ], + { + cwd: resolve(import.meta.dir, ".."), + env: process.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + expect(exitCode).toBe(1); + expect(stderr).not.toContain('Option(s) not valid for "linear": --json'); + const payload = JSON.parse(stdout) as { + readonly extensionId?: string; + }; + expect(payload.extensionId).toBe("dance.hack.linear"); +}); diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts new file mode 100644 index 000000000..5d5a4e33b --- /dev/null +++ b/tests/linear-commands.test.ts @@ -0,0 +1,222 @@ +import { expect, test } from "bun:test"; + +import { __testOnly } from "../src/control-plane/extensions/linear/commands.ts"; + +test("parseSetupArgs parses project binding flags", () => { + const parsed = __testOnly.parseSetupArgs({ + args: [ + "--profile", + "work", + "--project-id", + "proj_123", + "--project-name", + "Platform", + "--team-id", + "team_123", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + projectId: "proj_123", + projectName: "Platform", + teamId: "team_123", + json: true, + }); +}); + +test("parseConnectArgs parses token connection flags", () => { + const parsed = __testOnly.parseConnectArgs({ + args: [ + "--profile", + "work", + "--token", + "secret-token", + "--token-env", + "CUSTOM_LINEAR_TOKEN", + "--auth-ref", + "linear.work.api", + "--service", + "hack-linear-work", + "--api-url", + "https://api.linear.app/graphql", + "--refresh-token", + "refresh-token", + "--token-expires-at", + "2026-03-05T12:00:00.000Z", + "--refresh-token-expires-at", + "2026-04-05T12:00:00.000Z", + "--set-default", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + token: "secret-token", + tokenEnv: "CUSTOM_LINEAR_TOKEN", + authRef: "linear.work.api", + service: "hack-linear-work", + apiUrl: "https://api.linear.app/graphql", + refreshToken: "refresh-token", + tokenExpiresAt: "2026-03-05T12:00:00.000Z", + refreshTokenExpiresAt: "2026-04-05T12:00:00.000Z", + stdin: false, + setDefault: true, + }); +}); + +test("parseSyncIssueArgs rejects invalid direction", () => { + const parsed = __testOnly.parseSyncIssueArgs({ + args: ["--from", "other"], + }); + + expect(parsed.ok).toBe(false); + if (parsed.ok) { + return; + } + expect(parsed.error).toContain("Expected linear|hack"); +}); + +test("parseSyncProjectArgs parses owner filter and limits", () => { + const parsed = __testOnly.parseSyncProjectArgs({ + args: [ + "--from", + "hack", + "--owner", + "both", + "--project-id", + "proj_abc", + "--team-id", + "team_abc", + "--limit", + "25", + "--sync-labels", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + from: "hack", + ownerMode: "both", + projectId: "proj_abc", + teamId: "team_abc", + limit: 25, + syncLabels: true, + json: true, + }); +}); + +test("connect falls back to oauth when no token input exists", () => { + const envKey = "HACK_LINEAR_TEST_TOKEN"; + const previous = process.env[envKey]; + delete process.env[envKey]; + + const fallback = __testOnly.shouldFallbackConnectToOAuth({ + parsed: { + stdin: false, + setDefault: false, + }, + tokenEnv: envKey, + }); + + if (previous !== undefined) { + process.env[envKey] = previous; + } else { + delete process.env[envKey]; + } + + expect(fallback).toBe(true); +}); + +test("connect does not fall back to oauth when token exists in env", () => { + const envKey = "HACK_LINEAR_TEST_TOKEN"; + const previous = process.env[envKey]; + process.env[envKey] = "token-present"; + + const fallback = __testOnly.shouldFallbackConnectToOAuth({ + parsed: { + stdin: false, + setDefault: false, + }, + tokenEnv: envKey, + }); + + if (previous !== undefined) { + process.env[envKey] = previous; + } else { + delete process.env[envKey]; + } + + expect(fallback).toBe(false); +}); + +test("oauth connect prefers broker flow when no local oauth overrides are provided", () => { + const useBroker = __testOnly.shouldUseBrokerOAuthFlow({ + parsed: { + setDefault: false, + clientSecretStdin: false, + noOpen: false, + json: false, + }, + }); + + expect(useBroker).toBe(true); +}); + +test("oauth connect disables broker flow when local oauth overrides are provided", () => { + const useBroker = __testOnly.shouldUseBrokerOAuthFlow({ + parsed: { + setDefault: false, + clientId: "client-id", + clientSecretStdin: false, + noOpen: false, + json: false, + }, + }); + + expect(useBroker).toBe(false); +}); + +test("buildOAuthArgsFromConnectArgs maps connect defaults into oauth args", () => { + const args = __testOnly.buildOAuthArgsFromConnectArgs({ + profileId: "work", + parsed: { + stdin: false, + setDefault: true, + apiUrl: "https://api.linear.app/graphql", + tokenEnv: "LINEAR_TOKEN", + authRef: "linear.api.work", + service: "hack-linear-auth", + }, + }); + + expect(args).toEqual([ + "--profile", + "work", + "--set-default", + "--api-url", + "https://api.linear.app/graphql", + "--token-env", + "LINEAR_TOKEN", + "--auth-ref", + "linear.api.work", + "--service", + "hack-linear-auth", + ]); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index ba052e3b7..b2ca4b0e9 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -39,6 +39,8 @@ testIntegration( expect(names).toContain("hack.project.status"); expect(names).toContain("hack.project.logs.tail"); expect(names).toContain("hack.project.open"); + expect(names).toContain("hack.linear.status"); + expect(names).toContain("hack.linear.sync.project"); } ); @@ -63,6 +65,31 @@ testIntegration( } ); +testIntegration( + "hack.linear.status returns structured data", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.status", + arguments: {}, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + extensionId: "dance.hack.linear", + selectedProfile: "default", + tokenResolved: true, + }); + } +); + async function startMcpClient(): Promise { tempDir = await mkdtemp(join(tmpdir(), "hack-mcp-")); const homeDir = join(tempDir, "home"); @@ -102,6 +129,13 @@ function buildHackStubScript(): string { " console.log(JSON.stringify(payload))", " process.exit(0)", "}", + 'if (cmd === "linear") {', + ' const sub = args[1] ?? ""', + ' if (sub === "status") {', + ' console.log(JSON.stringify({ extensionId: "dance.hack.linear", selectedProfile: "default", tokenResolved: true }))', + " process.exit(0)", + " }", + "}", "console.error(`unknown command: ${cmd}`)", "process.exit(1)", "", diff --git a/tests/nodes-registry.test.ts b/tests/nodes-registry.test.ts index 372072eb6..13a8ae64f 100644 --- a/tests/nodes-registry.test.ts +++ b/tests/nodes-registry.test.ts @@ -232,7 +232,12 @@ async function writeGlobalSecretsConfig(input: { ? { allowEnvAuthRefs: input.allowEnvAuthRefs } : {}), ...(input.encryptedFilePath - ? { encryptedFile: { path: input.encryptedFilePath } } + ? { + encryptedFile: { + path: input.encryptedFilePath, + keyPath: "~/.hack/secrets-file.key", + }, + } : {}), ...(input.cloudProvider ? { diff --git a/tests/secret-store.test.ts b/tests/secret-store.test.ts index 5445ad455..72ccf8d01 100644 --- a/tests/secret-store.test.ts +++ b/tests/secret-store.test.ts @@ -130,6 +130,42 @@ test("cloud backend shim is provider-scoped and persists through encrypted store expect(secondValue).toBeNull(); }); +test("encrypted_file backend reads key material from configured keyPath when env is unset", async () => { + if (!tempDir) { + throw new Error("Missing temp dir"); + } + process.env.HACK_SECRETS_FILE_KEY = undefined; + const storePath = join(tempDir, "secrets.enc.json"); + const keyPath = join(tempDir, "secrets-file.key"); + await writeFile(keyPath, "stable-file-key\n"); + await writeGlobalConfig({ + value: { + controlPlane: { + secrets: { + backend: "encrypted_file", + encryptedFile: { + path: storePath, + keyPath, + }, + }, + }, + }, + }); + + const store = await resolveSecretStore({ + projectName: "app", + }); + await store.set({ + key: "API_TOKEN", + value: "stable-key-value", + }); + + const resolved = await store.get({ + key: "API_TOKEN", + }); + expect(resolved).toBe("stable-key-value"); +}); + async function writeGlobalConfig(input: { readonly value: Record; }): Promise { From ebbc60cdb7e8408e8cbca0de1388b6e6d190a82b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 5 Mar 2026 17:52:14 -0500 Subject: [PATCH 02/54] Finish Linear sync phase 2 and desktop review flow --- .../DashboardFeature/DashboardModel.swift | 22 + .../DashboardFeature/TicketsView.swift | 366 ++++- .../HackCLIService/HackCLIClient.swift | 25 + .../Sources/HackDesktopModels/Models.swift | 447 ++++++ .../LinearSyncUXModelTests.swift | 4 +- .../TicketDetailReviewModelTests.swift | 287 ++++ .../2026-03-05-linear-sync-phase-2-design.md | 155 ++ .../2026-03-05-linear-sync-phase-2-plan.md | 260 ++++ packages/db/src/schema/core.ts | 100 ++ packages/db/tests/smoke.test.ts | 25 + services/auth-broker/.env.example | 2 + services/auth-broker/README.md | 25 +- services/auth-broker/src/app.ts | 66 + services/auth-broker/src/better-auth-link.ts | 47 +- services/auth-broker/src/config.ts | 4 + services/auth-broker/src/flow-store.ts | 21 + services/auth-broker/src/linear.ts | 168 ++- .../src/modules/linear-agent/plugin.ts | 243 +++- .../src/modules/linear-connections/plugin.ts | 39 + .../src/modules/linear-connections/service.ts | 362 +++++ .../linear-oauth/callback-routes-plugin.ts | 8 + .../src/modules/linear-oauth/plugin.ts | 8 + .../src/modules/linear-oauth/service.ts | 178 ++- .../src/modules/linear-sync-store/plugin.ts | 125 ++ .../src/modules/linear-sync-store/service.ts | 425 ++++++ .../src/modules/providers/plugin.ts | 2 + services/auth-broker/src/types.ts | 6 + .../tests/better-auth-link.test.ts | 34 +- services/auth-broker/tests/config.test.ts | 15 + services/auth-broker/tests/index.test.ts | 393 ++++- src/control-plane/extensions/linear/client.ts | 395 +++++- .../extensions/linear/commands.ts | 1263 ++++++++++++++++- .../extensions/tickets/commands.ts | 409 +++++- src/control-plane/extensions/tickets/store.ts | 1000 +++++++++++-- src/mcp/server.ts | 68 + tests/linear-client.test.ts | 301 ++++ tests/linear-commands.test.ts | 529 +++++++ tests/mcp.test.ts | 70 + tests/tickets-extension.test.ts | 133 ++ tests/tickets-store.test.ts | 388 +++++ 40 files changed, 8043 insertions(+), 375 deletions(-) create mode 100644 apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift create mode 100644 docs/plans/2026-03-05-linear-sync-phase-2-design.md create mode 100644 docs/plans/2026-03-05-linear-sync-phase-2-plan.md create mode 100644 services/auth-broker/src/modules/linear-connections/plugin.ts create mode 100644 services/auth-broker/src/modules/linear-connections/service.ts create mode 100644 services/auth-broker/src/modules/linear-sync-store/plugin.ts create mode 100644 services/auth-broker/src/modules/linear-sync-store/service.ts create mode 100644 tests/linear-client.test.ts create mode 100644 tests/tickets-store.test.ts diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 059afb571..30f553da7 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -331,6 +331,28 @@ public final class DashboardModel { return response?.sync } + public func resolveTicketConflict( + for project: ProjectSummary, + ticketId: String, + conflictId: String, + resolution: TicketSyncConflictResolution, + summary: String? = nil + ) async -> TicketConflictResolutionResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + return await runActionResult(message: "Resolving sync conflict…") { + try await self.ticketsClient.resolveTicketConflict( + path: path, + ticketId: ticketId, + conflictId: conflictId, + resolution: resolution, + summary: summary + ) + } + } + public func setupTickets(for project: ProjectSummary) async -> Bool { guard let path = resolveProjectPath(project) else { errorMessage = "Missing project path for \(project.name)" diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index b1ece6644..f6c608b4b 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -33,6 +33,7 @@ struct TicketsView: View { @State private var linearRouteStatus: LinearStatusResponse? = nil @State private var pendingBulkSyncAction: TicketBulkSyncAction? = nil @State private var activeSyncAction: TicketSyncAction? = nil + @State private var resolvingConflictIds: Set = [] @FocusState private var ticketsListFocused: Bool var body: some View { @@ -678,6 +679,9 @@ struct TicketsView: View { HStack(spacing: 6) { ticketMetaPill(ticket.owner, tone: ticket.owner == "linear" ? .orange : .secondary) ticketMetaPill(ticket.source, tone: ticket.source == "linear" ? .orange : .secondary) + if let assignee = ticket.assignee?.trimmingCharacters(in: .whitespacesAndNewlines), !assignee.isEmpty { + ticketMetaPill("Assignee \(assignee)", tone: .secondary) + } ticketMetaPill(ticketAuthorityBadgeLabel(for: ticket), tone: ticketAuthorityBadgeColor(for: ticket)) if ticket.linearSyncUXState.reviewHint != nil { ticketMetaPill("Review sync", tone: .orange) @@ -719,7 +723,11 @@ struct TicketsView: View { } } - ticketSyncGuidanceCallout(detail.ticket) + ticketSyncGuidanceCallout(detail) + ticketSyncSnapshotStrip(detail) + ticketCommentsSection(detail) + ticketSyncCheckpointSection(detail) + ticketConflictSection(detail) } } @@ -728,6 +736,9 @@ struct TicketsView: View { HStack(spacing: 8) { ticketMetaPill(ticket.owner, tone: ticket.owner == "linear" ? .orange : .secondary) ticketMetaPill(ticket.source, tone: ticket.source == "linear" ? .orange : .secondary) + if let assignee = ticket.assignee?.trimmingCharacters(in: .whitespacesAndNewlines), !assignee.isEmpty { + ticketMetaPill("Assignee \(assignee)", tone: .secondary) + } ticketMetaPill(ticketAuthorityBadgeLabel(for: ticket), tone: ticketAuthorityBadgeColor(for: ticket)) if ticket.linearSyncUXState.reviewHint != nil { ticketMetaPill("Review sync", tone: .orange) @@ -750,16 +761,264 @@ struct TicketsView: View { } } - private func ticketSyncGuidanceCallout(_ ticket: TicketSummary) -> some View { - let guidance = ticketSyncGuidance(for: ticket) + private func ticketSyncGuidanceCallout(_ detail: TicketDetailResponse) -> some View { + let guidance = ticketSyncGuidance(for: detail) return InlineCallout( tone: guidance.tone, title: guidance.title, message: guidance.message, - actions: ticketSyncGuidanceActions(for: ticket) + actions: ticketSyncGuidanceActions(for: detail) ) } + @ViewBuilder + private func ticketSyncSnapshotStrip(_ detail: TicketDetailResponse) -> some View { + let review = detail.linearSyncReviewState + if detail.comments.isEmpty && detail.syncCheckpoints.isEmpty && detail.conflicts.isEmpty { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Sync snapshot") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ticketMetaPill(review.badgeLabel, tone: reviewToneColor(review.severity)) + ticketMetaPill("\(review.commentCount) comment\(review.commentCount == 1 ? "" : "s")", tone: .secondary) + ticketMetaPill("\(review.openConflictCount) open", tone: review.openConflictCount > 0 ? .orange : .secondary) + ticketMetaPill("\(review.resolvedConflictCount) resolved", tone: review.resolvedConflictCount > 0 ? .green : .secondary) + if let checkpointSummary = review.checkpointSummary { + ticketMetaPill(checkpointSummary, tone: .secondary) + } + } + } + } + } + } + + @ViewBuilder + private func ticketCommentsSection(_ detail: TicketDetailResponse) -> some View { + if !detail.comments.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Comments") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + ticketMetaPill("Append-only FIFO", tone: .secondary) + } + VStack(alignment: .leading, spacing: 10) { + ForEach(detail.comments) { comment in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + ticketMetaPill(comment.source, tone: comment.source == "linear" ? .orange : .secondary) + ticketMetaPill(comment.actor, tone: .secondary) + Text(humanReadableDetailDate(comment.createdAt)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + if let externalUrl = comment.externalUrl, + let url = URL(string: externalUrl) { + Button { + openURL(url) + } label: { + Label("Open", systemImage: "link") + } + .adaptiveToolbarButton() + } + } + markdownBody(comment.body) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + } + } + } + } + + @ViewBuilder + private func ticketSyncCheckpointSection(_ detail: TicketDetailResponse) -> some View { + if !detail.syncCheckpoints.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Sync checkpoints") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 8) { + ForEach(detail.syncCheckpoints.sorted(by: { $0.createdAt > $1.createdAt })) { checkpoint in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + ticketMetaPill(checkpoint.provider.capitalized, tone: .accentColor) + if let direction = checkpoint.direction, !direction.isEmpty { + ticketMetaPill(direction, tone: .secondary) + } + if let profileId = checkpoint.profileId, !profileId.isEmpty { + ticketMetaPill("Profile \(profileId)", tone: .secondary) + } + Spacer() + Text(humanReadableDetailDate(checkpoint.createdAt)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + if let remoteCursor = checkpoint.remoteCursor, !remoteCursor.isEmpty { + Text(remoteCursor) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + if let remoteUpdatedAt = checkpoint.remoteUpdatedAt, !remoteUpdatedAt.isEmpty { + Text("Remote updated \(humanReadableDetailDate(remoteUpdatedAt))") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + } + } + } + } + + @ViewBuilder + private func ticketConflictSection(_ detail: TicketDetailResponse) -> some View { + if !detail.conflicts.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Conflicts") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + if detail.openSyncConflicts.count > 0 { + ticketMetaPill("\(detail.openSyncConflicts.count) open", tone: .orange) + } + if detail.resolvedSyncConflicts.count > 0 { + ticketMetaPill("\(detail.resolvedSyncConflicts.count) resolved", tone: .green) + } + } + VStack(alignment: .leading, spacing: 8) { + ForEach(detail.conflicts.sorted(by: { $0.updatedAt > $1.updatedAt })) { conflict in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + ticketMetaPill(conflict.field, tone: conflict.status == .open ? .orange : .green) + ticketMetaPill(conflict.status == .open ? "Open" : "Resolved", tone: conflict.status == .open ? .orange : .green) + if let authority = conflict.authority, !authority.isEmpty { + ticketMetaPill(authority, tone: .secondary) + } + Spacer() + Text(humanReadableDetailDate(conflict.updatedAt)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + if let summary = conflict.summary, !summary.isEmpty { + Text(summary) + .font(.system(size: 13, weight: .medium)) + } + VStack(alignment: .leading, spacing: 6) { + ticketConflictValueRow(label: "Local", value: conflict.localValue?.displayText) + ticketConflictValueRow(label: "Remote", value: conflict.remoteValue?.displayText) + if let resolution = conflict.resolution?.rawValue { + ticketConflictValueRow(label: "Resolution", value: resolution.replacingOccurrences(of: "_", with: " ")) + } + if let resolutionSummary = conflict.resolutionSummary, !resolutionSummary.isEmpty { + ticketConflictValueRow(label: "Notes", value: resolutionSummary) + } + } + if conflict.status == .open { + ticketConflictActions(conflict) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(conflict.status == .open ? Color.orange.opacity(colorScheme == .dark ? 0.08 : 0.06) : Color.green.opacity(colorScheme == .dark ? 0.08 : 0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(conflict.status == .open ? Color.orange.opacity(0.18) : Color.green.opacity(0.18), lineWidth: 1) + ) + } + } + } + } + } + + @ViewBuilder + private func ticketConflictActions(_ conflict: TicketSyncConflict) -> some View { + let isResolving = resolvingConflictIds.contains(conflict.conflictId) + HStack(spacing: 8) { + Button { + Task { + await resolveTicketConflict( + conflict, + resolution: .acceptLocal + ) + } + } label: { + Label("Keep Hack", systemImage: "arrow.uturn.backward.circle") + } + .adaptiveToolbarButtonProminent() + .disabled(isResolving) + + Button { + Task { + await resolveTicketConflict( + conflict, + resolution: .acceptRemote + ) + } + } label: { + Label("Keep Linear", systemImage: "arrow.uturn.forward.circle") + } + .adaptiveToolbarButton() + .disabled(isResolving) + + Menu { + Button("Mark merged") { + Task { + await resolveTicketConflict( + conflict, + resolution: .merged, + summary: "Marked merged from the macOS review UI." + ) + } + } + Button("Ignore for now") { + Task { + await resolveTicketConflict( + conflict, + resolution: .ignore, + summary: "Ignored from the macOS review UI." + ) + } + } + } label: { + Label("More", systemImage: "ellipsis.circle") + } + .adaptiveToolbarButton() + .disabled(isResolving) + + if isResolving { + ProgressView() + .controlSize(.small) + } + } + } + private func ticketSyncGuidanceActions(for ticket: TicketSummary) -> [InlineCalloutAction] { let syncState = ticket.linearSyncUXState if syncState.reviewHint != nil { @@ -814,6 +1073,26 @@ struct TicketsView: View { } } + private func ticketSyncGuidanceActions(for detail: TicketDetailResponse) -> [InlineCalloutAction] { + if detail.linearSyncReviewState.needsReview { + var actions = [ + InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + openProjectRouting() + } + ] + if let externalURL = detail.ticket.externalUrl, + let url = URL(string: externalURL) { + actions.append( + InlineCalloutAction(label: "Open linked issue", systemImage: "link") { + openURL(url) + } + ) + } + return actions + } + return ticketSyncGuidanceActions(for: detail.ticket) + } + private func ticketAuthorityBadgeLabel(for ticket: TicketSummary) -> String { if ticket.linearSyncUXState.reviewHint != nil { return "Review authority" @@ -872,6 +1151,15 @@ struct TicketsView: View { } } + private func ticketSyncGuidance(for detail: TicketDetailResponse) -> TicketSyncGuidance { + let review = detail.linearSyncReviewState + return TicketSyncGuidance( + title: review.title, + message: review.message, + tone: review.severity == .conflict || review.severity == .review ? .warn : .neutral + ) + } + private func ticketDetailFooter(_ detail: TicketDetailResponse) -> some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { @@ -1329,6 +1617,49 @@ struct TicketsView: View { } } + private func resolveTicketConflict( + _ conflict: TicketSyncConflict, + resolution: TicketSyncConflictResolution, + summary: String? = nil + ) async { + guard !resolvingConflictIds.contains(conflict.conflictId) else { + return + } + resolvingConflictIds.insert(conflict.conflictId) + defer { resolvingConflictIds.remove(conflict.conflictId) } + + let result = await model.resolveTicketConflict( + for: project, + ticketId: conflict.ticketId, + conflictId: conflict.conflictId, + resolution: resolution, + summary: summary + ) + guard result?.ok == true else { + loadNotice = model.errorMessage ?? "Failed to resolve ticket conflict." + return + } + + loadNotice = "Resolved \(conflict.field) conflict by \(ticketConflictResolutionLabel(resolution))." + await refreshTickets() + await loadTicketDetail() + } + + private func ticketConflictResolutionLabel( + _ resolution: TicketSyncConflictResolution + ) -> String { + switch resolution { + case .acceptLocal: + return "keeping Hack" + case .acceptRemote: + return "keeping Linear" + case .merged: + return "marking it merged" + case .ignore: + return "ignoring it" + } + } + private func parseTicketRefs(_ text: String) -> [String] { let parts = text .split { $0 == "," || $0 == " " || $0 == "\n" || $0 == "\t" } @@ -1426,6 +1757,7 @@ struct TicketsView: View { DetailRowItem(label: "Status", value: ticket.status.label), DetailRowItem(label: "Owner", value: ticket.owner), DetailRowItem(label: "Source", value: ticket.source), + DetailRowItem(label: "Assignee", value: ticket.assignee ?? "—"), DetailRowItem(label: "Tags", value: ticket.tags.joined(separator: ", ").nilIfEmpty ?? "—"), DetailRowItem(label: "Remote", value: ticket.externalSystem ?? "—"), DetailRowItem(label: "Issue key", value: ticket.externalKey ?? "—"), @@ -1551,6 +1883,32 @@ struct TicketsView: View { .foregroundStyle(tone) } + @ViewBuilder + private func ticketConflictValueRow(label: String, value: String?) -> some View { + if let value, !value.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + } + } + + private func reviewToneColor(_ severity: TicketSyncReviewSeverity) -> Color { + switch severity { + case .clear: + return .green + case .review: + return .orange + case .conflict: + return .orange + } + } + private func ticketHeaderBadge( title: String, systemImage: String, diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index c72a2aa55..9c865bd6b 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -1013,6 +1013,31 @@ public actor HackCLIClient { return try decodeLenient(TicketStatusResponse.self, from: result.stdout) } + public func resolveTicketConflict( + path: String, + ticketId: String, + conflictId: String, + resolution: TicketSyncConflictResolution, + summary: String? = nil + ) async throws -> TicketConflictResolutionResponse { + var args = [ + "x", + "tickets", + "resolve-conflict", + ticketId, + "--conflict-id", + conflictId, + "--resolution", + resolution.rawValue, + "--json", + ] + if let summary, !summary.isEmpty { + args.append(contentsOf: ["--summary", summary]) + } + let result = try await run(args, cwd: path) + return try decodeLenient(TicketConflictResolutionResponse.self, from: result.stdout) + } + public func syncTickets(path: String) async throws -> TicketsSyncResponse { let result = try await run(["x", "tickets", "sync", "--json"], cwd: path) return try decodeLenient(TicketsSyncResponse.self, from: result.stdout) diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 92e26f85a..158d67bf0 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -1839,6 +1839,7 @@ public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { public let blocks: [String] public let owner: String public let source: String + public let assignee: String? public let tags: [String] public let externalSystem: String? public let externalId: String? @@ -1863,6 +1864,7 @@ public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { blocks: [String], owner: String, source: String, + assignee: String?, tags: [String], externalSystem: String?, externalId: String?, @@ -1884,6 +1886,7 @@ public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { self.blocks = blocks self.owner = owner self.source = source + self.assignee = assignee self.tags = tags self.externalSystem = externalSystem self.externalId = externalId @@ -1907,6 +1910,7 @@ public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { case blocks case owner case source + case assignee case tags case externalSystem case externalId @@ -1931,6 +1935,7 @@ public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { blocks = try container.decodeIfPresent([String].self, forKey: .blocks) ?? [] owner = try container.decodeIfPresent(String.self, forKey: .owner) ?? "hack" source = try container.decodeIfPresent(String.self, forKey: .source) ?? "hack" + assignee = try container.decodeIfPresent(String.self, forKey: .assignee) tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] externalSystem = try container.decodeIfPresent(String.self, forKey: .externalSystem) externalId = try container.decodeIfPresent(String.self, forKey: .externalId) @@ -2048,6 +2053,372 @@ public extension TicketSummary { } } +public indirect enum TicketMetadataValue: Decodable, Hashable { + case string(String) + case integer(Int) + case double(Double) + case boolean(Bool) + case array([TicketMetadataValue]) + case object([String: TicketMetadataValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + return + } + if let string = try? container.decode(String.self) { + self = .string(string) + return + } + if let integer = try? container.decode(Int.self) { + self = .integer(integer) + return + } + if let double = try? container.decode(Double.self) { + self = .double(double) + return + } + if let boolean = try? container.decode(Bool.self) { + self = .boolean(boolean) + return + } + if let array = try? container.decode([TicketMetadataValue].self) { + self = .array(array) + return + } + if let object = try? container.decode([String: TicketMetadataValue].self) { + self = .object(object) + return + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported ticket metadata value." + ) + } + + public func hash(into hasher: inout Hasher) { + switch self { + case let .string(value): + hasher.combine(0) + hasher.combine(value) + case let .integer(value): + hasher.combine(1) + hasher.combine(value) + case let .double(value): + hasher.combine(2) + hasher.combine(value) + case let .boolean(value): + hasher.combine(3) + hasher.combine(value) + case let .array(values): + hasher.combine(4) + for value in values { + hasher.combine(value) + } + case let .object(values): + hasher.combine(5) + for key in values.keys.sorted() { + hasher.combine(key) + hasher.combine(values[key]) + } + case .null: + hasher.combine(6) + } + } + + public var displayText: String { + switch self { + case let .string(value): + return value + case let .integer(value): + return String(value) + case let .double(value): + return value.formatted(.number) + case let .boolean(value): + return value ? "true" : "false" + case let .array(values): + return "[\(values.map(\.displayText).joined(separator: ", "))]" + case let .object(values): + let entries = values.keys.sorted().compactMap { key -> String? in + guard let value = values[key] else { + return nil + } + return #""\#(key)":\#(value.quotedDisplayText)"# + } + return "{\(entries.joined(separator: ","))}" + case .null: + return "null" + } + } + + private var quotedDisplayText: String { + switch self { + case .string: + return #""\#(escapedDisplayText)""# + default: + return displayText + } + } + + private var escapedDisplayText: String { + displayText + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} + +public struct TicketComment: Decodable, Identifiable, Hashable { + public let commentId: String + public let ticketId: String + public let body: String + public let source: String + public let actor: String + public let createdAt: String + public let externalId: String? + public let externalUrl: String? + + public var id: String { commentId } + + public init( + commentId: String, + ticketId: String, + body: String, + source: String, + actor: String, + createdAt: String, + externalId: String?, + externalUrl: String? + ) { + self.commentId = commentId + self.ticketId = ticketId + self.body = body + self.source = source + self.actor = actor + self.createdAt = createdAt + self.externalId = externalId + self.externalUrl = externalUrl + } +} + +public struct TicketSyncCheckpoint: Decodable, Identifiable, Hashable { + public let checkpointId: String + public let ticketId: String + public let provider: String + public let profileId: String? + public let direction: String? + public let remoteCursor: String? + public let remoteUpdatedAt: String? + public let localUpdatedAt: String? + public let actor: String + public let createdAt: String + + public var id: String { checkpointId } + + public init( + checkpointId: String, + ticketId: String, + provider: String, + profileId: String?, + direction: String?, + remoteCursor: String?, + remoteUpdatedAt: String?, + localUpdatedAt: String?, + actor: String, + createdAt: String + ) { + self.checkpointId = checkpointId + self.ticketId = ticketId + self.provider = provider + self.profileId = profileId + self.direction = direction + self.remoteCursor = remoteCursor + self.remoteUpdatedAt = remoteUpdatedAt + self.localUpdatedAt = localUpdatedAt + self.actor = actor + self.createdAt = createdAt + } +} + +public enum TicketSyncConflictStatus: String, Decodable, Hashable { + case open + case resolved +} + +public enum TicketSyncConflictResolution: String, Decodable, Hashable { + case acceptLocal = "accept_local" + case acceptRemote = "accept_remote" + case merged + case ignore +} + +public struct TicketSyncConflict: Decodable, Identifiable, Hashable { + public let conflictId: String + public let ticketId: String + public let provider: String + public let field: String + public let status: TicketSyncConflictStatus + public let authority: String? + public let summary: String? + public let localValue: TicketMetadataValue? + public let remoteValue: TicketMetadataValue? + public let createdAt: String + public let updatedAt: String + public let resolution: TicketSyncConflictResolution? + public let resolutionSummary: String? + public let resolvedAt: String? + public let resolvedBy: String? + + public var id: String { conflictId } + + public init( + conflictId: String, + ticketId: String, + provider: String, + field: String, + status: TicketSyncConflictStatus, + authority: String?, + summary: String?, + localValue: TicketMetadataValue?, + remoteValue: TicketMetadataValue?, + createdAt: String, + updatedAt: String, + resolution: TicketSyncConflictResolution?, + resolutionSummary: String?, + resolvedAt: String?, + resolvedBy: String? + ) { + self.conflictId = conflictId + self.ticketId = ticketId + self.provider = provider + self.field = field + self.status = status + self.authority = authority + self.summary = summary + self.localValue = localValue + self.remoteValue = remoteValue + self.createdAt = createdAt + self.updatedAt = updatedAt + self.resolution = resolution + self.resolutionSummary = resolutionSummary + self.resolvedAt = resolvedAt + self.resolvedBy = resolvedBy + } +} + +public enum TicketSyncReviewSeverity: String, Hashable { + case clear + case review + case conflict +} + +public struct TicketSyncReviewState: Hashable { + public let severity: TicketSyncReviewSeverity + public let needsReview: Bool + public let badgeLabel: String + public let title: String + public let message: String + public let commentCount: Int + public let openConflictCount: Int + public let resolvedConflictCount: Int + public let checkpointSummary: String? + public let highlightedFields: [String] + + public init(detail: TicketDetailResponse) { + let checkpointSummary = Self.makeCheckpointSummary(detail.latestSyncCheckpoint) + let openConflicts = detail.openSyncConflicts + let resolvedConflicts = detail.resolvedSyncConflicts + let highlightedFields = Self.makeHighlightedFields( + openConflicts: openConflicts, + reviewHint: detail.ticket.linearSyncUXState.reviewHint + ) + let guidance = detail.ticket.linearSyncUXState.shortGuidance + + let severity: TicketSyncReviewSeverity + let needsReview: Bool + let badgeLabel: String + let title: String + let message: String + + if !openConflicts.isEmpty { + severity = .conflict + needsReview = true + badgeLabel = openConflicts.count == 1 ? "1 open conflict" : "\(openConflicts.count) open conflicts" + title = "Resolve sync conflicts" + let summaries = openConflicts.compactMap(\.summary).joined(separator: " ") + let fieldSummary = highlightedFields.joined(separator: ", ") + let checkpointClause = checkpointSummary.map { " \($0)" } ?? "" + message = "Review \(fieldSummary) before the next sync. Comments stay append-only. \(guidance) \(summaries)\(checkpointClause)" + .replacingOccurrences(of: " ", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } else if let reviewHint = detail.ticket.linearSyncUXState.reviewHint { + severity = .review + needsReview = true + badgeLabel = "Needs review" + title = "Review mergeable fields" + let checkpointClause = checkpointSummary.map { " \($0)" } ?? "" + message = "\(reviewHint) \(guidance)\(checkpointClause)" + .replacingOccurrences(of: " ", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } else { + severity = .clear + needsReview = false + badgeLabel = "Ready" + title = detail.ticket.linearSyncUXState.isLinkedToLinear ? "Sync ready" : "Local only" + let checkpointClause = checkpointSummary.map { " \($0)" } ?? "" + message = "\(guidance)\(checkpointClause)" + .replacingOccurrences(of: " ", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + self.severity = severity + self.needsReview = needsReview + self.badgeLabel = badgeLabel + self.title = title + self.message = message + commentCount = detail.comments.count + openConflictCount = openConflicts.count + resolvedConflictCount = resolvedConflicts.count + self.checkpointSummary = checkpointSummary + self.highlightedFields = highlightedFields + } + + private static func makeCheckpointSummary(_ checkpoint: TicketSyncCheckpoint?) -> String? { + guard let checkpoint else { + return nil + } + let direction = checkpoint.direction?.trimmingCharacters(in: .whitespacesAndNewlines) + let profile = checkpoint.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) + let provider = checkpoint.provider.capitalized + var segments: [String] = [] + if let direction, !direction.isEmpty { + segments.append(direction) + } + segments.append("via \(provider)") + var summary = "Last sync: \(segments.joined(separator: " "))" + if let profile, !profile.isEmpty { + summary += " (profile \(profile))" + } + summary += "." + return summary + } + + private static func makeHighlightedFields( + openConflicts: [TicketSyncConflict], + reviewHint: String? + ) -> [String] { + let fields = openConflicts.map(\.field) + if !fields.isEmpty { + return fields + } + guard reviewHint != nil else { + return [] + } + return ["assignee", "labels", "dependencies"] + } +} + private enum LinearSyncActor: String { case hack case linear @@ -2103,12 +2474,69 @@ public struct TicketsListResponse: Decodable { public struct TicketDetailResponse: Decodable { public let ticket: TicketSummary + public let comments: [TicketComment] + public let syncCheckpoints: [TicketSyncCheckpoint] + public let conflicts: [TicketSyncConflict] public let events: [TicketEvent] public init(ticket: TicketSummary, events: [TicketEvent]) { self.ticket = ticket + comments = [] + syncCheckpoints = [] + conflicts = [] + self.events = events + } + + public init( + ticket: TicketSummary, + comments: [TicketComment], + syncCheckpoints: [TicketSyncCheckpoint], + conflicts: [TicketSyncConflict], + events: [TicketEvent] + ) { + self.ticket = ticket + self.comments = comments + self.syncCheckpoints = syncCheckpoints + self.conflicts = conflicts self.events = events } + + private enum CodingKeys: String, CodingKey { + case ticket + case comments + case syncCheckpoints + case conflicts + case events + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + ticket = try container.decode(TicketSummary.self, forKey: .ticket) + comments = try container.decodeIfPresent([TicketComment].self, forKey: .comments) ?? [] + syncCheckpoints = try container.decodeIfPresent([TicketSyncCheckpoint].self, forKey: .syncCheckpoints) ?? [] + conflicts = try container.decodeIfPresent([TicketSyncConflict].self, forKey: .conflicts) ?? [] + events = try container.decodeIfPresent([TicketEvent].self, forKey: .events) ?? [] + } +} + +public extension TicketDetailResponse { + var latestSyncCheckpoint: TicketSyncCheckpoint? { + syncCheckpoints.max(by: { lhs, rhs in + lhs.createdAt.localizedCompare(rhs.createdAt) == .orderedAscending + }) + } + + var openSyncConflicts: [TicketSyncConflict] { + conflicts.filter { $0.status == .open } + } + + var resolvedSyncConflicts: [TicketSyncConflict] { + conflicts.filter { $0.status == .resolved } + } + + var linearSyncReviewState: TicketSyncReviewState { + TicketSyncReviewState(detail: self) + } } public struct TicketCreateResponse: Decodable { @@ -2141,6 +2569,25 @@ public struct TicketStatusResponse: Decodable { } } +public struct TicketConflictResolutionResponse: Decodable { + public let ok: Bool + public let ticketId: String + public let conflictId: String + public let resolution: TicketSyncConflictResolution + + public init( + ok: Bool, + ticketId: String, + conflictId: String, + resolution: TicketSyncConflictResolution + ) { + self.ok = ok + self.ticketId = ticketId + self.conflictId = conflictId + self.resolution = resolution + } +} + public struct TicketsSyncResponse: Decodable { public let sync: TicketsSyncResult diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift index e956cf817..55835e7f6 100644 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift @@ -74,7 +74,8 @@ final class LinearSyncUXModelTests: XCTestCase { source: String, externalSystem: String?, externalId: String?, - externalKey: String? + externalKey: String?, + assignee: String? = nil ) -> TicketSummary { TicketSummary( ticketId: "T-00001", @@ -87,6 +88,7 @@ final class LinearSyncUXModelTests: XCTestCase { blocks: [], owner: owner, source: source, + assignee: assignee, tags: ["linear"], externalSystem: externalSystem, externalId: externalId, diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift new file mode 100644 index 000000000..e72b5f0ff --- /dev/null +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift @@ -0,0 +1,287 @@ +import XCTest + +@testable import HackDesktopModels + +final class TicketDetailReviewModelTests: XCTestCase { + func testTicketDetailDecodesRicherSyncPayload() throws { + let data = Data( + #""" + { + "ticket": { + "ticketId": "T-00042", + "title": "Investigate sync drift", + "body": "Imported from Linear.", + "status": "in_progress", + "createdAt": "2026-03-05T16:00:00Z", + "updatedAt": "2026-03-05T17:00:00Z", + "dependsOn": ["T-00001"], + "blocks": ["T-00099"], + "owner": "hack", + "source": "hack", + "assignee": "alice@hack", + "tags": ["linear", "review"], + "externalSystem": "linear", + "externalId": "lin_issue_42", + "externalKey": "ENG-42", + "externalUrl": "https://linear.app/hack/issue/ENG-42", + "externalProjectId": "proj-42", + "externalProjectName": "Control Plane", + "externalTeamId": "eng", + "projectId": "local-project", + "projectName": "Hack CLI" + }, + "comments": [ + { + "commentId": "c-1", + "ticketId": "T-00042", + "body": "Imported from Linear.", + "source": "linear", + "actor": "linear@app", + "createdAt": "2026-03-05T16:10:00Z", + "externalId": "linear-comment-1", + "externalUrl": "https://linear.app/comment/1" + }, + { + "commentId": "c-2", + "ticketId": "T-00042", + "body": "Local follow-up.", + "source": "hack", + "actor": "alice@hack", + "createdAt": "2026-03-05T16:20:00Z" + } + ], + "syncCheckpoints": [ + { + "checkpointId": "cp-1", + "ticketId": "T-00042", + "provider": "linear", + "profileId": "default", + "direction": "pull", + "remoteCursor": "issue/ENG-42#v2", + "remoteUpdatedAt": "2026-03-05T16:59:00Z", + "localUpdatedAt": "2026-03-05T17:00:00Z", + "actor": "sync@app", + "createdAt": "2026-03-05T17:00:01Z" + } + ], + "conflicts": [ + { + "conflictId": "conflict-1", + "ticketId": "T-00042", + "provider": "linear", + "field": "assignee", + "status": "open", + "authority": "origin", + "summary": "Assignee diverged during pull.", + "localValue": "alice@hack", + "remoteValue": "bob@linear", + "createdAt": "2026-03-05T17:00:02Z", + "updatedAt": "2026-03-05T17:00:02Z" + }, + { + "conflictId": "conflict-2", + "ticketId": "T-00042", + "provider": "linear", + "field": "labels", + "status": "resolved", + "authority": "mergeable", + "summary": "Label translation reviewed.", + "localValue": ["infra", "review"], + "remoteValue": {"team": "Platform", "name": "infra"}, + "createdAt": "2026-03-05T16:40:00Z", + "updatedAt": "2026-03-05T16:50:00Z", + "resolution": "merged", + "resolutionSummary": "Kept both labels.", + "resolvedAt": "2026-03-05T16:50:00Z", + "resolvedBy": "alice@hack" + } + ], + "events": [ + { + "eventId": "event-1", + "ts": 1741190400, + "tsIso": "2026-03-05T16:00:00Z", + "actor": "creator@hack", + "ticketId": "T-00042", + "type": "ticket.created" + } + ] + } + """#.utf8 + ) + + let decoder = JSONDecoder() + let detail = try decoder.decode(TicketDetailResponse.self, from: data) + + XCTAssertEqual(detail.ticket.assignee, "alice@hack") + XCTAssertEqual(detail.comments.count, 2) + XCTAssertEqual(detail.syncCheckpoints.count, 1) + XCTAssertEqual(detail.conflicts.count, 2) + XCTAssertEqual(detail.latestSyncCheckpoint?.remoteCursor, "issue/ENG-42#v2") + XCTAssertEqual(detail.openSyncConflicts.count, 1) + XCTAssertEqual(detail.resolvedSyncConflicts.count, 1) + XCTAssertEqual(detail.conflicts[0].localValue?.displayText, "alice@hack") + XCTAssertEqual(detail.conflicts[1].remoteValue?.displayText, #"{"name":"infra","team":"Platform"}"#) + } + + func testReviewStatePrioritizesOpenConflictsOverOwnershipHint() { + let detail = makeDetail( + ticket: makeTicketSummary(owner: "linear", source: "hack", assignee: "alice@hack"), + comments: [ + TicketComment( + commentId: "c-1", + ticketId: "T-00042", + body: "Imported from Linear.", + source: "linear", + actor: "linear@app", + createdAt: "2026-03-05T16:10:00Z", + externalId: "comment-1", + externalUrl: nil + ) + ], + syncCheckpoints: [ + TicketSyncCheckpoint( + checkpointId: "cp-1", + ticketId: "T-00042", + provider: "linear", + profileId: "default", + direction: "pull", + remoteCursor: "issue/ENG-42#v2", + remoteUpdatedAt: nil, + localUpdatedAt: nil, + actor: "sync@app", + createdAt: "2026-03-05T17:00:01Z" + ) + ], + conflicts: [ + TicketSyncConflict( + conflictId: "conflict-1", + ticketId: "T-00042", + provider: "linear", + field: "assignee", + status: .open, + authority: "origin", + summary: "Assignee diverged during pull.", + localValue: .string("alice@hack"), + remoteValue: .string("bob@linear"), + createdAt: "2026-03-05T17:00:02Z", + updatedAt: "2026-03-05T17:00:02Z", + resolution: nil, + resolutionSummary: nil, + resolvedAt: nil, + resolvedBy: nil + ) + ] + ) + + let review = detail.linearSyncReviewState + + XCTAssertEqual(review.severity, .conflict) + XCTAssertTrue(review.needsReview) + XCTAssertEqual(review.badgeLabel, "1 open conflict") + XCTAssertEqual(review.highlightedFields, ["assignee"]) + XCTAssertTrue(review.message.contains("Comments stay append-only")) + XCTAssertTrue(review.message.contains("Last sync: pull via Linear")) + } + + func testReviewStateUsesOwnershipHintWhenConflictLedgerIsEmpty() { + let detail = makeDetail( + ticket: makeTicketSummary(owner: "linear", source: "hack", assignee: nil), + comments: [], + syncCheckpoints: [], + conflicts: [] + ) + + let review = detail.linearSyncReviewState + + XCTAssertEqual(review.severity, .review) + XCTAssertTrue(review.needsReview) + XCTAssertEqual(review.badgeLabel, "Needs review") + XCTAssertEqual(review.highlightedFields, ["assignee", "labels", "dependencies"]) + XCTAssertTrue(review.message.contains("Review assignee, labels, and dependencies before the next sync.")) + } + + func testReviewStateReportsCleanLinkedTicketWithCheckpointSummary() { + let detail = makeDetail( + ticket: makeTicketSummary(owner: "hack", source: "hack", assignee: "alice@hack"), + comments: [ + TicketComment( + commentId: "c-1", + ticketId: "T-00042", + body: "Local follow-up.", + source: "hack", + actor: "alice@hack", + createdAt: "2026-03-05T16:20:00Z", + externalId: nil, + externalUrl: nil + ) + ], + syncCheckpoints: [ + TicketSyncCheckpoint( + checkpointId: "cp-1", + ticketId: "T-00042", + provider: "linear", + profileId: "default", + direction: "push", + remoteCursor: "issue/ENG-42#v3", + remoteUpdatedAt: nil, + localUpdatedAt: nil, + actor: "sync@app", + createdAt: "2026-03-05T17:00:01Z" + ) + ], + conflicts: [] + ) + + let review = detail.linearSyncReviewState + + XCTAssertEqual(review.severity, .clear) + XCTAssertFalse(review.needsReview) + XCTAssertEqual(review.badgeLabel, "Ready") + XCTAssertEqual(review.commentCount, 1) + XCTAssertEqual(review.openConflictCount, 0) + XCTAssertEqual(review.resolvedConflictCount, 0) + XCTAssertEqual(review.checkpointSummary, "Last sync: push via Linear (profile default).") + } + + private func makeDetail( + ticket: TicketSummary, + comments: [TicketComment], + syncCheckpoints: [TicketSyncCheckpoint], + conflicts: [TicketSyncConflict] + ) -> TicketDetailResponse { + TicketDetailResponse( + ticket: ticket, + comments: comments, + syncCheckpoints: syncCheckpoints, + conflicts: conflicts, + events: [] + ) + } + + private func makeTicketSummary(owner: String, source: String, assignee: String?) -> TicketSummary { + TicketSummary( + ticketId: "T-00042", + title: "Investigate sync drift", + body: "Imported from Linear.", + status: .inProgress, + createdAt: "2026-03-05T16:00:00Z", + updatedAt: "2026-03-05T17:00:00Z", + dependsOn: ["T-00001"], + blocks: ["T-00099"], + owner: owner, + source: source, + assignee: assignee, + tags: ["linear", "review"], + externalSystem: "linear", + externalId: "lin_issue_42", + externalKey: "ENG-42", + externalUrl: "https://linear.app/hack/issue/ENG-42", + externalProjectId: "proj-42", + externalProjectName: "Control Plane", + externalTeamId: "eng", + projectId: "local-project", + projectName: "Hack CLI" + ) + } +} diff --git a/docs/plans/2026-03-05-linear-sync-phase-2-design.md b/docs/plans/2026-03-05-linear-sync-phase-2-design.md new file mode 100644 index 000000000..cb79b9420 --- /dev/null +++ b/docs/plans/2026-03-05-linear-sync-phase-2-design.md @@ -0,0 +1,155 @@ +# Linear Sync Phase 2 Design + +## Goal +Ship the next phase of the Linear integration with durable sync state, append-only comment sync, assignee mapping, webhook-driven pending updates, and user/team-aware server-side ownership. + +## Existing Foundation +- Tickets remain git-backed via `.hack/tickets` event logs. +- Manual Linear OAuth, profile routing, project binding, and manual sync already exist. +- Auth broker already has Better Auth plus organization/team primitives and a Neon/Postgres database. +- The macOS app already exposes global Linear settings, project routing, and ticket-level sync controls. + +## Decisions +- Keep ticket source-of-truth local and git-backed. +- Do not introduce SQLite in this phase. +- Use Postgres in the auth broker for remote webhook/event durability and access metadata. +- Keep manual sync as the default; webhook events should become pending work that local clients can apply. +- Continue origin-derived authority: + - `hack` owns title/body/status/project binding for hack-origin tickets. + - `linear` owns title/body/status/project binding for linear-origin tickets. +- Comments are append-only, immutable, FIFO. No edit/delete mirroring. +- Assignees, labels, dependencies, and sub-issue links remain mergeable best-effort fields. +- Ambiguous mergeable-field updates or dual-edited authoritative fields create durable review records instead of silent overwrite. + +## Sync Data Model +### Local ticket store +Extend local ticket data with: +- `assignee?: string` +- append-only ticket comments +- sync checkpoints per external system/profile +- durable sync conflict records +- durable conflict resolutions + +New local ticket event types: +- `ticket.comment_appended` +- `ticket.assignee_set` +- `ticket.sync_checkpointed` +- `ticket.sync_conflict_recorded` +- `ticket.sync_conflict_resolved` + +The ticket detail response should grow from `ticket + events` to: +- `ticket` +- `events` +- `comments` +- `conflicts` +- `syncState` + +### Broker database +Add durable broker tables for Linear sync infrastructure: +- `linear_connections` + - profile/account linkage bound to Better Auth user/org/team where available +- `linear_assignee_mappings` + - per profile/team local-user <-> Linear-user mappings +- `linear_webhook_events` + - verified inbound webhook deliveries with delivery/apply status +- `linear_sync_subscriptions` + - optional future autosync config per profile/project/team/user scope + +Broker ownership model: +- connection records may exist without an authenticated Better Auth user in early/dev flows +- when Better Auth is available, bind connections to `userId` and optionally `organizationId` / `teamId` +- provider discovery remains public, but mutating broker routes should be designed so they can later require session/user context without changing core data shapes + +## Access And Usage Direction +We are now shipping real server-backed integration state, so the design must stop assuming a single trusted operator forever. + +Phase 2 access model: +- Treat every remote Linear connection as a first-class broker record. +- Record who created or refreshed a connection when Better Auth is enabled. +- Preserve room for org/team scoping even if enforcement starts permissive. +- Keep CLI/macOS local use working when Better Auth is disabled locally. + +Practical rule set for this phase: +- Local ticket mutations remain local and repo-scoped. +- Broker webhook/event persistence is server-scoped. +- Broker records should store optional `betterAuthUserId`, `organizationId`, and `teamId` fields when known. +- Future enforcement can then answer: + - who owns this connection? + - which team/project may apply these pending events? + - who consumed/applicated a webhook delivery? + +We are not building full billing/quota enforcement in this phase, but we are adding the ownership fields needed for it. + +## Sync Mechanics +### Comments +- Pull from Linear: + - fetch issue comments + - append only comments not yet seen locally + - never rewrite or delete local comment history +- Push from Hack: + - append only local comments not yet mirrored to Linear + - use dedupe markers/cursors so the same comment is never reposted + +### Assignees +Resolution order: +1. explicit assignee mapping for the selected Linear profile/team +2. normalized email match +3. normalized display-name match +4. unresolved -> record conflict/review-needed and leave current assignee unchanged + +### Conflicts +Create durable conflict records when: +- both sides changed an origin-owned field since the last checkpoint +- assignee mapping is ambiguous +- dependency/sub-issue target cannot be resolved safely +- comment append target cannot be deduped confidently + +Conflict records should include: +- ticket id +- field/category (`title`, `body`, `status`, `project`, `assignee`, `dependency`, `comment`) +- local value summary +- remote value summary +- authority expectation +- detected at timestamp +- status (`open`, `resolved_hack`, `resolved_linear`, `ignored`) + +### Webhook flow +- Railway broker verifies and stores Linear webhook deliveries. +- Local CLI/macOS can fetch pending deliveries for the selected profile/project/team. +- Applying a delivery runs the same sync engine used by manual sync. +- Manual remains the default UX; autosync can later become a per-project subscription that simply auto-applies pending deliveries. + +## CLI / MCP / macOS Surfaces +### CLI +Add commands for: +- pending webhook event list/apply +- assignee mapping list/set/remove +- conflict list/show/resolve +- ticket comment append/list if needed for parity + +### MCP +Expose the new manual tools through MCP: +- list/apply pending Linear deliveries +- list/resolve sync conflicts +- manage assignee mappings + +### macOS +Add: +- ticket detail comments section +- ticket detail sync review/conflicts section +- tickets filter for `Needs review` +- settings section for assignee mappings and broker ownership diagnostics +- project-level pending webhook/apply controls + +## Testing +- TDD for every new sync primitive. +- ticket store tests for new event materialization +- broker tests for new DB-backed webhook queue semantics +- CLI tests for conflict/mapping/apply commands +- macOS model tests for conflict/review state derivation + +## Non-Goals +- No SQLite migration +- No comment edit/delete sync +- No fully automatic background daemon sync on day one +- No full RBAC or billing/quota enforcement in this phase diff --git a/docs/plans/2026-03-05-linear-sync-phase-2-plan.md b/docs/plans/2026-03-05-linear-sync-phase-2-plan.md new file mode 100644 index 000000000..ef9ead6df --- /dev/null +++ b/docs/plans/2026-03-05-linear-sync-phase-2-plan.md @@ -0,0 +1,260 @@ +# Linear Sync Phase 2 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add durable Linear sync state, append-only comment sync, assignee mapping, broker-side pending webhook storage, and macOS conflict review UI while keeping tickets git-backed. + +**Architecture:** Keep tickets in the existing local event log, add user-visible sync state as new local ticket events, and use the auth-broker Postgres database for remote webhook queueing and ownership metadata. CLI and macOS remain thin surfaces over the same sync/runtime primitives. + +**Tech Stack:** Bun, TypeScript, Drizzle/Neon Postgres, Better Auth, SwiftUI, Elysia. + +--- + +### Task 1: Add Shared Broker DB Schema For Linear Sync + +**Files:** +- Modify: `packages/db/src/schema/core.ts` +- Modify: `packages/db/src/client.ts` if exports need extension +- Test: `packages/db/tests/smoke.test.ts` + +**Step 1: Write failing schema tests** +- Assert the new Linear tables/exports exist and can be imported. + +**Step 2: Run test to verify it fails** +Run: `bun test --cwd packages/db` +Expected: FAIL because schema exports are missing. + +**Step 3: Add schema tables** +- `linear_connections` +- `linear_assignee_mappings` +- `linear_webhook_events` +- `linear_sync_subscriptions` + +**Step 4: Run package tests** +Run: `bun test --cwd packages/db` +Expected: PASS + +**Step 5: Commit** +```bash +git add packages/db/src/schema/core.ts packages/db/tests/smoke.test.ts +git commit -m "feat(db): add linear sync schema" +``` + +### Task 2: Add Broker DB Access Layer For Linear Sync State + +**Files:** +- Create: `services/auth-broker/src/modules/linear-sync-store/service.ts` +- Modify: `services/auth-broker/src/better-auth.ts` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write failing broker tests** +- store webhook delivery +- list pending deliveries +- mark delivery applied +- preserve optional Better Auth ownership columns + +**Step 2: Run broker tests to verify failure** +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: FAIL due to missing store/routes. + +**Step 3: Implement minimal DB-backed store helpers** +- read/write pending deliveries +- read/write assignee mappings +- bind optional user/org/team ownership metadata + +**Step 4: Re-run broker tests** +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: PASS + +**Step 5: Commit** +```bash +git add services/auth-broker/src/modules/linear-sync-store/service.ts services/auth-broker/tests/index.test.ts services/auth-broker/src/better-auth.ts +git commit -m "feat(auth-broker): add linear sync store" +``` + +### Task 3: Persist Verified Linear Webhooks Instead Of Only Acking Them + +**Files:** +- Modify: `services/auth-broker/src/modules/linear-agent/plugin.ts` +- Modify: `services/auth-broker/src/app.ts` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write failing tests** +- valid webhook inserts pending delivery row +- invalid signature still rejected +- response returns accepted delivery metadata + +**Step 2: Run tests to verify failure** +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: FAIL for missing persistence. + +**Step 3: Implement persistence path** +- parse relevant webhook metadata +- insert pending event row +- attach optional ownership/profile/team metadata when possible + +**Step 4: Re-run tests** +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: PASS + +**Step 5: Commit** +```bash +git add services/auth-broker/src/modules/linear-agent/plugin.ts services/auth-broker/src/app.ts services/auth-broker/tests/index.test.ts +git commit -m "feat(auth-broker): persist linear webhook deliveries" +``` + +### Task 4: Extend Ticket Store With Comments, Sync Checkpoints, And Conflicts + +**Files:** +- Modify: `src/control-plane/extensions/tickets/store.ts` +- Modify: `src/control-plane/extensions/tickets/commands.ts` +- Test: `tests/...` or add focused ticket-store tests + +**Step 1: Write failing tests** +- comment append materializes in ticket detail +- checkpoint event materializes sync state +- conflict record event materializes open conflict +- conflict resolution event updates conflict status + +**Step 2: Run focused tests to verify failure** +Expected: FAIL because store has no such event types/materialization. + +**Step 3: Implement new event types and materialization** +- `ticket.comment_appended` +- `ticket.assignee_set` +- `ticket.sync_checkpointed` +- `ticket.sync_conflict_recorded` +- `ticket.sync_conflict_resolved` + +**Step 4: Re-run tests** +Expected: PASS + +**Step 5: Commit** +```bash +git add src/control-plane/extensions/tickets/store.ts src/control-plane/extensions/tickets/commands.ts tests + git commit -m "feat(tickets): add sync comments and conflicts" +``` + +### Task 5: Add Linear Assignee Mapping And Comment Sync Primitives + +**Files:** +- Modify: `src/control-plane/extensions/linear/client.ts` +- Modify: `src/control-plane/extensions/linear/commands.ts` +- Create/Modify tests: `tests/linear-commands.test.ts`, `tests/linear-auth.test.ts` + +**Step 1: Write failing tests** +- explicit assignee mapping wins +- unresolved assignee creates conflict instead of overwrite +- new remote comments append locally once +- local comments push once with dedupe + +**Step 2: Run tests to verify failure** +Run: `bun test tests/linear-commands.test.ts tests/linear-auth.test.ts` +Expected: FAIL + +**Step 3: Implement minimal sync primitives** +- Linear comment fetch/create client helpers +- assignee mapping resolver +- checkpoint-based conflict detection +- append-only comment sync in both directions + +**Step 4: Re-run tests** +Expected: PASS + +**Step 5: Commit** +```bash +git add src/control-plane/extensions/linear/client.ts src/control-plane/extensions/linear/commands.ts tests/linear-commands.test.ts tests/linear-auth.test.ts +git commit -m "feat(linear): add assignee mapping and comment sync" +``` + +### Task 6: Add Broker Pending-Event Apply Commands And MCP Tools + +**Files:** +- Modify: `src/control-plane/extensions/linear/commands.ts` +- Modify: `src/mcp/server.ts` +- Modify: `apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift` +- Test: `tests/mcp.test.ts`, `tests/linear-commands.test.ts` + +**Step 1: Write failing tests** +- list pending deliveries +- apply pending deliveries for routed project/profile +- MCP tools call the right CLI commands + +**Step 2: Run tests to verify failure** +Expected: FAIL + +**Step 3: Implement commands/tools** +- `hack linear pending list` +- `hack linear pending apply` +- optional conflict/mapping commands in same surface + +**Step 4: Re-run tests** +Expected: PASS + +**Step 5: Commit** +```bash +git add src/control-plane/extensions/linear/commands.ts src/mcp/server.ts apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift tests/mcp.test.ts tests/linear-commands.test.ts +git commit -m "feat(linear): add pending delivery apply tools" +``` + +### Task 7: Add macOS Review UI For Comments And Conflicts + +**Files:** +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` +- Test: `apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/...` + +**Step 1: Write failing model/UI tests** +- conflict badge and needs-review filter +- comments decode and display model +- assignee mapping/settings state + +**Step 2: Run tests to verify failure** +Run: `swift test --package-path apps/macos` +Expected: FAIL in focused new tests. + +**Step 3: Implement minimal UI** +- comments section in ticket detail +- conflicts section with resolve actions +- needs-review filter +- settings diagnostics for mappings and pending deliveries + +**Step 4: Re-run tests/build** +Run: `swift test --package-path apps/macos && swift build --package-path apps/macos` +Expected: PASS + +**Step 5: Commit** +```bash +git add apps/macos/Packages/Shared/Models apps/macos/Packages/Features/DashboardFeature +git commit -m "feat(macos): add linear sync review ui" +``` + +### Task 8: End-to-End Verification And Docs + +**Files:** +- Modify: `docs/extensions.md` +- Modify: `docs/cli.md` +- Modify: `services/auth-broker/README.md` + +**Step 1: Update docs for new sync/apply/review flow** +- pending webhook apply +- assignee mappings +- conflicts/comments behavior +- user/team ownership notes + +**Step 2: Run full verification** +```bash +bun run build +bun run typecheck +bun run test +bun run check +hack ps +``` +Expected: all PASS + +**Step 3: Commit** +```bash +git add docs/extensions.md docs/cli.md services/auth-broker/README.md +git commit -m "docs: cover linear sync phase 2" +``` diff --git a/packages/db/src/schema/core.ts b/packages/db/src/schema/core.ts index 659b1d3af..b2d9b59db 100644 --- a/packages/db/src/schema/core.ts +++ b/packages/db/src/schema/core.ts @@ -52,3 +52,103 @@ export const githubConnections = pgTable("github_connections", { .notNull() .defaultNow(), }); + +/** + * Durable Linear connection ownership records for future access control, + * routing diagnostics, and usage attribution. + */ +export const linearConnections = pgTable("linear_connections", { + id: uuid("id").defaultRandom().primaryKey(), + connectionKey: text("connection_key").notNull().unique(), + profileId: text("profile_id"), + accountId: text("account_id"), + accountName: text("account_name"), + accountEmail: text("account_email"), + authRef: text("auth_ref"), + betterAuthUserId: text("better_auth_user_id"), + organizationId: text("organization_id"), + teamId: text("team_id"), + metadataJson: text("metadata_json").notNull().default("{}"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +/** + * Explicit local-user <-> Linear-user mappings used during assignee sync. + */ +export const linearAssigneeMappings = pgTable("linear_assignee_mappings", { + id: uuid("id").defaultRandom().primaryKey(), + mappingKey: text("mapping_key").notNull().unique(), + profileId: text("profile_id").notNull(), + linearTeamId: text("linear_team_id"), + localAssignee: text("local_assignee").notNull(), + linearUserId: text("linear_user_id"), + linearUserName: text("linear_user_name"), + linearUserEmail: text("linear_user_email"), + betterAuthUserId: text("better_auth_user_id"), + organizationId: text("organization_id"), + teamId: text("team_id"), + metadataJson: text("metadata_json").notNull().default("{}"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +/** + * Verified Linear webhook deliveries waiting to be applied by a local client. + */ +export const linearWebhookEvents = pgTable("linear_webhook_events", { + id: uuid("id").defaultRandom().primaryKey(), + deliveryKey: text("delivery_key").notNull().unique(), + profileId: text("profile_id"), + projectId: text("project_id"), + teamId: text("team_id"), + issueId: text("issue_id"), + issueIdentifier: text("issue_identifier"), + eventType: text("event_type"), + action: text("action"), + status: text("status").notNull().default("pending"), + payloadJson: text("payload_json").notNull().default("{}"), + applyError: text("apply_error"), + claimedBy: text("claimed_by"), + betterAuthUserId: text("better_auth_user_id"), + organizationId: text("organization_id"), + ownerTeamId: text("owner_team_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + appliedAt: timestamp("applied_at", { withTimezone: true }), +}); + +/** + * Future opt-in autosync subscriptions keyed by connection/project/team scope. + */ +export const linearSyncSubscriptions = pgTable("linear_sync_subscriptions", { + id: uuid("id").defaultRandom().primaryKey(), + subscriptionKey: text("subscription_key").notNull().unique(), + profileId: text("profile_id").notNull(), + projectId: text("project_id"), + teamId: text("team_id"), + mode: text("mode").notNull().default("manual"), + status: text("status").notNull().default("active"), + betterAuthUserId: text("better_auth_user_id"), + organizationId: text("organization_id"), + ownerTeamId: text("owner_team_id"), + configJson: text("config_json").notNull().default("{}"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); diff --git a/packages/db/tests/smoke.test.ts b/packages/db/tests/smoke.test.ts index ef7a5d324..7ebcc36b0 100644 --- a/packages/db/tests/smoke.test.ts +++ b/packages/db/tests/smoke.test.ts @@ -1,7 +1,32 @@ import { expect, test } from "bun:test"; import { createDbClient } from "../src/client.ts"; +import { + githubConnections, + linearAssigneeMappings, + linearConnections, + linearSyncSubscriptions, + linearWebhookEvents, +} from "../src/schema/core.ts"; test("db package exports createDbClient", () => { expect(typeof createDbClient).toBe("function"); }); + +test("db package exports linear sync schema tables", () => { + expect(linearConnections[Symbol.for("drizzle:Name")]).toBe( + "linear_connections" + ); + expect(linearAssigneeMappings[Symbol.for("drizzle:Name")]).toBe( + "linear_assignee_mappings" + ); + expect(linearWebhookEvents[Symbol.for("drizzle:Name")]).toBe( + "linear_webhook_events" + ); + expect(linearSyncSubscriptions[Symbol.for("drizzle:Name")]).toBe( + "linear_sync_subscriptions" + ); + expect(githubConnections[Symbol.for("drizzle:Name")]).toBe( + "github_connections" + ); +}); diff --git a/services/auth-broker/.env.example b/services/auth-broker/.env.example index 3f521aa46..781ba4c7b 100644 --- a/services/auth-broker/.env.example +++ b/services/auth-broker/.env.example @@ -41,3 +41,5 @@ NEON_PROJECT_ID= BETTER_AUTH_SECRET= # Optional: auto-provision Better Auth users from GitHub email during callback. BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS=false +# Optional: auto-provision Better Auth users from Linear email during callback. +BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS=false diff --git a/services/auth-broker/README.md b/services/auth-broker/README.md index ae602a599..d4cda5590 100644 --- a/services/auth-broker/README.md +++ b/services/auth-broker/README.md @@ -97,18 +97,19 @@ so desktop users can return focus to the app immediately after browser auth. 16. `BETTER_AUTH_URL` (optional base URL override) 17. `BETTER_AUTH_TRUSTED_ORIGINS` (optional comma-separated origins) 18. `BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from GitHub email if no match exists) -19. `HACK_LINEAR_CLIENT_ID` (recommended Linear OAuth client id) -20. `HACK_LINEAR_SECRET` (optional Linear OAuth client secret; PKCE can run without it) -21. `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional app token for agent/system automations) -22. `HACK_LINEAR_WEBHOOK_SECRET` (recommended Linear webhook signing secret) -23. `HACK_LINEAR_SCOPES` (optional; default: `read,write,app:mentionable,app:assignable`) -24. `HACK_LINEAR_OAUTH_ACTOR` (optional; default: `app` for Linear agent/app installs) -25. `HACK_LINEAR_REDIRECT_URI` (default: `${AUTH_BROKER_PUBLIC_BASE_URL}/linear/callback`) -26. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) -27. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) -28. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) -29. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) -30. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) +19. `BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from Linear email if no match exists) +20. `HACK_LINEAR_CLIENT_ID` (recommended Linear OAuth client id) +21. `HACK_LINEAR_SECRET` (optional Linear OAuth client secret; PKCE can run without it) +22. `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional app token for agent/system automations) +23. `HACK_LINEAR_WEBHOOK_SECRET` (recommended Linear webhook signing secret) +24. `HACK_LINEAR_SCOPES` (optional; default: `read,write,app:mentionable,app:assignable`) +25. `HACK_LINEAR_OAUTH_ACTOR` (optional; default: `app` for Linear agent/app installs) +26. `HACK_LINEAR_REDIRECT_URI` (default: `${AUTH_BROKER_PUBLIC_BASE_URL}/linear/callback`) +27. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) +28. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) +29. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) +30. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) +31. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) When running broker from repo root (`bun run auth:dev`), Linear env aliases also fallback to root `.env.local` / `.env` if process env values are unset. diff --git a/services/auth-broker/src/app.ts b/services/auth-broker/src/app.ts index c9ddb85a3..e2f542a05 100644 --- a/services/auth-broker/src/app.ts +++ b/services/auth-broker/src/app.ts @@ -10,7 +10,19 @@ import { createBetterAuthPlugin } from "./modules/better-auth/plugin.ts"; import { createCoreRoutesPlugin } from "./modules/core/plugin.ts"; import { createGitHubOAuthPlugin } from "./modules/github-oauth/plugin.ts"; import { createLinearAgentPlugin } from "./modules/linear-agent/plugin.ts"; +import { createLinearConnectionsPlugin } from "./modules/linear-connections/plugin.ts"; +import { + createLinearConnectionStoreFromDb, + InMemoryLinearConnectionStore, + type LinearConnectionStore, +} from "./modules/linear-connections/service.ts"; import { createLinearOAuthPlugin } from "./modules/linear-oauth/plugin.ts"; +import { createLinearSyncStorePlugin } from "./modules/linear-sync-store/plugin.ts"; +import { + createLinearSyncStoreFromDb, + InMemoryLinearSyncStore, + type LinearSyncStore, +} from "./modules/linear-sync-store/service.ts"; import { createProvidersPlugin } from "./modules/providers/plugin.ts"; import { createSharedMiddlewarePlugin } from "./plugins/shared-middleware.ts"; @@ -18,6 +30,8 @@ export type CreateAuthBrokerAppOptions = { readonly config: BrokerConfig; readonly flowStore?: FlowStore; readonly betterAuthRuntime?: BetterAuthRuntime; + readonly linearSyncStore?: LinearSyncStore; + readonly linearConnectionStore?: LinearConnectionStore; }; /** @@ -27,11 +41,17 @@ export function createAuthBrokerApp({ config, flowStore: externalStore, betterAuthRuntime: externalBetterAuthRuntime, + linearSyncStore: externalLinearSyncStore, + linearConnectionStore: externalLinearConnectionStore, }: CreateAuthBrokerAppOptions) { const flowStore = externalStore ?? new FlowStore({ filePath: config.flowStorePath }); const betterAuthRuntime = externalBetterAuthRuntime ?? createBetterAuthRuntimeFromEnv(); + const linearSyncStore = + externalLinearSyncStore ?? createDefaultLinearSyncStore(); + const linearConnectionStore = + externalLinearConnectionStore ?? createDefaultLinearConnectionStore(); let flowSweepTimer: ReturnType | null = null; return new Elysia({ @@ -77,12 +97,26 @@ export function createAuthBrokerApp({ .use( createLinearAgentPlugin({ config, + syncStore: linearSyncStore, + connectionStore: linearConnectionStore, + }) + ) + .use( + createLinearConnectionsPlugin({ + connectionStore: linearConnectionStore, + }) + ) + .use( + createLinearSyncStorePlugin({ + syncStore: linearSyncStore, }) ) .use( createLinearOAuthPlugin({ config, flowStore, + connectionStore: linearConnectionStore, + betterAuthRuntime, }) ) .onStart(() => { @@ -101,6 +135,32 @@ export function createAuthBrokerApp({ }); } +function createDefaultLinearSyncStore(): LinearSyncStore { + if (!process.env.DATABASE_URL) { + return new InMemoryLinearSyncStore(); + } + try { + return createLinearSyncStoreFromDb({ + databaseUrl: process.env.DATABASE_URL, + }); + } catch { + return new InMemoryLinearSyncStore(); + } +} + +function createDefaultLinearConnectionStore(): LinearConnectionStore { + if (!process.env.DATABASE_URL) { + return new InMemoryLinearConnectionStore(); + } + try { + return createLinearConnectionStoreFromDb({ + databaseUrl: process.env.DATABASE_URL, + }); + } catch { + return new InMemoryLinearConnectionStore(); + } +} + /** * Identify route paths that are intentionally GET-only in broker v1. */ @@ -123,6 +183,12 @@ function isReadOnlyRoutePath(input: { readonly path: string }): boolean { if (input.path === "/v1/auth/linear/refresh") { return false; } + if ( + input.path.startsWith("/v1/auth/linear/deliveries/") && + input.path.endsWith("/apply") + ) { + return false; + } if (input.path.startsWith("/gh/")) { return true; } diff --git a/services/auth-broker/src/better-auth-link.ts b/services/auth-broker/src/better-auth-link.ts index 0662ad9cc..9d3463187 100644 --- a/services/auth-broker/src/better-auth-link.ts +++ b/services/auth-broker/src/better-auth-link.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { sql } from "drizzle-orm"; import type { BetterAuthRuntime } from "./better-auth.ts"; -import type { GitHubFlowAccount } from "./types.ts"; +import type { OAuthFlowAccount } from "./types.ts"; export type BetterAuthUserLinkState = | "disabled" @@ -29,8 +29,35 @@ export type BetterAuthUserLinkResult = { */ export async function resolveBetterAuthUserFromGitHubAccount(input: { readonly runtime: BetterAuthRuntime; - readonly account: GitHubFlowAccount; + readonly account: OAuthFlowAccount; readonly autoProvision: boolean; +}): Promise { + return await resolveBetterAuthUserFromOAuthAccount({ + runtime: input.runtime, + account: input.account, + autoProvision: input.autoProvision, + providerId: "github", + }); +} + +export async function resolveBetterAuthUserFromLinearAccount(input: { + readonly runtime: BetterAuthRuntime; + readonly account: OAuthFlowAccount; + readonly autoProvision: boolean; +}): Promise { + return await resolveBetterAuthUserFromOAuthAccount({ + runtime: input.runtime, + account: input.account, + autoProvision: input.autoProvision, + providerId: "linear", + }); +} + +async function resolveBetterAuthUserFromOAuthAccount(input: { + readonly runtime: BetterAuthRuntime; + readonly account: OAuthFlowAccount; + readonly autoProvision: boolean; + readonly providerId: string; }): Promise { if (!hasEnabledBetterAuthDb(input.runtime)) { return { state: "disabled" }; @@ -45,8 +72,9 @@ export async function resolveBetterAuthUserFromGitHubAccount(input: { try { if (accountId) { - const linkedUserId = await findUserIdByGitHubAccountId({ + const linkedUserId = await findUserIdByProviderAccountId({ runtime, + providerId: input.providerId, accountId, }); if (linkedUserId) { @@ -93,12 +121,13 @@ async function findUserIdByGitHubAccountId(input: { readonly runtime: BetterAuthRuntime & { readonly db: NonNullable; }; + readonly providerId: string; readonly accountId: string; }): Promise { const result = await input.runtime.db.execute( sql`select "userId" as "userId" from "account" - where "providerId" = 'github' + where "providerId" = ${input.providerId} and "accountId" = ${input.accountId} order by "createdAt" desc limit 1` @@ -109,6 +138,16 @@ async function findUserIdByGitHubAccountId(input: { }); } +async function findUserIdByProviderAccountId(input: { + readonly runtime: BetterAuthRuntime & { + readonly db: NonNullable; + }; + readonly providerId: string; + readonly accountId: string; +}): Promise { + return await findUserIdByGitHubAccountId(input); +} + async function findUserIdByEmail(input: { readonly runtime: BetterAuthRuntime & { readonly db: NonNullable; diff --git a/services/auth-broker/src/config.ts b/services/auth-broker/src/config.ts index 059c73f48..56b075cab 100644 --- a/services/auth-broker/src/config.ts +++ b/services/auth-broker/src/config.ts @@ -17,6 +17,7 @@ export type BrokerConfig = { readonly githubApiBaseUrl: string; readonly githubRedirectUri: string; readonly betterAuthGitHubAutoProvisionUsers: boolean; + readonly betterAuthLinearAutoProvisionUsers: boolean; readonly linearClientId?: string; readonly linearClientSecret?: string; readonly linearDeveloperAppToken?: string; @@ -81,6 +82,9 @@ export function resolveConfig(): BrokerConfig { betterAuthGitHubAutoProvisionUsers: parseBoolean(process.env.BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS) ?? false, + betterAuthLinearAutoProvisionUsers: + parseBoolean(process.env.BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS) ?? + false, ...linear, flowTtlMs: parsePositiveInt(process.env.FLOW_TTL_MS) ?? DEFAULT_FLOW_TTL_MS, flowSweepIntervalMs: diff --git a/services/auth-broker/src/flow-store.ts b/services/auth-broker/src/flow-store.ts index e0d8e23b1..979206618 100644 --- a/services/auth-broker/src/flow-store.ts +++ b/services/auth-broker/src/flow-store.ts @@ -277,6 +277,13 @@ function toPublicStatus(flow: OAuthFlow): OAuthFlowPublicStatus { ...(flow.account?.accountEmail ? { accountEmail: flow.account.accountEmail } : {}), + ...(flow.account?.organizationId + ? { organizationId: flow.account.organizationId } + : {}), + ...(flow.account?.organizationName + ? { organizationName: flow.account.organizationName } + : {}), + ...(flow.account?.teamIds ? { teamIds: flow.account.teamIds } : {}), ...(flow.account?.betterAuthUserId ? { betterAuthUserId: flow.account.betterAuthUserId } : {}), @@ -429,6 +436,14 @@ function parsePersistedFlowAccount(input: { const accountName = asOptionalString(input.value.accountName); const accountId = asOptionalString(input.value.accountId); const accountEmail = asOptionalString(input.value.accountEmail); + const organizationId = asOptionalString(input.value.organizationId); + const organizationName = asOptionalString(input.value.organizationName); + const teamIds = Array.isArray(input.value.teamIds) + ? input.value.teamIds.filter( + (entry): entry is string => + typeof entry === "string" && entry.trim().length > 0 + ) + : []; const betterAuthUserId = asOptionalString(input.value.betterAuthUserId); const betterAuthLinkState = asOptionalString(input.value.betterAuthLinkState); if ( @@ -438,6 +453,9 @@ function parsePersistedFlowAccount(input: { accountName || accountId || accountEmail || + organizationId || + organizationName || + teamIds.length > 0 || installationIds.length > 0 || betterAuthUserId || betterAuthLinkState @@ -452,6 +470,9 @@ function parsePersistedFlowAccount(input: { ...(accountName ? { accountName } : {}), ...(accountId ? { accountId } : {}), ...(accountEmail ? { accountEmail } : {}), + ...(organizationId ? { organizationId } : {}), + ...(organizationName ? { organizationName } : {}), + ...(teamIds.length > 0 ? { teamIds } : {}), ...(betterAuthUserId ? { betterAuthUserId } : {}), ...(betterAuthLinkState ? { diff --git a/services/auth-broker/src/linear.ts b/services/auth-broker/src/linear.ts index 211946e23..3d16c0c35 100644 --- a/services/auth-broker/src/linear.ts +++ b/services/auth-broker/src/linear.ts @@ -1,6 +1,26 @@ import type { OAuthFlowAccount } from "./types.ts"; const USER_AGENT = "hack-auth-broker"; +const LINEAR_VIEWER_QUERY = [ + "query LinearViewer {", + " viewer {", + " id", + " name", + " email", + " displayName", + " organization {", + " id", + " name", + " }", + " teams(first: 50) {", + " nodes {", + " id", + " name", + " }", + " }", + " }", + "}", +].join("\n"); type LinearTokenExchangeResult = | { @@ -175,70 +195,126 @@ export async function fetchIdentity(input: { "user-agent": USER_AGENT, }, body: JSON.stringify({ - query: [ - "query LinearViewer {", - " viewer {", - " id", - " name", - " email", - " displayName", - " }", - "}", - ].join("\n"), + query: LINEAR_VIEWER_QUERY, }), }); - const payload = (await response.json().catch(() => null)) as unknown; - if (!isRecord(payload)) { - return { - ok: false, - statusCode: response.status, - error: `identity_lookup_failed: invalid payload (${response.status})`, - }; + const payload = await readJsonRecord({ + response, + errorPrefix: "identity_lookup_failed", + }); + if (!payload.ok) { + return payload; } - const errors = Array.isArray(payload.errors) - ? payload.errors - .map((entry) => - isRecord(entry) ? readOptionalString(entry.message) : null - ) - .filter((entry): entry is string => Boolean(entry)) - : []; - if (!response.ok || errors.length > 0) { + const responseError = readGraphqlResponseError({ + payload: payload.value, + response, + }); + if (responseError) { + return responseError; + } + const account = buildIdentityAccount({ payload: payload.value }); + if (!account) { return { ok: false, - statusCode: response.status, - error: errors[0] ?? response.statusText, + statusCode: 502, + error: "identity_lookup_failed: missing viewer.id", }; } + return { ok: true, account }; +} - const viewer = - isRecord(payload.data) && isRecord(payload.data.viewer) - ? payload.data.viewer - : null; - const accountId = viewer ? readOptionalString(viewer.id) : null; +function buildIdentityAccount(input: { + readonly payload: Record; +}): OAuthFlowAccount | null { + const viewer = readViewerRecord({ payload: input.payload }); + const accountId = readOptionalString(viewer?.id); if (!accountId) { + return null; + } + const accountName = + readOptionalString(viewer?.displayName) ?? + readOptionalString(viewer?.name) ?? + undefined; + const accountEmail = readOptionalString(viewer?.email) ?? undefined; + const organization = readRecord(viewer?.organization); + const organizationId = readOptionalString(organization?.id) ?? undefined; + const organizationName = readOptionalString(organization?.name) ?? undefined; + const teamIds = readTeamIds({ viewer }); + return { + accountId, + ...(accountName ? { accountName, accountHandle: accountName } : {}), + ...(accountEmail ? { accountEmail } : {}), + ...(organizationId ? { organizationId } : {}), + ...(organizationName ? { organizationName } : {}), + ...(teamIds.length > 0 ? { teamIds } : {}), + }; +} + +async function readJsonRecord(input: { + readonly response: Response; + readonly errorPrefix: string; +}): Promise< + | { readonly ok: true; readonly value: Record } + | { readonly ok: false; readonly error: string; readonly statusCode: number } +> { + const payload = (await input.response.json().catch(() => null)) as unknown; + if (!isRecord(payload)) { return { ok: false, - statusCode: 502, - error: "identity_lookup_failed: missing viewer.id", + statusCode: input.response.status, + error: `${input.errorPrefix}: invalid payload (${input.response.status})`, }; } - const accountName = - (viewer && readOptionalString(viewer.displayName)) ?? - (viewer && readOptionalString(viewer.name)) ?? - undefined; - const accountEmail = - (viewer && readOptionalString(viewer.email)) ?? undefined; return { ok: true, - account: { - accountId, - ...(accountName ? { accountName } : {}), - ...(accountEmail ? { accountEmail } : {}), - ...(accountName ? { accountHandle: accountName } : {}), - }, + value: payload, }; } +function readGraphqlResponseError(input: { + readonly payload: Record; + readonly response: Response; +}): { + readonly ok: false; + readonly error: string; + readonly statusCode: number; +} | null { + const errors = Array.isArray(input.payload.errors) + ? input.payload.errors + .map((entry) => readOptionalString(readRecord(entry)?.message)) + .filter((entry): entry is string => Boolean(entry)) + : []; + if (input.response.ok && errors.length === 0) { + return null; + } + return { + ok: false, + statusCode: input.response.status, + error: errors[0] ?? input.response.statusText, + }; +} + +function readViewerRecord(input: { + readonly payload: Record; +}): Record | null { + const data = readRecord(input.payload.data); + return readRecord(data?.viewer); +} + +function readTeamIds(input: { + readonly viewer: Record | null; +}): string[] { + const teams = readRecord(input.viewer?.teams); + const nodes = Array.isArray(teams?.nodes) ? teams.nodes : []; + return nodes + .map((entry) => readOptionalString(readRecord(entry)?.id)) + .filter((entry): entry is string => Boolean(entry)); +} + +function readRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + function readOptionalString(value: unknown): string | null { if (typeof value === "number" && Number.isFinite(value)) { return String(value); diff --git a/services/auth-broker/src/modules/linear-agent/plugin.ts b/services/auth-broker/src/modules/linear-agent/plugin.ts index 83f8d4e30..e60c16840 100644 --- a/services/auth-broker/src/modules/linear-agent/plugin.ts +++ b/services/auth-broker/src/modules/linear-agent/plugin.ts @@ -3,6 +3,8 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { Elysia } from "elysia"; import type { BrokerConfig } from "../../config.ts"; +import type { LinearConnectionStore } from "../linear-connections/service.ts"; +import type { LinearSyncStore } from "../linear-sync-store/service.ts"; const MAX_LINEAR_WEBHOOK_SKEW_MS = 60_000; const SHA256_PREFIX = "sha256="; @@ -11,12 +13,48 @@ const LEGACY_LINEAR_WEBHOOK_PATH = "/v1/integrations/linear/webhook"; type CreateLinearAgentPluginOptions = { readonly config: BrokerConfig; + readonly syncStore: LinearSyncStore; + readonly connectionStore: LinearConnectionStore; }; type LinearWebhookEvent = { readonly action?: string; readonly type?: string; readonly webhookTimestamp?: string; + readonly issueId?: string; + readonly issueIdentifier?: string; + readonly projectId?: string; + readonly teamId?: string; + readonly profileId?: string; + readonly organizationId?: string; +}; + +type LinearWebhookRouteResult = { + readonly statusCode: number; + readonly body: + | { + readonly ok: true; + readonly accepted: true; + readonly provider: "linear"; + readonly deliveryId: string; + readonly deliveryStatus: string; + readonly signatureVerified: boolean; + readonly eventType: string | null; + readonly action: string | null; + readonly webhookTimestamp: string | null; + } + | { + readonly ok: false; + readonly error: string; + }; +}; + +type PreparedWebhookRequest = { + readonly requestPath: string; + readonly rawBody: string; + readonly payload: unknown; + readonly event: LinearWebhookEvent; + readonly signatureVerified: boolean; }; /** @@ -27,6 +65,8 @@ type LinearWebhookEvent = { */ export function createLinearAgentPlugin({ config, + syncStore, + connectionStore, }: CreateLinearAgentPluginOptions) { const app = new Elysia({ name: "hack-auth-broker.linear-agent", @@ -36,66 +76,143 @@ export function createLinearAgentPlugin({ configuredPath: config.linearWebhookPath, })) { app.post(path, async ({ request, set }) => { - const rawBody = await request.text(); - const signatureHeader = request.headers.get("linear-signature"); - - const signature = verifyLinearWebhookSignature({ - rawBody, - signatureHeader, - secret: config.linearWebhookSigningSecret, + const result = await handleLinearWebhookRequest({ + request, + config, + syncStore, + connectionStore, }); - if (!signature.ok) { - set.status = signature.statusCode; - return { - ok: false, - error: signature.error, - } as const; - } - - const payload = parseJson({ raw: rawBody }); - if (!payload.ok) { - set.status = 400; - return { - ok: false, - error: "invalid_json", - } as const; - } + set.status = result.statusCode; + return result.body; + }); + } + return app; +} - const parsedEvent = parseLinearWebhookEvent({ payload: payload.value }); - if (!parsedEvent.ok) { - set.status = 400; - return { - ok: false, - error: parsedEvent.error, - } as const; - } +async function handleLinearWebhookRequest(input: { + readonly request: Request; + readonly config: BrokerConfig; + readonly syncStore: LinearSyncStore; + readonly connectionStore: LinearConnectionStore; +}): Promise { + const prepared = await prepareWebhookRequest({ + request: input.request, + secret: input.config.linearWebhookSigningSecret, + }); + if (!prepared.ok) { + return { + statusCode: prepared.statusCode, + body: { + ok: false, + error: prepared.error, + }, + }; + } + return await persistWebhookDelivery({ + prepared: prepared.value, + syncStore: input.syncStore, + connectionStore: input.connectionStore, + }); +} - const replayCheck = validateWebhookTimestamp({ - event: parsedEvent.event, - requireTimestamp: signature.verified, - nowMs: Date.now(), - }); - if (!replayCheck.ok) { - set.status = replayCheck.statusCode; - return { - ok: false, - error: replayCheck.error, - } as const; - } +async function prepareWebhookRequest(input: { + readonly request: Request; + readonly secret?: string; +}): Promise< + | { readonly ok: true; readonly value: PreparedWebhookRequest } + | { readonly ok: false; readonly statusCode: number; readonly error: string } +> { + const rawBody = await input.request.text(); + const signature = verifyLinearWebhookSignature({ + rawBody, + signatureHeader: input.request.headers.get("linear-signature"), + secret: input.secret, + }); + if (!signature.ok) { + return signature; + } + const payload = parseJson({ raw: rawBody }); + if (!payload.ok) { + return { ok: false, statusCode: 400, error: "invalid_json" }; + } + const event = parseLinearWebhookEvent({ payload: payload.value }); + if (!event.ok) { + return { ok: false, statusCode: 400, error: event.error }; + } + const replayCheck = validateWebhookTimestamp({ + event: event.event, + requireTimestamp: signature.verified, + nowMs: Date.now(), + }); + if (!replayCheck.ok) { + return replayCheck; + } + return { + ok: true, + value: { + requestPath: new URL(input.request.url).pathname, + rawBody, + payload: payload.value, + event: event.event, + signatureVerified: signature.verified, + }, + }; +} - set.status = 202; - return { +async function persistWebhookDelivery(input: { + readonly prepared: PreparedWebhookRequest; + readonly syncStore: LinearSyncStore; + readonly connectionStore: LinearConnectionStore; +}): Promise { + try { + const ownership = await input.connectionStore.resolveWebhookOwnership({ + profileId: input.prepared.event.profileId ?? null, + organizationId: input.prepared.event.organizationId ?? null, + }); + const delivery = await input.syncStore.recordWebhookDelivery({ + path: input.prepared.requestPath, + rawBody: input.prepared.rawBody, + payloadJson: input.prepared.payload, + signatureVerified: input.prepared.signatureVerified, + eventType: input.prepared.event.type ?? null, + action: input.prepared.event.action ?? null, + webhookTimestamp: input.prepared.event.webhookTimestamp ?? null, + profileId: + ownership.status === "matched" + ? ownership.profileId + : (input.prepared.event.profileId ?? null), + projectId: input.prepared.event.projectId ?? null, + issueId: input.prepared.event.issueId ?? null, + issueIdentifier: input.prepared.event.issueIdentifier ?? null, + betterAuthUserId: + ownership.status === "matched" ? ownership.betterAuthUserId : null, + organizationId: + input.prepared.event.organizationId ?? ownership.organizationId ?? null, + teamId: input.prepared.event.teamId ?? ownership.teamId ?? null, + }); + return { + statusCode: 202, + body: { ok: true, accepted: true, provider: "linear", - signatureVerified: signature.verified, - eventType: parsedEvent.event.type ?? null, - action: parsedEvent.event.action ?? null, - webhookTimestamp: parsedEvent.event.webhookTimestamp ?? null, - } as const; - }); + deliveryId: delivery.id, + deliveryStatus: delivery.status, + signatureVerified: input.prepared.signatureVerified, + eventType: input.prepared.event.type ?? null, + action: input.prepared.event.action ?? null, + webhookTimestamp: input.prepared.event.webhookTimestamp ?? null, + }, + }; + } catch { + return { + statusCode: 503, + body: { + ok: false, + error: "linear_webhook_persist_failed", + }, + }; } - return app; } function verifyLinearWebhookSignature(input: { @@ -209,12 +326,34 @@ function parseLinearWebhookEvent(input: { const action = normalizeString(input.payload.action); const type = normalizeString(input.payload.type); const webhookTimestamp = normalizeString(input.payload.webhookTimestamp); + const data = isRecord(input.payload.data) ? input.payload.data : null; + const organization = isRecord(input.payload.organization) + ? input.payload.organization + : null; + const project = isRecord(data?.project) ? data.project : null; + const team = isRecord(data?.team) ? data.team : null; + const issueId = normalizeString(data?.id); + const issueIdentifier = + normalizeString(data?.identifier) ?? normalizeString(data?.issueIdentifier); + const projectId = + normalizeString(data?.projectId) ?? normalizeString(project?.id); + const teamId = normalizeString(data?.teamId) ?? normalizeString(team?.id); + const profileId = + normalizeString(input.payload.profileId) ?? + normalizeString(organization?.id); + const organizationId = normalizeString(organization?.id); return { ok: true, event: { ...(action ? { action } : {}), ...(type ? { type } : {}), ...(webhookTimestamp ? { webhookTimestamp } : {}), + ...(issueId ? { issueId } : {}), + ...(issueIdentifier ? { issueIdentifier } : {}), + ...(projectId ? { projectId } : {}), + ...(teamId ? { teamId } : {}), + ...(profileId ? { profileId } : {}), + ...(organizationId ? { organizationId } : {}), }, }; } diff --git a/services/auth-broker/src/modules/linear-connections/plugin.ts b/services/auth-broker/src/modules/linear-connections/plugin.ts new file mode 100644 index 000000000..1523caf45 --- /dev/null +++ b/services/auth-broker/src/modules/linear-connections/plugin.ts @@ -0,0 +1,39 @@ +import { Elysia, t } from "elysia"; + +import type { LinearConnectionStore } from "./service.ts"; + +const listConnectionsQuerySchema = t.Object({ + profileId: t.Optional(t.String()), + organizationId: t.Optional(t.String()), +}); + +type CreateLinearConnectionsPluginOptions = { + readonly connectionStore: LinearConnectionStore; +}; + +export function createLinearConnectionsPlugin({ + connectionStore, +}: CreateLinearConnectionsPluginOptions) { + return new Elysia({ + name: "hack-auth-broker.linear-connections", + }).get( + "/v1/auth/linear/connections", + async ({ query }) => ({ + ok: true, + accessControlMode: "manual_unenforced", + connections: await connectionStore.listConnections({ + profileId: normalizeOptionalQueryValue(query.profileId), + organizationId: normalizeOptionalQueryValue(query.organizationId), + }), + }), + { query: listConnectionsQuerySchema } + ); +} + +function normalizeOptionalQueryValue(value?: string): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/services/auth-broker/src/modules/linear-connections/service.ts b/services/auth-broker/src/modules/linear-connections/service.ts new file mode 100644 index 000000000..fd628ef87 --- /dev/null +++ b/services/auth-broker/src/modules/linear-connections/service.ts @@ -0,0 +1,362 @@ +import { randomUUID } from "node:crypto"; + +import { createDbClient } from "@hack/db"; +import { linearConnections } from "@hack/db/schema/core"; +import { and, desc, eq, type SQL } from "drizzle-orm"; + +export type LinearConnectionMetadata = Record; + +export type LinearConnectionRecord = { + readonly id: string; + readonly connectionKey: string; + readonly profileId: string | null; + readonly accountId: string | null; + readonly accountName: string | null; + readonly accountEmail: string | null; + readonly authRef: string | null; + readonly betterAuthUserId: string | null; + readonly organizationId: string | null; + readonly teamId: string | null; + readonly metadata: LinearConnectionMetadata; + readonly createdAt: string; + readonly updatedAt: string; +}; + +export type UpsertLinearConnectionInput = { + readonly profileId?: string | null; + readonly accountId?: string | null; + readonly accountName?: string | null; + readonly accountEmail?: string | null; + readonly authRef?: string | null; + readonly betterAuthUserId?: string | null; + readonly organizationId?: string | null; + readonly teamId?: string | null; + readonly metadata?: LinearConnectionMetadata; +}; + +export type ListLinearConnectionsInput = { + readonly profileId?: string | null; + readonly organizationId?: string | null; +}; + +export type LinearWebhookOwnership = { + readonly status: "matched" | "ambiguous" | "unmatched"; + readonly profileId: string | null; + readonly betterAuthUserId: string | null; + readonly organizationId: string | null; + readonly teamId: string | null; + readonly connectionId?: string; +}; + +export type LinearConnectionStore = { + readonly upsertConnection: ( + input: UpsertLinearConnectionInput + ) => Promise; + readonly listConnections: ( + input?: ListLinearConnectionsInput + ) => Promise; + readonly resolveWebhookOwnership: (input: { + readonly profileId?: string | null; + readonly organizationId?: string | null; + }) => Promise; +}; + +export class InMemoryLinearConnectionStore implements LinearConnectionStore { + private readonly recordsByKey = new Map(); + + upsertConnection( + input: UpsertLinearConnectionInput + ): Promise { + const connectionKey = buildConnectionKey(input); + const existing = this.recordsByKey.get(connectionKey); + const now = new Date().toISOString(); + const record: LinearConnectionRecord = { + id: existing?.id ?? randomUUID(), + connectionKey, + profileId: normalizeText(input.profileId), + accountId: normalizeText(input.accountId), + accountName: normalizeText(input.accountName), + accountEmail: normalizeText(input.accountEmail), + authRef: normalizeText(input.authRef), + betterAuthUserId: normalizeText(input.betterAuthUserId), + organizationId: normalizeText(input.organizationId), + teamId: normalizeText(input.teamId), + metadata: input.metadata ?? {}, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + this.recordsByKey.set(connectionKey, record); + return Promise.resolve(record); + } + + listConnections( + input: ListLinearConnectionsInput = {} + ): Promise { + return Promise.resolve( + [...this.recordsByKey.values()] + .filter((record) => matchesConnectionFilter({ record, filter: input })) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + ); + } + + async resolveWebhookOwnership(input: { + readonly profileId?: string | null; + readonly organizationId?: string | null; + }): Promise { + const profileId = normalizeText(input.profileId); + if (profileId) { + const matches = await this.listConnections({ profileId }); + if (matches.length > 0) { + return toWebhookOwnership({ + matches, + fallbackOrganizationId: normalizeText(input.organizationId), + preferred: matches[0] ?? null, + }); + } + } + + const organizationId = normalizeText(input.organizationId); + if (!organizationId) { + return { + status: "unmatched", + profileId: null, + betterAuthUserId: null, + organizationId: null, + teamId: null, + }; + } + + const matches = await this.listConnections({ organizationId }); + return toWebhookOwnership({ + matches, + fallbackOrganizationId: organizationId, + }); + } +} + +export function createLinearConnectionStoreFromDb(input: { + readonly databaseUrl: string; +}): LinearConnectionStore { + const db = createDbClient({ databaseUrl: input.databaseUrl }); + return { + upsertConnection: async (connection) => { + const connectionKey = buildConnectionKey(connection); + const now = new Date(); + const inserted = await db + .insert(linearConnections) + .values({ + connectionKey, + profileId: normalizeText(connection.profileId), + accountId: normalizeText(connection.accountId), + accountName: normalizeText(connection.accountName), + accountEmail: normalizeText(connection.accountEmail), + authRef: normalizeText(connection.authRef), + betterAuthUserId: normalizeText(connection.betterAuthUserId), + organizationId: normalizeText(connection.organizationId), + teamId: normalizeText(connection.teamId), + metadataJson: JSON.stringify(connection.metadata ?? {}), + updatedAt: now, + }) + .onConflictDoUpdate({ + target: linearConnections.connectionKey, + set: { + profileId: normalizeText(connection.profileId), + accountId: normalizeText(connection.accountId), + accountName: normalizeText(connection.accountName), + accountEmail: normalizeText(connection.accountEmail), + authRef: normalizeText(connection.authRef), + betterAuthUserId: normalizeText(connection.betterAuthUserId), + organizationId: normalizeText(connection.organizationId), + teamId: normalizeText(connection.teamId), + metadataJson: JSON.stringify(connection.metadata ?? {}), + updatedAt: now, + }, + }) + .returning(); + const row = inserted[0]; + if (!row) { + throw new Error("Failed to persist Linear connection."); + } + return toConnectionRecord({ row }); + }, + + listConnections: async (input = {}) => { + const filters = buildListFilters({ input }); + const rows = + filters.length === 0 + ? await db + .select() + .from(linearConnections) + .orderBy(desc(linearConnections.updatedAt)) + : await db + .select() + .from(linearConnections) + .where(and(...filters)) + .orderBy(desc(linearConnections.updatedAt)); + return rows.map((row) => toConnectionRecord({ row })); + }, + + resolveWebhookOwnership: async ({ profileId, organizationId }) => { + const normalizedProfileId = normalizeText(profileId); + if (normalizedProfileId) { + const rows = await db + .select() + .from(linearConnections) + .where(eq(linearConnections.profileId, normalizedProfileId)) + .orderBy(desc(linearConnections.updatedAt)) + .limit(2); + if (rows.length > 0) { + return toWebhookOwnership({ + matches: rows.map((row) => toConnectionRecord({ row })), + fallbackOrganizationId: normalizeText(organizationId), + }); + } + } + + const normalizedOrganizationId = normalizeText(organizationId); + if (!normalizedOrganizationId) { + return { + status: "unmatched", + profileId: null, + betterAuthUserId: null, + organizationId: null, + teamId: null, + }; + } + + const rows = await db + .select() + .from(linearConnections) + .where(eq(linearConnections.organizationId, normalizedOrganizationId)) + .orderBy(desc(linearConnections.updatedAt)) + .limit(2); + return toWebhookOwnership({ + matches: rows.map((row) => toConnectionRecord({ row })), + fallbackOrganizationId: normalizedOrganizationId, + }); + }, + }; +} + +function buildConnectionKey(input: UpsertLinearConnectionInput): string { + const profileId = normalizeText(input.profileId); + if (profileId) { + return `profile:${profileId}`; + } + const accountId = normalizeText(input.accountId); + if (accountId) { + return `account:${accountId}`; + } + const accountEmail = normalizeText(input.accountEmail); + if (accountEmail) { + return `email:${accountEmail.toLowerCase()}`; + } + throw new Error( + "Linear connection requires profileId, accountId, or accountEmail." + ); +} + +function buildListFilters(input: { + readonly input: ListLinearConnectionsInput; +}): SQL[] { + const filters: SQL[] = []; + const profileId = normalizeText(input.input.profileId); + if (profileId) { + filters.push(eq(linearConnections.profileId, profileId)); + } + const organizationId = normalizeText(input.input.organizationId); + if (organizationId) { + filters.push(eq(linearConnections.organizationId, organizationId)); + } + return filters; +} + +function matchesConnectionFilter(input: { + readonly record: LinearConnectionRecord; + readonly filter: ListLinearConnectionsInput; +}): boolean { + const profileId = normalizeText(input.filter.profileId); + if (profileId && input.record.profileId !== profileId) { + return false; + } + const organizationId = normalizeText(input.filter.organizationId); + if (organizationId && input.record.organizationId !== organizationId) { + return false; + } + return true; +} + +function toConnectionRecord(input: { + readonly row: typeof linearConnections.$inferSelect; +}): LinearConnectionRecord { + return { + id: input.row.id, + connectionKey: input.row.connectionKey, + profileId: input.row.profileId ?? null, + accountId: input.row.accountId ?? null, + accountName: input.row.accountName ?? null, + accountEmail: input.row.accountEmail ?? null, + authRef: input.row.authRef ?? null, + betterAuthUserId: input.row.betterAuthUserId ?? null, + organizationId: input.row.organizationId ?? null, + teamId: input.row.teamId ?? null, + metadata: parseMetadata({ raw: input.row.metadataJson }), + createdAt: input.row.createdAt.toISOString(), + updatedAt: input.row.updatedAt.toISOString(), + }; +} + +function parseMetadata(input: { + readonly raw: string; +}): LinearConnectionMetadata { + try { + const parsed = JSON.parse(input.raw) as unknown; + return typeof parsed === "object" && parsed !== null + ? (parsed as LinearConnectionMetadata) + : {}; + } catch { + return {}; + } +} + +function toWebhookOwnership(input: { + readonly matches: readonly LinearConnectionRecord[]; + readonly fallbackOrganizationId: string | null; + readonly preferred?: LinearConnectionRecord | null; +}): LinearWebhookOwnership { + const preferred = input.preferred ?? input.matches[0] ?? null; + if (input.matches.length === 1 && preferred) { + return { + status: "matched", + profileId: preferred.profileId, + betterAuthUserId: preferred.betterAuthUserId, + organizationId: preferred.organizationId ?? input.fallbackOrganizationId, + teamId: preferred.teamId, + connectionId: preferred.id, + }; + } + if (input.matches.length > 1) { + return { + status: "ambiguous", + profileId: null, + betterAuthUserId: null, + organizationId: input.fallbackOrganizationId, + teamId: null, + }; + } + return { + status: "unmatched", + profileId: null, + betterAuthUserId: null, + organizationId: input.fallbackOrganizationId, + teamId: null, + }; +} + +function normalizeText(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts b/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts index 33e5368ca..0037489f7 100644 --- a/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts +++ b/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts @@ -1,18 +1,24 @@ import { Elysia } from "elysia"; +import type { BetterAuthRuntime } from "../../better-auth.ts"; import type { BrokerConfig } from "../../config.ts"; import type { FlowStore } from "../../flow-store.ts"; +import type { LinearConnectionStore } from "../linear-connections/service.ts"; import { LinearOAuthModel } from "./model.ts"; import { handleLinearCallback } from "./service.ts"; type CreateLinearOAuthCallbackRoutesPluginOptions = { readonly config: BrokerConfig; readonly flowStore: FlowStore; + readonly connectionStore: LinearConnectionStore; + readonly betterAuthRuntime: BetterAuthRuntime; }; export function createLinearOAuthCallbackRoutesPlugin({ config, flowStore, + connectionStore, + betterAuthRuntime, }: CreateLinearOAuthCallbackRoutesPluginOptions) { return new Elysia({ name: "hack-auth-broker.linear-oauth.callback-routes", @@ -22,6 +28,8 @@ export function createLinearOAuthCallbackRoutesPlugin({ handleLinearCallback({ config, flowStore, + connectionStore, + betterAuthRuntime, query, }), { query: LinearOAuthModel.callbackQuery } diff --git a/services/auth-broker/src/modules/linear-oauth/plugin.ts b/services/auth-broker/src/modules/linear-oauth/plugin.ts index a3fd6d60a..91c7c7c37 100644 --- a/services/auth-broker/src/modules/linear-oauth/plugin.ts +++ b/services/auth-broker/src/modules/linear-oauth/plugin.ts @@ -1,7 +1,9 @@ import { Elysia } from "elysia"; +import type { BetterAuthRuntime } from "../../better-auth.ts"; import type { BrokerConfig } from "../../config.ts"; import type { FlowStore } from "../../flow-store.ts"; +import type { LinearConnectionStore } from "../linear-connections/service.ts"; import { createLinearOAuthCallbackRoutesPlugin } from "./callback-routes-plugin.ts"; import { createLinearOAuthFlowStatusRoutesPlugin } from "./flow-status-routes-plugin.ts"; import { createLinearOAuthRefreshRoutesPlugin } from "./refresh-routes-plugin.ts"; @@ -10,11 +12,15 @@ import { createLinearOAuthStartRoutesPlugin } from "./start-routes-plugin.ts"; type CreateLinearOAuthPluginOptions = { readonly config: BrokerConfig; readonly flowStore: FlowStore; + readonly connectionStore: LinearConnectionStore; + readonly betterAuthRuntime: BetterAuthRuntime; }; export function createLinearOAuthPlugin({ config, flowStore, + connectionStore, + betterAuthRuntime, }: CreateLinearOAuthPluginOptions) { return new Elysia({ name: "hack-auth-broker.linear-oauth", @@ -29,6 +35,8 @@ export function createLinearOAuthPlugin({ createLinearOAuthCallbackRoutesPlugin({ config, flowStore, + connectionStore, + betterAuthRuntime, }) ) .use( diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts index 63754f8b0..f72362a0f 100644 --- a/services/auth-broker/src/modules/linear-oauth/service.ts +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -1,5 +1,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { BetterAuthRuntime } from "../../better-auth.ts"; +import { resolveBetterAuthUserFromLinearAccount } from "../../better-auth-link.ts"; import type { BrokerConfig } from "../../config.ts"; import { type FlowStore, hashDeviceCode } from "../../flow-store.ts"; import { @@ -8,6 +10,8 @@ import { fetchIdentity, refreshAccessToken, } from "../../linear.ts"; +import type { OAuthFlow } from "../../types.ts"; +import type { LinearConnectionStore } from "../linear-connections/service.ts"; import type { LinearOAuthModel } from "./model.ts"; export type StartFlowPayload = { @@ -83,51 +87,29 @@ export function createFlow(input: { export async function handleLinearCallback(input: { readonly config: BrokerConfig; readonly flowStore: FlowStore; + readonly connectionStore: LinearConnectionStore; + readonly betterAuthRuntime: BetterAuthRuntime; readonly query: LinearOAuthModel["callbackQuery"]; }): Promise { - const state = normalizeText(input.query.state); - if (!state) { - return renderCallbackPage({ - title: "Missing state", - body: "This Linear sign-in session is missing a state token.", - statusCode: 400, - }); - } - - const flow = input.flowStore.getByState(state); - if (!(flow && flow.provider === "linear")) { - return renderCallbackPage({ - title: "Session not found", - body: "This Linear sign-in session was not found or already expired.", - statusCode: 404, - }); - } - - const nowMs = Date.now(); - if (nowMs > flow.expiresAtMs) { - input.flowStore.markError({ - flowId: flow.id, - error: "oauth_flow_expired", - status: "expired", - }); - return renderCallbackPage({ - title: "Session expired", - body: "This Linear sign-in session expired. Start a new flow from Hack.", - statusCode: 410, - }); + const flowResult = resolveLinearCallbackFlow({ + flowStore: input.flowStore, + query: input.query, + }); + if (!flowResult.ok) { + return flowResult.response; } + const flow = flowResult.flow; const oauthError = normalizeText(input.query.error); if (oauthError) { const oauthDescription = normalizeText(input.query.error_description); - input.flowStore.markError({ + return markLinearFlowError({ + flowStore: input.flowStore, flowId: flow.id, error: oauthDescription ? `${oauthError}: ${oauthDescription}` : oauthError, status: "error", - }); - return renderCallbackPage({ title: "Linear authorization failed", body: oauthDescription ?? @@ -138,12 +120,11 @@ export async function handleLinearCallback(input: { const code = normalizeText(input.query.code); if (!code) { - input.flowStore.markError({ + return markLinearFlowError({ + flowStore: input.flowStore, flowId: flow.id, error: "missing_authorization_code", status: "error", - }); - return renderCallbackPage({ title: "Missing authorization code", body: "Linear did not return a code for this sign-in request.", statusCode: 400, @@ -152,12 +133,11 @@ export async function handleLinearCallback(input: { const clientId = input.config.linearClientId; if (!(clientId && flow.codeVerifier)) { - input.flowStore.markError({ + return markLinearFlowError({ + flowStore: input.flowStore, flowId: flow.id, error: "linear_oauth_not_configured", status: "error", - }); - return renderCallbackPage({ title: "Linear OAuth not configured", body: "The auth broker is missing Linear OAuth configuration.", statusCode: 412, @@ -175,12 +155,11 @@ export async function handleLinearCallback(input: { codeVerifier: flow.codeVerifier, }); if (!exchange.ok) { - input.flowStore.markError({ + return markLinearFlowError({ + flowStore: input.flowStore, flowId: flow.id, error: exchange.error, status: "error", - }); - return renderCallbackPage({ title: "Token exchange failed", body: exchange.error, statusCode: 502, @@ -192,26 +171,64 @@ export async function handleLinearCallback(input: { token: exchange.token, }); if (!identity.ok) { - input.flowStore.markError({ + return markLinearFlowError({ + flowStore: input.flowStore, flowId: flow.id, error: identity.error, status: "error", - }); - return renderCallbackPage({ title: "Linear account lookup failed", body: identity.error, statusCode: 502, }); } + const betterAuthLink = await resolveBetterAuthUserFromLinearAccount({ + runtime: input.betterAuthRuntime, + account: identity.account, + autoProvision: input.config.betterAuthLinearAutoProvisionUsers, + }); + const linkedAccount = { + ...identity.account, + ...(betterAuthLink.userId + ? { betterAuthUserId: betterAuthLink.userId } + : {}), + ...(betterAuthLink.state + ? { betterAuthLinkState: betterAuthLink.state } + : {}), + } as const; + input.flowStore.markComplete({ flowId: flow.id, - account: identity.account, + account: linkedAccount, token: exchange.token, tokenExpiresAt: exchange.tokenExpiresAt, refreshToken: exchange.refreshToken, refreshTokenExpiresAt: exchange.refreshTokenExpiresAt, }); + try { + await input.connectionStore.upsertConnection({ + profileId: flow.profileId, + accountId: identity.account.accountId, + accountName: identity.account.accountName, + accountEmail: identity.account.accountEmail, + betterAuthUserId: betterAuthLink.userId ?? null, + organizationId: identity.account.organizationId, + teamId: identity.account.teamIds?.[0] ?? null, + metadata: { + ...(betterAuthLink.state + ? { betterAuthLinkState: betterAuthLink.state } + : {}), + ...(identity.account.organizationName + ? { organizationName: identity.account.organizationName } + : {}), + ...(identity.account.teamIds + ? { teamIds: identity.account.teamIds } + : {}), + }, + }); + } catch { + // Connection persistence is diagnostic state and must not fail OAuth completion. + } return renderCallbackPage({ title: "Linear connected", @@ -221,6 +238,75 @@ export async function handleLinearCallback(input: { }); } +function resolveLinearCallbackFlow(input: { + readonly flowStore: FlowStore; + readonly query: LinearOAuthModel["callbackQuery"]; +}): + | { readonly ok: true; readonly flow: OAuthFlow } + | { readonly ok: false; readonly response: Response } { + const state = normalizeText(input.query.state); + if (!state) { + return { + ok: false, + response: renderCallbackPage({ + title: "Missing state", + body: "This Linear sign-in session is missing a state token.", + statusCode: 400, + }), + }; + } + const flow = input.flowStore.getByState(state); + if (!(flow && flow.provider === "linear")) { + return { + ok: false, + response: renderCallbackPage({ + title: "Session not found", + body: "This Linear sign-in session was not found or already expired.", + statusCode: 404, + }), + }; + } + if (Date.now() <= flow.expiresAtMs) { + return { + ok: true, + flow, + }; + } + return { + ok: false, + response: markLinearFlowError({ + flowStore: input.flowStore, + flowId: flow.id, + error: "oauth_flow_expired", + status: "expired", + title: "Session expired", + body: "This Linear sign-in session expired. Start a new flow from Hack.", + statusCode: 410, + }), + }; +} + +function markLinearFlowError(input: { + readonly flowStore: FlowStore; + readonly flowId: string; + readonly error: string; + readonly status: "error" | "expired"; + readonly title: string; + readonly body: string; + readonly statusCode: number; +}): Response { + input.flowStore.markError({ + flowId: input.flowId, + error: input.error, + status: input.status, + }); + return renderCallbackPage({ + title: input.title, + body: input.body, + statusCode: input.statusCode, + }); +} + export async function handleRefreshToken(input: { readonly config: BrokerConfig; readonly body: LinearOAuthModel["refreshBody"]; diff --git a/services/auth-broker/src/modules/linear-sync-store/plugin.ts b/services/auth-broker/src/modules/linear-sync-store/plugin.ts new file mode 100644 index 000000000..b21f49e1c --- /dev/null +++ b/services/auth-broker/src/modules/linear-sync-store/plugin.ts @@ -0,0 +1,125 @@ +import { Elysia, t } from "elysia"; + +import type { + LinearSyncStore, + LinearWebhookDeliveryStatus, +} from "./service.ts"; + +const LINEAR_DELIVERY_STATUSES = ["pending", "applied", "ignored"] as const; + +type CreateLinearSyncStorePluginOptions = { + readonly syncStore: LinearSyncStore; +}; + +const listDeliveriesQuerySchema = t.Object({ + status: t.Optional(t.String()), + profileId: t.Optional(t.String()), + projectId: t.Optional(t.String()), + teamId: t.Optional(t.String()), +}); + +const applyDeliveryParamsSchema = t.Object({ + deliveryId: t.String(), +}); + +/** + * Routes for pending Linear webhook deliveries consumed by local clients. + */ +export function createLinearSyncStorePlugin({ + syncStore, +}: CreateLinearSyncStorePluginOptions) { + return new Elysia({ + name: "hack-auth-broker.linear-sync-store", + }) + .get( + "/v1/auth/linear/deliveries", + async ({ query, set }) => { + const statusResult = normalizeStatusQuery({ value: query.status }); + if (!statusResult.ok) { + set.status = 400; + return { + ok: false, + error: statusResult.error, + } as const; + } + + const deliveries = await syncStore.listWebhookDeliveries({ + status: statusResult.status, + profileId: normalizeOptionalQueryValue({ value: query.profileId }), + projectId: normalizeOptionalQueryValue({ value: query.projectId }), + teamId: normalizeOptionalQueryValue({ value: query.teamId }), + }); + return { + ok: true, + deliveries, + } as const; + }, + { + query: listDeliveriesQuerySchema, + } + ) + .post( + "/v1/auth/linear/deliveries/:deliveryId/apply", + async ({ params, set }) => { + const delivery = await syncStore.markWebhookDeliveryApplied({ + deliveryId: params.deliveryId, + }); + if (!delivery) { + set.status = 404; + return { + ok: false, + error: "linear_delivery_not_found", + } as const; + } + return { + ok: true, + delivery, + } as const; + }, + { + params: applyDeliveryParamsSchema, + } + ); +} + +function normalizeStatusQuery(input: { + readonly value?: string; +}): + | { readonly ok: true; readonly status: LinearWebhookDeliveryStatus } + | { readonly ok: false; readonly error: string } { + if (!input.value) { + return { + ok: true, + status: "pending", + }; + } + const normalized = input.value.trim(); + if (isLinearWebhookDeliveryStatus(normalized)) { + return { + ok: true, + status: normalized, + }; + } + return { + ok: false, + error: "invalid_linear_delivery_status", + }; +} + +function normalizeOptionalQueryValue(input: { + readonly value?: string; +}): string | null { + if (typeof input.value !== "string") { + return null; + } + const trimmed = input.value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isLinearWebhookDeliveryStatus( + value: string +): value is LinearWebhookDeliveryStatus { + return LINEAR_DELIVERY_STATUSES.includes( + value as (typeof LINEAR_DELIVERY_STATUSES)[number] + ); +} diff --git a/services/auth-broker/src/modules/linear-sync-store/service.ts b/services/auth-broker/src/modules/linear-sync-store/service.ts new file mode 100644 index 000000000..bb1e16cd0 --- /dev/null +++ b/services/auth-broker/src/modules/linear-sync-store/service.ts @@ -0,0 +1,425 @@ +import { createHash, randomUUID } from "node:crypto"; + +import { createDbClient } from "@hack/db"; +import { linearWebhookEvents } from "@hack/db/schema/core"; +import { and, desc, eq, type SQL, sql } from "drizzle-orm"; + +export type LinearWebhookDeliveryStatus = "pending" | "applied" | "ignored"; + +export type RecordLinearWebhookDeliveryInput = { + readonly path: string; + readonly rawBody: string; + readonly payloadJson: unknown; + readonly signatureVerified: boolean; + readonly eventType: string | null; + readonly action: string | null; + readonly webhookTimestamp: string | null; + readonly deliveryKey?: string | null; + readonly profileId?: string | null; + readonly projectId?: string | null; + readonly issueId?: string | null; + readonly issueIdentifier?: string | null; + readonly betterAuthUserId: string | null; + readonly organizationId: string | null; + readonly teamId: string | null; +}; + +export type ListLinearWebhookDeliveriesInput = { + readonly status?: LinearWebhookDeliveryStatus; + readonly profileId?: string | null; + readonly projectId?: string | null; + readonly teamId?: string | null; +}; + +export type LinearWebhookDelivery = { + readonly id: string; + readonly path: string; + readonly rawBody: string; + readonly payloadJson: unknown; + readonly signatureVerified: boolean; + readonly eventType: string | null; + readonly action: string | null; + readonly webhookTimestamp: string | null; + readonly profileId: string | null; + readonly projectId: string | null; + readonly teamId: string | null; + readonly issueId: string | null; + readonly issueIdentifier: string | null; + readonly betterAuthUserId: string | null; + readonly organizationId: string | null; + readonly ownerTeamId: string | null; + readonly status: LinearWebhookDeliveryStatus; + readonly receivedAt: string; + readonly updatedAt: string; + readonly appliedAt: string | null; +}; + +export type LinearSyncStore = { + readonly recordWebhookDelivery: ( + input: RecordLinearWebhookDeliveryInput + ) => Promise; + readonly listWebhookDeliveries: ( + input?: ListLinearWebhookDeliveriesInput + ) => Promise; + readonly markWebhookDeliveryApplied: (input: { + readonly deliveryId: string; + }) => Promise; +}; + +/** + * Temporary in-memory Linear sync store used by tests and local runtime wiring. + * + * The interface is shaped so a Postgres-backed implementation can replace this + * without changing route contracts when shared DB schema wiring lands. + */ +export class InMemoryLinearSyncStore implements LinearSyncStore { + private readonly deliveriesById = new Map(); + + recordWebhookDelivery( + input: RecordLinearWebhookDeliveryInput + ): Promise { + const now = new Date().toISOString(); + const delivery: LinearWebhookDelivery = { + id: randomUUID(), + path: input.path, + rawBody: input.rawBody, + payloadJson: input.payloadJson, + signatureVerified: input.signatureVerified, + eventType: input.eventType, + action: input.action, + webhookTimestamp: input.webhookTimestamp, + profileId: normalizeText(input.profileId), + projectId: normalizeText(input.projectId), + teamId: normalizeText(input.teamId), + issueId: normalizeText(input.issueId), + issueIdentifier: normalizeText(input.issueIdentifier), + betterAuthUserId: input.betterAuthUserId, + organizationId: input.organizationId, + ownerTeamId: normalizeText(input.teamId), + status: "pending", + receivedAt: now, + updatedAt: now, + appliedAt: null, + }; + this.deliveriesById.set(delivery.id, delivery); + return Promise.resolve(delivery); + } + + listWebhookDeliveries( + input: ListLinearWebhookDeliveriesInput = {} + ): Promise { + return Promise.resolve( + [...this.deliveriesById.values()] + .filter((delivery) => + matchesDeliveryFilter({ delivery, filter: input }) + ) + .sort((left, right) => right.receivedAt.localeCompare(left.receivedAt)) + ); + } + + markWebhookDeliveryApplied(input: { + readonly deliveryId: string; + }): Promise { + const delivery = this.deliveriesById.get(input.deliveryId); + if (!delivery) { + return Promise.resolve(null); + } + const appliedAt = delivery.appliedAt ?? new Date().toISOString(); + const updated: LinearWebhookDelivery = { + ...delivery, + status: "applied", + updatedAt: new Date().toISOString(), + appliedAt, + }; + this.deliveriesById.set(updated.id, updated); + return Promise.resolve(updated); + } +} + +export function createLinearSyncStoreFromDb(input: { + readonly databaseUrl: string; +}): LinearSyncStore { + const db = createDbClient({ + databaseUrl: input.databaseUrl, + }); + return { + recordWebhookDelivery: async ({ + path, + rawBody, + payloadJson, + signatureVerified, + eventType, + action, + webhookTimestamp, + deliveryKey, + profileId, + projectId, + issueId, + issueIdentifier, + betterAuthUserId, + organizationId, + teamId, + }) => { + const resolvedDeliveryKey = + normalizeText(deliveryKey) ?? + buildDeliveryKey({ + path, + rawBody, + eventType, + action, + webhookTimestamp, + }); + const inserted = await db + .insert(linearWebhookEvents) + .values({ + deliveryKey: resolvedDeliveryKey, + profileId: normalizeText(profileId), + projectId: normalizeText(projectId), + teamId: normalizeText(teamId), + issueId: normalizeText(issueId), + issueIdentifier: normalizeText(issueIdentifier), + eventType: eventType ?? "unknown", + action: action ?? "unknown", + status: "pending", + payloadJson: JSON.stringify({ + path, + rawBody, + payload: payloadJson, + signatureVerified, + webhookTimestamp, + }), + applyError: null, + claimedBy: null, + betterAuthUserId, + organizationId, + ownerTeamId: teamId, + }) + .onConflictDoNothing({ + target: linearWebhookEvents.deliveryKey, + }) + .returning(); + + if (inserted[0]) { + return toWebhookDelivery({ row: inserted[0] }); + } + + const existing = await db + .select() + .from(linearWebhookEvents) + .where(eq(linearWebhookEvents.deliveryKey, resolvedDeliveryKey)) + .limit(1); + if (!existing[0]) { + throw new Error("Failed to persist Linear webhook delivery."); + } + return toWebhookDelivery({ row: existing[0] }); + }, + + listWebhookDeliveries: async (input = {}) => { + const filters = buildListFilters({ input }); + const rows = + filters.length === 0 + ? await db + .select() + .from(linearWebhookEvents) + .orderBy(desc(linearWebhookEvents.createdAt)) + : await db + .select() + .from(linearWebhookEvents) + .where(and(...filters)) + .orderBy(desc(linearWebhookEvents.createdAt)); + return rows.map((row) => toWebhookDelivery({ row })); + }, + + markWebhookDeliveryApplied: async ({ deliveryId }) => { + const now = new Date(); + const updated = await db + .update(linearWebhookEvents) + .set({ + status: "applied", + applyError: null, + claimedBy: sql`coalesce(${linearWebhookEvents.claimedBy}, 'manual')`, + updatedAt: now, + appliedAt: now, + }) + .where(eq(linearWebhookEvents.id, deliveryId)) + .returning(); + return updated[0] ? toWebhookDelivery({ row: updated[0] }) : null; + }, + }; +} + +function toWebhookDelivery(input: { + readonly row: typeof linearWebhookEvents.$inferSelect; +}): LinearWebhookDelivery { + return { + id: input.row.id, + path: readStoredPath({ payloadJson: input.row.payloadJson }), + rawBody: readStoredRawBody({ payloadJson: input.row.payloadJson }), + payloadJson: readStoredPayload({ payloadJson: input.row.payloadJson }), + signatureVerified: readStoredSignatureVerified({ + payloadJson: input.row.payloadJson, + }), + eventType: input.row.eventType, + action: input.row.action, + webhookTimestamp: + readStoredWebhookTimestamp({ payloadJson: input.row.payloadJson }) ?? + null, + profileId: input.row.profileId ?? null, + projectId: input.row.projectId ?? null, + teamId: input.row.teamId ?? null, + issueId: input.row.issueId ?? null, + issueIdentifier: input.row.issueIdentifier ?? null, + betterAuthUserId: input.row.betterAuthUserId ?? null, + organizationId: input.row.organizationId ?? null, + ownerTeamId: input.row.ownerTeamId ?? null, + status: normalizeDeliveryStatus({ value: input.row.status }), + receivedAt: input.row.createdAt.toISOString(), + updatedAt: input.row.updatedAt.toISOString(), + appliedAt: input.row.appliedAt?.toISOString() ?? null, + }; +} + +function buildListFilters(input: { + readonly input: ListLinearWebhookDeliveriesInput; +}) { + const filters: SQL[] = []; + if (input.input.status) { + filters.push(eq(linearWebhookEvents.status, input.input.status)); + } + const profileId = normalizeText(input.input.profileId); + if (profileId) { + filters.push(eq(linearWebhookEvents.profileId, profileId)); + } + const projectId = normalizeText(input.input.projectId); + if (projectId) { + filters.push(eq(linearWebhookEvents.projectId, projectId)); + } + const teamId = normalizeText(input.input.teamId); + if (teamId) { + filters.push(eq(linearWebhookEvents.teamId, teamId)); + } + return filters; +} + +function matchesDeliveryFilter(input: { + readonly delivery: LinearWebhookDelivery; + readonly filter: ListLinearWebhookDeliveriesInput; +}): boolean { + if (input.filter.status && input.delivery.status !== input.filter.status) { + return false; + } + const profileId = normalizeText(input.filter.profileId); + if (profileId && input.delivery.profileId !== profileId) { + return false; + } + const projectId = normalizeText(input.filter.projectId); + if (projectId && input.delivery.projectId !== projectId) { + return false; + } + const teamId = normalizeText(input.filter.teamId); + if (teamId && input.delivery.teamId !== teamId) { + return false; + } + return true; +} + +function buildDeliveryKey(input: { + readonly path: string; + readonly rawBody: string; + readonly eventType: string | null; + readonly action: string | null; + readonly webhookTimestamp: string | null; +}): string { + return createHash("sha256") + .update( + [ + input.path, + input.eventType ?? "", + input.action ?? "", + input.webhookTimestamp ?? "", + input.rawBody, + ].join("\n"), + "utf8" + ) + .digest("hex"); +} + +function readStoredEnvelope(input: { + readonly payloadJson: string; +}): Record | null { + try { + const parsed = JSON.parse(input.payloadJson) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function readStoredPath(input: { readonly payloadJson: string }): string { + return ( + readStringField({ record: readStoredEnvelope(input), key: "path" }) ?? "" + ); +} + +function readStoredRawBody(input: { readonly payloadJson: string }): string { + return ( + readStringField({ record: readStoredEnvelope(input), key: "rawBody" }) ?? "" + ); +} + +function readStoredPayload(input: { readonly payloadJson: string }): unknown { + return readStoredEnvelope(input)?.payload ?? null; +} + +function readStoredWebhookTimestamp(input: { + readonly payloadJson: string; +}): string | null { + return readStringField({ + record: readStoredEnvelope(input), + key: "webhookTimestamp", + }); +} + +function readStoredSignatureVerified(input: { + readonly payloadJson: string; +}): boolean { + const record = readStoredEnvelope(input); + return record?.signatureVerified === true; +} + +function readStringField(input: { + readonly record: Record | null; + readonly key: string; +}): string | null { + const value = input.record?.[input.key]; + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function normalizeText(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeDeliveryStatus(input: { + readonly value: string; +}): LinearWebhookDeliveryStatus { + if ( + input.value === "pending" || + input.value === "applied" || + input.value === "ignored" + ) { + return input.value; + } + return "pending"; +} diff --git a/services/auth-broker/src/modules/providers/plugin.ts b/services/auth-broker/src/modules/providers/plugin.ts index 8a54c729b..5b4ce78db 100644 --- a/services/auth-broker/src/modules/providers/plugin.ts +++ b/services/auth-broker/src/modules/providers/plugin.ts @@ -45,6 +45,8 @@ export function createProvidersPlugin({ tokenUrl: config.linearTokenUrl, apiBaseUrl: config.linearApiBaseUrl, webhookPath: config.linearWebhookPath, + connectionsPath: "/v1/auth/linear/connections", + accessControlMode: "manual_unenforced", developerAppTokenConfigured: Boolean(config.linearDeveloperAppToken), webhookSignatureVerification: config.linearWebhookSigningSecret ? "hmac-sha256" diff --git a/services/auth-broker/src/types.ts b/services/auth-broker/src/types.ts index 360f592c8..744f78b4f 100644 --- a/services/auth-broker/src/types.ts +++ b/services/auth-broker/src/types.ts @@ -13,6 +13,9 @@ export type OAuthFlowAccount = { readonly accountName?: string; readonly accountId?: string; readonly accountEmail?: string; + readonly organizationId?: string; + readonly organizationName?: string; + readonly teamIds?: readonly string[]; readonly installationIds?: readonly string[]; readonly betterAuthUserId?: string; readonly betterAuthLinkState?: @@ -69,6 +72,9 @@ export type OAuthFlowPublicStatus = { readonly accountName?: string; readonly accountId?: string; readonly accountEmail?: string; + readonly organizationId?: string; + readonly organizationName?: string; + readonly teamIds?: readonly string[]; readonly betterAuthUserId?: string; readonly betterAuthLinkState?: | "disabled" diff --git a/services/auth-broker/tests/better-auth-link.test.ts b/services/auth-broker/tests/better-auth-link.test.ts index 818b913ad..6bb16af5c 100644 --- a/services/auth-broker/tests/better-auth-link.test.ts +++ b/services/auth-broker/tests/better-auth-link.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from "bun:test"; -import { resolveBetterAuthUserFromGitHubAccount } from "../src/better-auth-link.ts"; +import type { BetterAuthRuntime } from "../src/better-auth.ts"; +import { + resolveBetterAuthUserFromGitHubAccount, + resolveBetterAuthUserFromLinearAccount, +} from "../src/better-auth-link.ts"; + +type BetterAuthDb = NonNullable; + +function createBetterAuthDb( + rows: readonly Record[] +): BetterAuthDb { + const execute = (async () => ({ + rows: [...rows], + })) as unknown as BetterAuthDb["execute"]; + return { execute } as unknown as BetterAuthDb; +} describe("better-auth github account linking", () => { test("returns disabled state when Better Auth runtime is not enabled", async () => { @@ -19,4 +34,21 @@ describe("better-auth github account linking", () => { expect(result.state).toBe("disabled"); expect(result.userId).toBeUndefined(); }); + + test("links a Linear account to an existing Better Auth user by email", async () => { + const result = await resolveBetterAuthUserFromLinearAccount({ + runtime: { + enabled: true, + db: createBetterAuthDb([{ id: "user-linear-1" }]), + }, + account: { + accountEmail: "linear@example.com", + accountName: "Linear User", + }, + autoProvision: false, + }); + + expect(result.state).toBe("linked_existing"); + expect(result.userId).toBe("user-linear-1"); + }); }); diff --git a/services/auth-broker/tests/config.test.ts b/services/auth-broker/tests/config.test.ts index 051cbd183..e593a7181 100644 --- a/services/auth-broker/tests/config.test.ts +++ b/services/auth-broker/tests/config.test.ts @@ -23,6 +23,7 @@ const ENV_KEYS = [ "LINEAR_WEBHOOK_SIGNING_SECRET", "FLOW_STORE_PATH", "BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS", + "BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS", ] as const; describe("auth broker config", () => { @@ -68,6 +69,20 @@ describe("auth broker config", () => { ); }); + test("parses Better Auth Linear auto-provision boolean", () => { + withEnv( + { + GITHUB_CLIENT_ID: "test-client-id", + GITHUB_CLIENT_SECRET: "test-client-secret", + BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS: "true", + }, + () => { + const config = resolveConfig(); + expect(config.betterAuthLinearAutoProvisionUsers).toBe(true); + } + ); + }); + test("resolves linear aliases and defaults", () => { withEnv( { diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 7c4ff32a2..9d9b893f6 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -1,8 +1,16 @@ import { describe, expect, test } from "bun:test"; import { createHmac } from "node:crypto"; +import type { BetterAuthRuntime } from "../src/better-auth.ts"; import { FlowStore } from "../src/flow-store.ts"; import { createAuthBrokerApp } from "../src/index.ts"; +import { InMemoryLinearConnectionStore } from "../src/modules/linear-connections/service.ts"; +import { + InMemoryLinearSyncStore, + type LinearSyncStore, +} from "../src/modules/linear-sync-store/service.ts"; + +type BetterAuthDb = NonNullable; type StartFlowResponse = { readonly ok: true; @@ -14,6 +22,15 @@ type StartFlowResponse = { }; }; +function createBetterAuthDb( + rows: readonly Record[] +): BetterAuthDb { + const execute = (async () => ({ + rows: [...rows], + })) as unknown as BetterAuthDb["execute"]; + return { execute } as unknown as BetterAuthDb; +} + /** * Build deterministic config values for broker route tests. */ @@ -31,6 +48,7 @@ function createTestConfig() { githubApiBaseUrl: "https://api.github.com", githubRedirectUri: "http://127.0.0.1:8080/gh/callback", betterAuthGitHubAutoProvisionUsers: false, + betterAuthLinearAutoProvisionUsers: false, linearClientId: "linear-client-id", linearClientSecret: "linear-client-secret", linearActor: "app", @@ -163,6 +181,8 @@ describe("auth broker github flow routes", () => { readonly id: string; readonly enabled: boolean; readonly webhookPath?: string; + readonly connectionsPath?: string; + readonly accessControlMode?: string; readonly webhookSignatureVerification?: string; }>; }; @@ -170,6 +190,8 @@ describe("auth broker github flow routes", () => { expect(linearProvider).toBeDefined(); expect(linearProvider?.enabled).toBe(true); expect(linearProvider?.webhookPath).toBe("/linear/webhooks"); + expect(linearProvider?.connectionsPath).toBe("/v1/auth/linear/connections"); + expect(linearProvider?.accessControlMode).toBe("manual_unenforced"); expect(linearProvider?.webhookSignatureVerification).toBe("hmac-sha256"); }); @@ -193,12 +215,14 @@ describe("auth broker github flow routes", () => { test("linear webhook accepts valid signed payload", async () => { const secret = "linear-hook-secret"; + const syncStore = new InMemoryLinearSyncStore(); const app = createAuthBrokerApp({ config: { ...createTestConfig(), linearWebhookSigningSecret: secret, }, flowStore: new FlowStore(), + linearSyncStore: syncStore, }); const rawBody = JSON.stringify({ action: "create", @@ -224,11 +248,23 @@ describe("auth broker github flow routes", () => { readonly signatureVerified: boolean; readonly accepted: boolean; readonly eventType: string | null; + readonly deliveryId: string; }; expect(payload.ok).toBe(true); expect(payload.accepted).toBe(true); expect(payload.signatureVerified).toBe(true); expect(payload.eventType).toBe("Issue"); + expect(payload.deliveryId.length).toBeGreaterThan(0); + + const deliveries = await syncStore.listWebhookDeliveries({ + status: "pending", + }); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]?.id).toBe(payload.deliveryId); + expect(deliveries[0]?.path).toBe("/linear/webhooks"); + expect(deliveries[0]?.eventType).toBe("Issue"); + expect(deliveries[0]?.action).toBe("create"); + expect(deliveries[0]?.signatureVerified).toBe(true); }); test("linear webhook rejects invalid signature", async () => { @@ -277,9 +313,11 @@ describe("auth broker github flow routes", () => { }); test("linear webhook legacy alias remains available", async () => { + const syncStore = new InMemoryLinearSyncStore(); const app = createAuthBrokerApp({ config: createTestConfig(), flowStore: new FlowStore(), + linearSyncStore: syncStore, }); const response = await app.handle( new Request("http://localhost/v1/integrations/linear/webhook", { @@ -291,8 +329,307 @@ describe("auth broker github flow routes", () => { }) ); expect(response.status).toBe(202); - const payload = (await response.json()) as { readonly ok: boolean }; + const payload = (await response.json()) as { + readonly ok: boolean; + readonly deliveryId: string; + }; + expect(payload.ok).toBe(true); + expect(payload.deliveryId.length).toBeGreaterThan(0); + + const deliveries = await syncStore.listWebhookDeliveries({ + status: "pending", + }); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]?.path).toBe("/v1/integrations/linear/webhook"); + expect(deliveries[0]?.signatureVerified).toBe(false); + }); + + test("linear webhook persistence records organization ownership from payload", async () => { + const syncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + }); + const response = await app.handle( + new Request("http://localhost/linear/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + type: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + organization: { + id: "org-linear", + name: "Hack Dance", + }, + }), + }) + ); + + expect(response.status).toBe(202); + const deliveries = await syncStore.listWebhookDeliveries({ + status: "pending", + }); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]?.betterAuthUserId).toBeNull(); + expect(deliveries[0]?.organizationId).toBe("org-linear"); + expect(deliveries[0]?.teamId).toBeNull(); + }); + + test("linear webhook resolves profile ownership from a unique connected organization", async () => { + const syncStore = new InMemoryLinearSyncStore(); + const connectionStore = new InMemoryLinearConnectionStore(); + await connectionStore.upsertConnection({ + profileId: "work", + accountId: "usr_linear", + accountEmail: "linear@example.com", + organizationId: "org-linear", + }); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + linearConnectionStore: connectionStore, + }); + + const response = await app.handle( + new Request("http://localhost/linear/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + type: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + organization: { + id: "org-linear", + name: "Hack Dance", + }, + data: { + teamId: "team-linear", + }, + }), + }) + ); + + expect(response.status).toBe(202); + const deliveries = await syncStore.listWebhookDeliveries({ + status: "pending", + }); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]?.profileId).toBe("work"); + expect(deliveries[0]?.organizationId).toBe("org-linear"); + expect(deliveries[0]?.teamId).toBe("team-linear"); + }); + + test("linear sync store can mark a delivery as applied", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const recorded = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { + type: "Issue", + }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + betterAuthUserId: null, + organizationId: null, + teamId: null, + }); + + expect(recorded.status).toBe("pending"); + + const applied = await syncStore.markWebhookDeliveryApplied({ + deliveryId: recorded.id, + }); + expect(applied?.status).toBe("applied"); + + const pending = await syncStore.listWebhookDeliveries({ + status: "pending", + }); + expect(pending).toHaveLength(0); + }); + + test("linear pending deliveries route lists pending deliveries with scope filters", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + }); + + const matchingDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue","action":"create"}', + payloadJson: { + type: "Issue", + action: "create", + }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-a", + projectId: "project-a", + issueId: "issue-a", + issueIdentifier: "HACK-1", + betterAuthUserId: "user-a", + organizationId: "org-a", + teamId: "team-a", + }); + await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue","action":"update"}', + payloadJson: { + type: "Issue", + action: "update", + }, + signatureVerified: true, + eventType: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-b", + projectId: "project-b", + issueId: "issue-b", + issueIdentifier: "HACK-2", + betterAuthUserId: "user-b", + organizationId: "org-b", + teamId: "team-b", + }); + const appliedDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue","action":"comment"}', + payloadJson: { + type: "Issue", + action: "comment", + }, + signatureVerified: true, + eventType: "Comment", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-a", + projectId: "project-a", + issueId: "issue-a", + issueIdentifier: "HACK-1", + betterAuthUserId: "user-a", + organizationId: "org-a", + teamId: "team-a", + }); + await syncStore.markWebhookDeliveryApplied({ + deliveryId: appliedDelivery.id, + }); + + const response = await app.handle( + new Request( + "http://localhost/v1/auth/linear/deliveries?profileId=profile-a&projectId=project-a&teamId=team-a" + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly deliveries: ReadonlyArray<{ + readonly id: string; + readonly status: string; + readonly profileId: string | null; + readonly projectId: string | null; + readonly teamId: string | null; + readonly betterAuthUserId: string | null; + readonly organizationId: string | null; + readonly ownerTeamId: string | null; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.deliveries).toHaveLength(1); + expect(payload.deliveries[0]?.id).toBe(matchingDelivery.id); + expect(payload.deliveries[0]?.status).toBe("pending"); + expect(payload.deliveries[0]?.profileId).toBe("profile-a"); + expect(payload.deliveries[0]?.projectId).toBe("project-a"); + expect(payload.deliveries[0]?.teamId).toBe("team-a"); + expect(payload.deliveries[0]?.betterAuthUserId).toBe("user-a"); + expect(payload.deliveries[0]?.organizationId).toBe("org-a"); + expect(payload.deliveries[0]?.ownerTeamId).toBe("team-a"); + }); + + test("linear pending deliveries route can mark a delivery applied", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + }); + const delivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { + type: "Issue", + }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-a", + projectId: "project-a", + issueId: "issue-a", + issueIdentifier: "HACK-1", + betterAuthUserId: "user-a", + organizationId: "org-a", + teamId: "team-a", + }); + + const response = await app.handle( + new Request( + `http://localhost/v1/auth/linear/deliveries/${delivery.id}/apply`, + { + method: "POST", + } + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly delivery: { + readonly id: string; + readonly status: string; + readonly appliedAt: string | null; + readonly betterAuthUserId: string | null; + readonly organizationId: string | null; + readonly ownerTeamId: string | null; + }; + }; expect(payload.ok).toBe(true); + expect(payload.delivery.id).toBe(delivery.id); + expect(payload.delivery.status).toBe("applied"); + expect(payload.delivery.appliedAt).not.toBeNull(); + expect(payload.delivery.betterAuthUserId).toBe("user-a"); + expect(payload.delivery.organizationId).toBe("org-a"); + expect(payload.delivery.ownerTeamId).toBe("team-a"); + }); + + test("linear pending deliveries route returns not found for unknown apply id", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: new InMemoryLinearSyncStore(), + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/deliveries/missing/apply", { + method: "POST", + }) + ); + expect(response.status).toBe(404); + const payload = (await response.json()) as { + readonly ok: false; + readonly error: string; + }; + expect(payload.ok).toBe(false); + expect(payload.error).toBe("linear_delivery_not_found"); }); test("linear start route issues flow payload and polling reads pending state", async () => { @@ -343,9 +680,15 @@ describe("auth broker github flow routes", () => { test("linear callback completes flow and claim returns token", async () => { const flowStore = new FlowStore(); + const connectionStore = new InMemoryLinearConnectionStore(); const app = createAuthBrokerApp({ config: createTestConfig(), flowStore, + linearConnectionStore: connectionStore, + betterAuthRuntime: { + enabled: true, + db: createBetterAuthDb([{ id: "better-auth-linear-user" }]), + }, }); const startResponse = await app.handle( @@ -390,6 +733,13 @@ describe("auth broker github flow routes", () => { name: "Linear User", email: "linear@example.com", displayName: "Linear User", + organization: { + id: "org-linear", + name: "Hack Dance", + }, + teams: { + nodes: [{ id: "team-linear", name: "Platform" }], + }, }, }, }), @@ -429,6 +779,8 @@ describe("auth broker github flow routes", () => { readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; readonly accountEmail?: string; + readonly betterAuthUserId?: string; + readonly betterAuthLinkState?: string; }; }; expect(claimPayload.ok).toBe(true); @@ -437,6 +789,45 @@ describe("auth broker github flow routes", () => { expect(claimPayload.status.refreshToken).toBe("lin_refresh_token"); expect(claimPayload.status.refreshTokenExpiresAt).toBeDefined(); expect(claimPayload.status.accountEmail).toBe("linear@example.com"); + expect(claimPayload.status.betterAuthUserId).toBe( + "better-auth-linear-user" + ); + expect(claimPayload.status.betterAuthLinkState).toBe("linked_existing"); + + const connectionsResponse = await app.handle( + new Request( + "http://localhost/v1/auth/linear/connections?profileId=work" + ) + ); + expect(connectionsResponse.status).toBe(200); + const connectionsPayload = (await connectionsResponse.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly connections: ReadonlyArray<{ + readonly profileId: string | null; + readonly accountEmail: string | null; + readonly organizationId: string | null; + readonly betterAuthUserId: string | null; + readonly metadata: Record; + }>; + }; + expect(connectionsPayload.ok).toBe(true); + expect(connectionsPayload.accessControlMode).toBe("manual_unenforced"); + expect(connectionsPayload.connections).toHaveLength(1); + expect(connectionsPayload.connections[0]?.profileId).toBe("work"); + expect(connectionsPayload.connections[0]?.accountEmail).toBe( + "linear@example.com" + ); + expect(connectionsPayload.connections[0]?.organizationId).toBe( + "org-linear" + ); + expect(connectionsPayload.connections[0]?.betterAuthUserId).toBe( + "better-auth-linear-user" + ); + expect(connectionsPayload.connections[0]?.metadata).toMatchObject({ + organizationName: "Hack Dance", + teamIds: ["team-linear"], + }); } finally { globalThis.fetch = originalFetch; } diff --git a/src/control-plane/extensions/linear/client.ts b/src/control-plane/extensions/linear/client.ts index 33e00119c..c4e41da5a 100644 --- a/src/control-plane/extensions/linear/client.ts +++ b/src/control-plane/extensions/linear/client.ts @@ -27,6 +27,25 @@ export type LinearProject = { readonly teamName?: string; }; +export type LinearUser = { + readonly id: string; + readonly name?: string; + readonly displayName?: string; + readonly email?: string; + readonly active?: boolean; +}; + +export type LinearComment = { + readonly id: string; + readonly body: string; + readonly createdAt: string; + readonly updatedAt?: string; + readonly userId?: string; + readonly userName?: string; + readonly userDisplayName?: string; + readonly userEmail?: string; +}; + export type LinearIssue = { readonly id: string; readonly identifier: string; @@ -39,6 +58,11 @@ export type LinearIssue = { readonly teamName?: string; readonly projectId?: string; readonly projectName?: string; + readonly assigneeId?: string; + readonly assigneeName?: string; + readonly assigneeDisplayName?: string; + readonly assigneeEmail?: string; + readonly assigneeActive?: boolean; readonly labels: readonly LinearLabel[]; readonly parentId?: string; readonly parentIdentifier?: string; @@ -91,6 +115,7 @@ export type LinearClient = { readonly description?: string; readonly projectId?: string; readonly stateId?: string; + readonly assigneeId?: string; readonly labelIds?: readonly string[]; readonly parentId?: string; }) => Promise>; @@ -100,6 +125,7 @@ export type LinearClient = { readonly description?: string; readonly projectId?: string; readonly stateId?: string; + readonly assigneeId?: string; readonly labelIds?: readonly string[]; readonly parentId?: string; }) => Promise>; @@ -114,6 +140,18 @@ export type LinearClient = { readonly listTeamLabels: (input: { readonly teamId: string; }) => Promise>; + readonly listTeamUsers: (input: { + readonly teamId: string; + readonly first?: number; + }) => Promise>; + readonly listIssueComments: (input: { + readonly issueId: string; + readonly first?: number; + }) => Promise>; + readonly createComment: (input: { + readonly issueId: string; + readonly body: string; + }) => Promise>; }; const DEFAULT_LINEAR_API_URL = "https://api.linear.app/graphql"; @@ -358,6 +396,9 @@ export function createLinearClient(input: { if (input.stateId) { mutationInput.stateId = input.stateId; } + if (input.assigneeId) { + mutationInput.assigneeId = input.assigneeId; + } if (input.labelIds && input.labelIds.length > 0) { mutationInput.labelIds = [...input.labelIds]; } @@ -421,6 +462,9 @@ export function createLinearClient(input: { if (input.stateId !== undefined) { mutationInput.stateId = input.stateId; } + if (input.assigneeId !== undefined) { + mutationInput.assigneeId = input.assigneeId; + } if (input.labelIds !== undefined) { mutationInput.labelIds = [...input.labelIds]; } @@ -592,6 +636,131 @@ export function createLinearClient(input: { data: labels, }; }, + + listTeamUsers: async (input) => { + const teamId = input.teamId.trim(); + if (!teamId) { + return { + ok: false, + status: 400, + error: "Missing team id.", + }; + } + const first = normalizePositiveInt({ + value: input.first, + fallback: DEFAULT_PAGE_SIZE, + }); + const result = await request<{ + readonly team?: unknown; + }>({ + query: [ + "query LinearTeamUsers($teamId: String!, $first: Int!) {", + " team(id: $teamId) {", + " memberships(first: $first) {", + " nodes {", + " user {", + userFieldsSelection({ indent: " " }), + " }", + " }", + " }", + " }", + "}", + ].join("\n"), + variables: { teamId, first }, + }); + if (!result.ok) { + return result; + } + return { + ok: true, + data: parseTeamUsers(result.data.team), + }; + }, + + listIssueComments: async (input) => { + const issueId = input.issueId.trim(); + if (!issueId) { + return { + ok: false, + status: 400, + error: "Missing Linear issue id.", + }; + } + const first = normalizePositiveInt({ + value: input.first, + fallback: DEFAULT_PAGE_SIZE, + }); + const result = await request<{ + readonly issue?: unknown; + }>({ + query: [ + "query LinearIssueComments($issueId: String!, $first: Int!) {", + " issue(id: $issueId) {", + " comments(first: $first) {", + " nodes {", + commentFieldsSelection({ indent: " " }), + " }", + " }", + " }", + "}", + ].join("\n"), + variables: { issueId, first }, + }); + if (!result.ok) { + return result; + } + return { + ok: true, + data: parseIssueComments(result.data.issue), + }; + }, + + createComment: async (input) => { + const issueId = input.issueId.trim(); + const body = input.body.trim(); + if (!(issueId && body)) { + return { + ok: false, + status: 400, + error: "Linear comment creation requires issueId and body.", + }; + } + const result = await request<{ + readonly commentCreate?: unknown; + }>({ + query: [ + "mutation LinearCommentCreate($input: CommentCreateInput!) {", + " commentCreate(input: $input) {", + " success", + " comment {", + commentFieldsSelection({ indent: " " }), + " }", + " }", + "}", + ].join("\n"), + variables: { + input: { + issueId, + body, + }, + }, + }); + if (!result.ok) { + return result; + } + const comment = parseCommentMutationComment(result.data.commentCreate); + if (!comment) { + return { + ok: false, + status: 500, + error: "Linear commentCreate payload missing comment data.", + }; + } + return { + ok: true, + data: comment, + }; + }, }; } @@ -630,6 +799,9 @@ function issueFieldsSelection(): string { " id", " name", "}", + "assignee {", + userFieldsSelection({ indent: " " }), + "}", "labels {", " nodes {", " id", @@ -643,6 +815,24 @@ function issueFieldsSelection(): string { ].join("\n"); } +function userFieldsSelection(input: { readonly indent: string }): string { + return ["id", "name", "displayName", "email", "active"] + .map((field) => `${input.indent}${field}`) + .join("\n"); +} + +function commentFieldsSelection(input: { readonly indent: string }): string { + return [ + `${input.indent}id`, + `${input.indent}body`, + `${input.indent}createdAt`, + `${input.indent}updatedAt`, + `${input.indent}user {`, + userFieldsSelection({ indent: `${input.indent} ` }), + `${input.indent}}`, + ].join("\n"); +} + function parseGraphQlErrors(value: unknown): LinearGraphQlError[] { if (!Array.isArray(value)) { return []; @@ -695,6 +885,21 @@ function parseViewerProjects(value: unknown): LinearProject[] { return out; } +function parseUser(value: unknown): LinearUser | null { + if (!(isRecord(value) && typeof value.id === "string")) { + return null; + } + return { + id: value.id, + ...(typeof value.name === "string" ? { name: value.name } : {}), + ...(typeof value.displayName === "string" + ? { displayName: value.displayName } + : {}), + ...(typeof value.email === "string" ? { email: value.email } : {}), + ...(typeof value.active === "boolean" ? { active: value.active } : {}), + }; +} + function parseProject(value: unknown): LinearProject | null { if (!isRecord(value)) { return null; @@ -719,13 +924,8 @@ function parseIssue(value: unknown): LinearIssue | null { if (!isRecord(value)) { return null; } - if ( - !( - typeof value.id === "string" && - typeof value.identifier === "string" && - typeof value.title === "string" - ) - ) { + const baseIssue = parseIssueBase(value); + if (!baseIssue) { return null; } @@ -735,34 +935,20 @@ function parseIssue(value: unknown): LinearIssue | null { return null; } const project = isRecord(value.project) ? value.project : null; + const assignee = parseUser(value.assignee); const labels = parseLabels(value.labels); const parent = isRecord(value.parent) ? value.parent : null; return { - id: value.id, - identifier: value.identifier, - title: value.title, - ...(typeof value.description === "string" - ? { description: value.description } - : {}), - ...(typeof value.url === "string" ? { url: value.url } : {}), + ...baseIssue, state, teamId: team.id, ...(typeof team.key === "string" ? { teamKey: team.key } : {}), ...(typeof team.name === "string" ? { teamName: team.name } : {}), - ...(project && typeof project.id === "string" - ? { - projectId: project.id, - ...(typeof project.name === "string" - ? { projectName: project.name } - : {}), - } - : {}), + ...parseIssueProjectFields(project), + ...parseIssueAssigneeFields(assignee), labels, - ...(parent && typeof parent.id === "string" ? { parentId: parent.id } : {}), - ...(parent && typeof parent.identifier === "string" - ? { parentIdentifier: parent.identifier } - : {}), + ...parseIssueParentFields(parent), }; } @@ -889,6 +1075,163 @@ function parseTeamLabels(value: unknown): LinearLabel[] { return out; } +function parseTeamUsers(value: unknown): LinearUser[] { + if (!isRecord(value)) { + return []; + } + const memberships = isRecord(value.memberships) ? value.memberships : null; + const nodes = Array.isArray(memberships?.nodes) ? memberships.nodes : []; + const out: LinearUser[] = []; + for (const node of nodes) { + if (!isRecord(node)) { + continue; + } + const user = parseUser(node.user); + if (!user) { + continue; + } + out.push(user); + } + return out; +} + +function parseIssueComments(value: unknown): LinearComment[] { + if (!isRecord(value)) { + return []; + } + const comments = isRecord(value.comments) ? value.comments : null; + const nodes = Array.isArray(comments?.nodes) ? comments.nodes : []; + const out: LinearComment[] = []; + for (const node of nodes) { + const comment = parseComment(node); + if (!comment) { + continue; + } + out.push(comment); + } + return out; +} + +function parseComment(value: unknown): LinearComment | null { + if (!(isRecord(value) && typeof value.id === "string")) { + return null; + } + if ( + !(typeof value.body === "string" && typeof value.createdAt === "string") + ) { + return null; + } + const user = parseUser(value.user); + return { + id: value.id, + body: value.body, + createdAt: value.createdAt, + ...(typeof value.updatedAt === "string" + ? { updatedAt: value.updatedAt } + : {}), + ...(user + ? { + userId: user.id, + ...(typeof user.name === "string" ? { userName: user.name } : {}), + ...(typeof user.displayName === "string" + ? { userDisplayName: user.displayName } + : {}), + ...(typeof user.email === "string" ? { userEmail: user.email } : {}), + } + : {}), + }; +} + +function parseCommentMutationComment(value: unknown): LinearComment | null { + if (!isRecord(value)) { + return null; + } + const success = value.success; + if (typeof success === "boolean" && !success) { + return null; + } + return parseComment(value.comment); +} + +function parseIssueBase(value: Record): { + readonly id: string; + readonly identifier: string; + readonly title: string; + readonly description?: string; + readonly url?: string; +} | null { + if ( + !( + typeof value.id === "string" && + typeof value.identifier === "string" && + typeof value.title === "string" + ) + ) { + return null; + } + return { + id: value.id, + identifier: value.identifier, + title: value.title, + ...(typeof value.description === "string" + ? { description: value.description } + : {}), + ...(typeof value.url === "string" ? { url: value.url } : {}), + }; +} + +function parseIssueProjectFields( + value: Record | null +): Pick { + if (!(value && typeof value.id === "string")) { + return {}; + } + return { + projectId: value.id, + ...(typeof value.name === "string" ? { projectName: value.name } : {}), + }; +} + +function parseIssueAssigneeFields( + value: LinearUser | null +): Pick< + LinearIssue, + | "assigneeId" + | "assigneeName" + | "assigneeDisplayName" + | "assigneeEmail" + | "assigneeActive" +> { + if (!value) { + return {}; + } + return { + assigneeId: value.id, + ...(typeof value.name === "string" ? { assigneeName: value.name } : {}), + ...(typeof value.displayName === "string" + ? { assigneeDisplayName: value.displayName } + : {}), + ...(typeof value.email === "string" ? { assigneeEmail: value.email } : {}), + ...(typeof value.active === "boolean" + ? { assigneeActive: value.active } + : {}), + }; +} + +function parseIssueParentFields( + value: Record | null +): Pick { + if (!value) { + return {}; + } + return { + ...(typeof value.id === "string" ? { parentId: value.id } : {}), + ...(typeof value.identifier === "string" + ? { parentIdentifier: value.identifier } + : {}), + }; +} + function isLinearWorkflowStateType( value: unknown ): value is LinearWorkflowStateType { diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index 402ad1e87..473d65bed 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -11,6 +11,8 @@ import { openUrl } from "../../../lib/os.ts"; import { display } from "../../../ui/display.ts"; import { createTicketsStore, + type TicketComment, + type TicketMetadataValue, type TicketStatus, type TicketSummary, } from "../tickets/store.ts"; @@ -26,7 +28,9 @@ import { } from "./auth.ts"; import { createLinearClient, + type LinearComment, type LinearIssue, + type LinearUser, type LinearWorkflowState, type LinearWorkflowStateType, } from "./client.ts"; @@ -48,6 +52,7 @@ const DEFAULT_OAUTH_SECRET_SERVICE = "hack-linear-auth"; const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; const OAUTH_POLL_INTERVAL_MS = 1000; const DEFAULT_PROJECT_SYNC_LIMIT = 100; +const DEFAULT_DELIVERY_LIST_LIMIT = 50; const TRAILING_SLASH_REGEX = /\/+$/; export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ @@ -789,6 +794,91 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 0; }, }, + { + name: "deliveries", + summary: + "List pending/applied Linear webhook deliveries from the auth broker", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseDeliveriesArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const deliveries = await listLinearDeliveries({ + controlPlaneConfig: ctx.controlPlaneConfig, + status: parsed.value.status, + limit: parsed.value.limit, + }); + if (!deliveries.ok) { + ctx.logger.error({ message: deliveries.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(deliveries.data, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear deliveries", + entries: [ + ["status", deliveries.data.status ?? "all"], + ["count", String(deliveries.data.deliveries.length)], + ], + }); + await display.table({ + columns: ["Delivery ID", "Status", "Event", "Action", "Received At"], + rows: deliveries.data.deliveries.map((delivery) => [ + delivery.id, + delivery.status, + delivery.eventType ?? "", + delivery.action ?? "", + delivery.receivedAt ?? "", + ]), + }); + return 0; + }, + }, + { + name: "apply-delivery", + summary: + "Mark a pending Linear webhook delivery as applied after manual review", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseApplyDeliveryArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const applied = await applyLinearDelivery({ + controlPlaneConfig: ctx.controlPlaneConfig, + deliveryId: parsed.value.deliveryId, + }); + if (!applied.ok) { + ctx.logger.error({ message: applied.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(applied.data, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear delivery applied", + entries: [ + ["delivery_id", applied.data.deliveryId], + ["status", applied.data.status], + ], + }); + return 0; + }, + }, ]; type LinearCommandContext = ExtensionCommandContext; @@ -1305,9 +1395,38 @@ async function launchLinearOAuthBrowser(input: { return { ok: true }; } +type TicketSyncStore = Pick< + ReturnType, + | "appendComment" + | "createTicket" + | "getTicket" + | "getTicketDetail" + | "linkCommentExternalId" + | "listTickets" + | "recordSyncCheckpoint" + | "recordSyncConflict" + | "setStatus" + | "updateTicket" +>; + +type LinearSyncClient = Pick< + ReturnType, + | "createComment" + | "createIssue" + | "getIssueById" + | "getIssueByIdentifier" + | "getProject" + | "listIssueComments" + | "listProjectIssuesPage" + | "listTeamLabels" + | "listTeamStates" + | "listTeamUsers" + | "updateIssue" +>; + type SyncRuntime = { - readonly tickets: ReturnType; - readonly linear: ReturnType; + readonly tickets: TicketSyncStore; + readonly linear: LinearSyncClient; readonly profileId: string; readonly apiUrl: string; readonly projectBinding: ReturnType; @@ -1320,6 +1439,72 @@ type SyncToggles = { readonly projects: boolean; }; +type LinearDeliveryStatus = "pending" | "applied" | "ignored"; + +type DeliveriesArgs = { + status?: LinearDeliveryStatus; + limit?: number; + json: boolean; +}; + +type ApplyDeliveryArgs = { + deliveryId: string; + json: boolean; +}; + +type SyncAssigneeResolution = { + readonly requestedAssignee?: string; + readonly matchedUserId?: string; + readonly matchedUserDisplayName?: string; + readonly applied: boolean; +}; + +type LinearToHackRuntime = { + readonly tickets: Pick< + TicketSyncStore, + | "appendComment" + | "createTicket" + | "getTicketDetail" + | "listTickets" + | "recordSyncCheckpoint" + | "recordSyncConflict" + | "setStatus" + | "updateTicket" + >; + readonly linear: Pick< + LinearSyncClient, + "getIssueByIdentifier" | "listIssueComments" + >; + readonly profileId: string; +}; + +type HackToLinearRuntime = { + readonly tickets: Pick< + TicketSyncStore, + | "getTicket" + | "getTicketDetail" + | "linkCommentExternalId" + | "recordSyncCheckpoint" + | "recordSyncConflict" + | "updateTicket" + >; + readonly linear: Pick< + LinearSyncClient, + | "createComment" + | "createIssue" + | "getIssueById" + | "getIssueByIdentifier" + | "getProject" + | "listIssueComments" + | "listTeamLabels" + | "listTeamStates" + | "listTeamUsers" + | "updateIssue" + >; + readonly profileId: string; + readonly projectBinding: ReturnType; +}; + async function createSyncRuntime(input: { readonly ctx: ExtensionCommandContext; readonly profileId?: string; @@ -1369,18 +1554,481 @@ async function createSyncRuntime(input: { }; } -async function syncIssueFromLinearToTicket(input: { - readonly runtime: SyncRuntime; +type RecordedSyncConflict = { + readonly field: string; + readonly authority: "hack" | "linear"; + readonly summary: string; + readonly localValue?: TicketMetadataValue; + readonly remoteValue?: TicketMetadataValue; +}; + +type SyncTicketFromLinearSuccess = { + readonly ok: true; + readonly operation: "created" | "updated"; + readonly ticketId: string; readonly issueIdentifier: string; - readonly syncToggles: SyncToggles; + readonly commentsPulled: number; + readonly conflictsRecorded: number; + readonly checkpointRecorded: boolean; +}; + +type SyncTicketToLinearSuccess = { + readonly ok: true; + readonly operation: "created" | "updated"; + readonly ticketId: string; + readonly issueIdentifier: string; + readonly issueId: string; + readonly commentsPushed: number; + readonly conflictsRecorded: number; + readonly checkpointRecorded: boolean; + readonly assignee: SyncAssigneeResolution; +}; + +type LinearDeliverySummary = { + readonly id: string; + readonly status: string; + readonly eventType?: string; + readonly action?: string; + readonly receivedAt?: string; + readonly updatedAt?: string; + readonly payload?: unknown; +}; + +function resolveTicketAuthority(input: { + readonly ticket: TicketSummary; +}): "hack" | "linear" { + const owner = input.ticket.owner.trim().toLowerCase(); + const source = input.ticket.source.trim().toLowerCase(); + if (owner === "linear" || source === "linear") { + return "linear"; + } + return "hack"; +} + +function normalizeBodyForConflictComparison(input: { + readonly body?: string; +}): string { + const body = (input.body ?? "").trim(); + const markerIndex = body.indexOf("\n\n---\n\nLinear Issue:"); + if (markerIndex !== -1) { + return body.slice(0, markerIndex).trim(); + } + return body; +} + +function normalizeCommentBody(input: { readonly body: string }): string { + return input.body.trim().replaceAll("\r\n", "\n"); +} + +function normalizeProjectValue(input: { + readonly projectId?: string; + readonly projectName?: string; +}): TicketMetadataValue | undefined { + const projectId = readOptionalString(input.projectId); + const projectName = readOptionalString(input.projectName); + if (!(projectId || projectName)) { + return undefined; + } + return { + ...(projectId ? { projectId } : {}), + ...(projectName ? { projectName } : {}), + }; +} + +function detectAuthoritativeFieldConflicts(input: { + readonly authority: "hack" | "linear"; + readonly ticket: TicketSummary; + readonly issue: LinearIssue; + readonly remoteProjection: Pick< + TicketProjectionFromLinearIssue, + "body" | "status" + >; +}): readonly RecordedSyncConflict[] { + const conflicts: RecordedSyncConflict[] = []; + const localTitle = input.ticket.title.trim(); + const remoteTitle = input.issue.title.trim(); + if (localTitle !== remoteTitle) { + conflicts.push({ + field: "title", + authority: input.authority, + summary: `Authoritative ${input.authority} title diverged from the other side.`, + localValue: localTitle, + remoteValue: remoteTitle, + }); + } + + const localBody = normalizeBodyForConflictComparison({ + body: input.ticket.body, + }); + const remoteBody = normalizeBodyForConflictComparison({ + body: input.remoteProjection.body, + }); + if (localBody !== remoteBody) { + conflicts.push({ + field: "body", + authority: input.authority, + summary: `Authoritative ${input.authority} body diverged from the other side.`, + localValue: localBody, + remoteValue: remoteBody, + }); + } + + if (input.ticket.status !== input.remoteProjection.status) { + conflicts.push({ + field: "status", + authority: input.authority, + summary: `Authoritative ${input.authority} status diverged from the other side.`, + localValue: input.ticket.status, + remoteValue: input.remoteProjection.status, + }); + } + + const localProject = normalizeProjectValue({ + projectId: input.ticket.projectId, + projectName: input.ticket.projectName, + }); + const remoteProject = normalizeProjectValue({ + projectId: input.issue.projectId, + projectName: input.issue.projectName, + }); + if ( + JSON.stringify(localProject ?? null) !== + JSON.stringify(remoteProject ?? null) + ) { + conflicts.push({ + field: "project", + authority: input.authority, + summary: `Authoritative ${input.authority} project routing diverged from the other side.`, + ...(localProject !== undefined ? { localValue: localProject } : {}), + ...(remoteProject !== undefined ? { remoteValue: remoteProject } : {}), + }); + } + + return conflicts; +} + +function selectLinearCommentsToAppend(input: { + readonly localComments: readonly TicketComment[]; + readonly remoteComments: readonly LinearComment[]; +}): readonly LinearComment[] { + const existingExternalIds = new Set( + input.localComments + .map((comment) => readOptionalString(comment.externalId)) + .filter((value): value is string => value !== undefined) + ); + const existingBodies = new Set( + input.localComments + .map((comment) => normalizeCommentBody({ body: comment.body })) + .filter((value) => value.length > 0) + ); + + return input.remoteComments.filter((comment) => { + if (existingExternalIds.has(comment.id)) { + return false; + } + const normalizedBody = normalizeCommentBody({ body: comment.body }); + if (!normalizedBody) { + return false; + } + return !existingBodies.has(normalizedBody); + }); +} + +function selectTicketCommentsToPush(input: { + readonly localComments: readonly TicketComment[]; + readonly remoteComments: readonly LinearComment[]; +}): readonly TicketComment[] { + const remoteBodies = new Set( + input.remoteComments + .map((comment) => normalizeCommentBody({ body: comment.body })) + .filter((value) => value.length > 0) + ); + + return input.localComments.filter((comment) => { + const source = comment.source.trim().toLowerCase(); + if (source === "linear") { + return false; + } + if (readOptionalString(comment.externalId)) { + return false; + } + const normalizedBody = normalizeCommentBody({ body: comment.body }); + if (!normalizedBody) { + return false; + } + return !remoteBodies.has(normalizedBody); + }); +} + +function deriveTicketAssigneeFromLinearIssue(input: { + readonly issue: LinearIssue; +}): string | undefined { + return ( + readOptionalString(input.issue.assigneeDisplayName) ?? + readOptionalString(input.issue.assigneeEmail) ?? + readOptionalString(input.issue.assigneeName) + ); +} + +function resolveLinearAssigneeMatch(input: { + readonly assignee?: string; + readonly users: readonly LinearUser[]; +}): LinearUser | null { + const requested = readOptionalString(input.assignee)?.toLowerCase(); + if (!requested) { + return null; + } + + const match = + input.users.find( + (user) => readOptionalString(user.email)?.toLowerCase() === requested + ) ?? + input.users.find( + (user) => + readOptionalString(user.displayName)?.toLowerCase() === requested + ) ?? + input.users.find( + (user) => readOptionalString(user.name)?.toLowerCase() === requested + ); + + return match ?? null; +} + +function buildConflictDedupKey(input: { + readonly field: string; + readonly localValue?: TicketMetadataValue; + readonly remoteValue?: TicketMetadataValue; +}): string { + return [ + input.field, + JSON.stringify(input.localValue ?? null), + JSON.stringify(input.remoteValue ?? null), + ].join("|"); +} + +async function recordAuthoritativeConflicts(input: { + readonly tickets: Pick< + TicketSyncStore, + "getTicketDetail" | "recordSyncConflict" + >; + readonly ticketId: string; + readonly conflicts: readonly RecordedSyncConflict[]; }): Promise< - | { - readonly ok: true; - readonly operation: "created" | "updated"; - readonly ticketId: string; - readonly issueIdentifier: string; + | { readonly ok: true; readonly recorded: number } + | { readonly ok: false; readonly error: string } +> { + if (input.conflicts.length === 0) { + return { ok: true, recorded: 0 }; + } + + const detail = await input.tickets.getTicketDetail({ + ticketId: input.ticketId, + }); + const existing = new Set( + detail.conflicts + .filter((conflict) => conflict.status === "open") + .map((conflict) => + buildConflictDedupKey({ + field: conflict.field, + localValue: conflict.localValue, + remoteValue: conflict.remoteValue, + }) + ) + ); + + let recorded = 0; + for (const conflict of input.conflicts) { + const key = buildConflictDedupKey(conflict); + if (existing.has(key)) { + continue; + } + const result = await input.tickets.recordSyncConflict({ + ticketId: input.ticketId, + provider: "linear", + field: conflict.field, + authority: conflict.authority, + summary: conflict.summary, + ...(conflict.localValue !== undefined + ? { localValue: conflict.localValue } + : {}), + ...(conflict.remoteValue !== undefined + ? { remoteValue: conflict.remoteValue } + : {}), + }); + if (!result.ok) { + return { ok: false, error: result.error }; + } + existing.add(key); + recorded += 1; + } + + return { ok: true, recorded }; +} + +async function pullLinearCommentsToTicket(input: { + readonly runtime: Pick; + readonly ticketId: string; + readonly issue: LinearIssue; +}): Promise< + | { readonly ok: true; readonly commentsPulled: number } + | { readonly ok: false; readonly error: string } +> { + const detail = await input.runtime.tickets.getTicketDetail({ + ticketId: input.ticketId, + }); + const commentsResult = await input.runtime.linear.listIssueComments({ + issueId: input.issue.id, + }); + if (!commentsResult.ok) { + return { ok: false, error: commentsResult.error }; + } + + const selected = selectLinearCommentsToAppend({ + localComments: detail.comments, + remoteComments: commentsResult.data, + }); + + for (const comment of selected) { + const appended = await input.runtime.tickets.appendComment({ + ticketId: input.ticketId, + body: comment.body, + source: "linear", + actor: + readOptionalString(comment.userDisplayName) ?? + readOptionalString(comment.userName) ?? + readOptionalString(comment.userEmail) ?? + "linear", + externalId: comment.id, + }); + if (!appended.ok) { + return { ok: false, error: appended.error }; + } + } + + return { ok: true, commentsPulled: selected.length }; +} + +async function pushTicketCommentsToLinear(input: { + readonly runtime: Pick; + readonly ticketId: string; + readonly issueId: string; +}): Promise< + | { readonly ok: true; readonly commentsPushed: number } + | { readonly ok: false; readonly error: string } +> { + const detail = await input.runtime.tickets.getTicketDetail({ + ticketId: input.ticketId, + }); + const remoteComments = await input.runtime.linear.listIssueComments({ + issueId: input.issueId, + }); + if (!remoteComments.ok) { + return { ok: false, error: remoteComments.error }; + } + + const selected = selectTicketCommentsToPush({ + localComments: detail.comments, + remoteComments: remoteComments.data, + }); + + for (const comment of selected) { + const created = await input.runtime.linear.createComment({ + issueId: input.issueId, + body: comment.body, + }); + if (!created.ok) { + return { ok: false, error: created.error }; + } + const linked = await input.runtime.tickets.linkCommentExternalId({ + ticketId: input.ticketId, + commentId: comment.commentId, + externalId: created.data.id, + }); + if (!linked.ok) { + return { ok: false, error: linked.error }; } + } + + return { ok: true, commentsPushed: selected.length }; +} + +async function recordLinearSyncCheckpoint(input: { + readonly tickets: Pick; + readonly ticketId: string; + readonly profileId: string; + readonly direction: string; + readonly issue: LinearIssue; +}): Promise< + | { readonly ok: true; readonly checkpointRecorded: boolean } | { readonly ok: false; readonly error: string } +> { + const checkpoint = await input.tickets.recordSyncCheckpoint({ + ticketId: input.ticketId, + provider: "linear", + profileId: input.profileId, + direction: input.direction, + remoteCursor: input.issue.identifier, + localUpdatedAt: new Date().toISOString(), + }); + if (!checkpoint.ok) { + return { ok: false, error: checkpoint.error }; + } + return { ok: true, checkpointRecorded: true }; +} + +async function resolveTicketAssigneeForLinear(input: { + readonly runtime: Pick; + readonly ticket: TicketSummary; + readonly teamId: string; +}): Promise { + const requestedAssignee = readOptionalString(input.ticket.assignee); + if (!requestedAssignee) { + return { applied: false }; + } + + const users = await input.runtime.linear.listTeamUsers({ + teamId: input.teamId, + }); + if (!users.ok) { + return { + requestedAssignee, + applied: false, + }; + } + const match = resolveLinearAssigneeMatch({ + assignee: requestedAssignee, + users: users.data, + }); + return { + requestedAssignee, + ...(match?.id ? { matchedUserId: match.id } : {}), + ...(readOptionalString(match?.displayName ?? match?.name) + ? { + matchedUserDisplayName: + readOptionalString(match?.displayName ?? match?.name) ?? undefined, + } + : {}), + applied: Boolean(match?.id), + }; +} + +type BrokerListDeliveriesPayload = { + readonly status?: string; + readonly limit?: number; + readonly deliveries: readonly LinearDeliverySummary[]; +}; + +type BrokerApplyDeliveryPayload = { + readonly deliveryId: string; + readonly status: string; +}; + +async function syncIssueFromLinearToTicket(input: { + readonly runtime: LinearToHackRuntime; + readonly issueIdentifier: string; + readonly syncToggles: SyncToggles; +}): Promise< + SyncTicketFromLinearSuccess | { readonly ok: false; readonly error: string } > { const issueResult = await input.runtime.linear.getIssueByIdentifier({ identifier: input.issueIdentifier, @@ -1406,20 +2054,13 @@ async function syncIssueFromLinearToTicket(input: { } async function syncTicketToLinearIssue(input: { - readonly runtime: SyncRuntime; + readonly runtime: HackToLinearRuntime; readonly ticketId: string; readonly explicitProjectId?: string; readonly explicitTeamId?: string; readonly syncToggles: SyncToggles; }): Promise< - | { - readonly ok: true; - readonly operation: "created" | "updated"; - readonly ticketId: string; - readonly issueIdentifier: string; - readonly issueId: string; - } - | { readonly ok: false; readonly error: string } + SyncTicketToLinearSuccess | { readonly ok: false; readonly error: string } > { const ticket = await input.runtime.tickets.getTicket({ ticketId: input.ticketId, @@ -1457,13 +2098,71 @@ async function syncTicketToLinearIssue(input: { return fields; } + const authority = existingIssue.issue + ? resolveTicketAuthority({ ticket }) + : "hack"; + + const assignee = await resolveTicketAssigneeForLinear({ + runtime: input.runtime, + ticket, + teamId: target.value.teamId, + }); + + const conflicts = existingIssue.issue + ? detectAuthoritativeFieldConflicts({ + authority, + ticket, + issue: existingIssue.issue, + remoteProjection: buildTicketProjectionFromLinearIssue({ + issue: existingIssue.issue, + existingTicket: ticket, + syncToggles: input.syncToggles, + dependencyIndex: { + byLinearId: new Map(), + byLinearIdentifier: new Map(), + }, + }), + }) + : []; + + const recordedConflicts = existingIssue.issue + ? await recordAuthoritativeConflicts({ + tickets: input.runtime.tickets, + ticketId: ticket.ticketId, + conflicts, + }) + : { ok: true as const, recorded: 0 }; + if (!recordedConflicts.ok) { + return { ok: false, error: recordedConflicts.error }; + } + + const effectiveFields = + authority === "linear" && existingIssue.issue + ? { + ...fields.value, + title: existingIssue.issue.title, + description: existingIssue.issue.description ?? "", + ...(existingIssue.issue.assigneeId + ? { assigneeId: existingIssue.issue.assigneeId } + : {}), + ...(input.syncToggles.statuses + ? { stateId: existingIssue.issue.state.id } + : {}), + } + : fields.value; + + const effectiveProjectId = + authority === "linear" && existingIssue.issue + ? existingIssue.issue.projectId + : target.value.projectId; + const syncedIssue = await upsertLinearIssueForTicketSync({ runtime: input.runtime, ticket, existingIssue: existingIssue.issue, - fields: fields.value, + fields: effectiveFields, teamId: target.value.teamId, - projectId: target.value.projectId, + projectId: effectiveProjectId, syncToggles: input.syncToggles, }); @@ -1485,12 +2184,36 @@ async function syncTicketToLinearIssue(input: { return { ok: false, error: updated.error }; } + const pushedComments = await pushTicketCommentsToLinear({ + runtime: input.runtime, + ticketId: ticket.ticketId, + issueId: syncedIssue.data.id, + }); + if (!pushedComments.ok) { + return { ok: false, error: pushedComments.error }; + } + + const checkpoint = await recordLinearSyncCheckpoint({ + tickets: input.runtime.tickets, + ticketId: ticket.ticketId, + profileId: input.runtime.profileId, + direction: "hack_to_linear", + issue: syncedIssue.data, + }); + if (!checkpoint.ok) { + return { ok: false, error: checkpoint.error }; + } + return { ok: true, operation: existingIssue.issue ? "updated" : "created", ticketId: ticket.ticketId, issueIdentifier: syncedIssue.data.identifier, issueId: syncedIssue.data.id, + commentsPushed: pushedComments.commentsPushed, + conflictsRecorded: recordedConflicts.recorded, + checkpointRecorded: checkpoint.checkpointRecorded, + assignee, }; } @@ -1548,6 +2271,7 @@ type LinearTicketMutationFields = { readonly title: string; readonly description: string; readonly stateId?: string; + readonly assigneeId?: string; readonly labelIds?: readonly string[]; readonly parentId?: string; }; @@ -1598,12 +2322,19 @@ async function resolveLinearMutationFieldsForTicketSync(input: { }; } + const assignee = await resolveTicketAssigneeForLinear({ + runtime: input.runtime, + ticket: input.ticket, + teamId: input.teamId, + }); + return { ok: true, value: { title, description: input.ticket.body ?? "", ...(stateId.value ? { stateId: stateId.value } : {}), + ...(assignee.matchedUserId ? { assigneeId: assignee.matchedUserId } : {}), ...(labelIds.value ? { labelIds: labelIds.value } : {}), ...(parentId.value !== undefined ? { parentId: parentId.value } : {}), }, @@ -1626,6 +2357,9 @@ async function upsertLinearIssueForTicketSync(input: { description: input.fields.description, ...(input.syncToggles.projects ? { projectId: input.projectId } : {}), ...(input.fields.stateId ? { stateId: input.fields.stateId } : {}), + ...(input.fields.assigneeId + ? { assigneeId: input.fields.assigneeId } + : {}), ...(input.fields.labelIds ? { labelIds: input.fields.labelIds } : {}), ...(input.fields.parentId !== undefined ? { parentId: input.fields.parentId } @@ -1639,6 +2373,7 @@ async function upsertLinearIssueForTicketSync(input: { description: input.fields.description, ...(input.syncToggles.projects ? { projectId: input.projectId } : {}), ...(input.fields.stateId ? { stateId: input.fields.stateId } : {}), + ...(input.fields.assigneeId ? { assigneeId: input.fields.assigneeId } : {}), ...(input.fields.labelIds ? { labelIds: input.fields.labelIds } : {}), ...(input.fields.parentId !== undefined ? { parentId: input.fields.parentId } @@ -1657,6 +2392,9 @@ async function syncProjectFromLinearToTickets(input: { readonly processed: number; readonly created: number; readonly updated: number; + readonly commentsPulled: number; + readonly conflictsRecorded: number; + readonly checkpointsRecorded: number; } | { readonly ok: false; readonly error: string } > { @@ -1704,6 +2442,9 @@ async function syncProjectFromLinearToTickets(input: { processed: issuesResult.processed, created: upserted.created, updated: upserted.updated, + commentsPulled: upserted.commentsPulled, + conflictsRecorded: upserted.conflictsRecorded, + checkpointsRecorded: upserted.checkpointsRecorded, }; } @@ -1755,11 +2496,21 @@ async function upsertLinearProjectIssuesToTickets(input: { readonly byLinearIdentifier: Map; }; }): Promise< - | { readonly ok: true; readonly created: number; readonly updated: number } + | { + readonly ok: true; + readonly created: number; + readonly updated: number; + readonly commentsPulled: number; + readonly conflictsRecorded: number; + readonly checkpointsRecorded: number; + } | { readonly ok: false; readonly error: string } > { let created = 0; let updated = 0; + let commentsPulled = 0; + let conflictsRecorded = 0; + let checkpointsRecorded = 0; for (const issue of input.issues) { const synced = await upsertTicketFromLinearIssue({ @@ -1776,6 +2527,9 @@ async function upsertLinearProjectIssuesToTickets(input: { } else { updated += 1; } + commentsPulled += synced.commentsPulled; + conflictsRecorded += synced.conflictsRecorded; + checkpointsRecorded += synced.checkpointRecorded ? 1 : 0; input.dependencyIndex.byLinearId.set(issue.id, synced.ticketId); input.dependencyIndex.byLinearIdentifier.set( issue.identifier, @@ -1787,6 +2541,9 @@ async function upsertLinearProjectIssuesToTickets(input: { ok: true, created, updated, + commentsPulled, + conflictsRecorded, + checkpointsRecorded, }; } @@ -1834,6 +2591,9 @@ async function syncProjectFromTicketsToLinear(input: { readonly processed: number; readonly created: number; readonly updated: number; + readonly commentsPushed: number; + readonly conflictsRecorded: number; + readonly checkpointsRecorded: number; } | { readonly ok: false; readonly error: string } > { @@ -1853,6 +2613,9 @@ async function syncProjectFromTicketsToLinear(input: { let created = 0; let updated = 0; + let commentsPushed = 0; + let conflictsRecorded = 0; + let checkpointsRecorded = 0; for (const ticket of candidates) { const synced = await syncTicketToLinearIssue({ @@ -1870,6 +2633,9 @@ async function syncProjectFromTicketsToLinear(input: { } else { updated += 1; } + commentsPushed += synced.commentsPushed; + conflictsRecorded += synced.conflictsRecorded; + checkpointsRecorded += synced.checkpointRecorded ? 1 : 0; } return { @@ -1877,11 +2643,14 @@ async function syncProjectFromTicketsToLinear(input: { processed: candidates.length, created, updated, + commentsPushed, + conflictsRecorded, + checkpointsRecorded, }; } async function upsertTicketFromLinearIssue(input: { - readonly runtime: SyncRuntime; + readonly runtime: LinearToHackRuntime; readonly issue: LinearIssue; readonly syncToggles: SyncToggles; readonly dependencyIndex: { @@ -1889,13 +2658,7 @@ async function upsertTicketFromLinearIssue(input: { readonly byLinearIdentifier: Map; }; }): Promise< - | { - readonly ok: true; - readonly operation: "created" | "updated"; - readonly ticketId: string; - readonly issueIdentifier: string; - } - | { readonly ok: false; readonly error: string } + SyncTicketFromLinearSuccess | { readonly ok: false; readonly error: string } > { const snapshot = await input.runtime.tickets.listTickets(); const existing = snapshot.find((ticket) => @@ -1909,20 +2672,39 @@ async function upsertTicketFromLinearIssue(input: { dependencyIndex: input.dependencyIndex, }); - return existing - ? await applyLinearIssueToExistingTicket({ - runtime: input.runtime, - issue: input.issue, - existingTicket: existing, - projection, - syncToggles: input.syncToggles, - }) - : await createTicketFromLinearIssueProjection({ - runtime: input.runtime, + if (existing) { + const authority = resolveTicketAuthority({ ticket: existing }); + const recordedConflicts = await recordAuthoritativeConflicts({ + tickets: input.runtime.tickets, + ticketId: existing.ticketId, + conflicts: detectAuthoritativeFieldConflicts({ + authority, + ticket: existing, issue: input.issue, - projection, - syncToggles: input.syncToggles, - }); + remoteProjection: projection, + }), + }); + if (!recordedConflicts.ok) { + return { ok: false, error: recordedConflicts.error }; + } + + return await applyLinearIssueToExistingTicket({ + runtime: input.runtime, + issue: input.issue, + existingTicket: existing, + projection, + syncToggles: input.syncToggles, + authority, + conflictsRecorded: recordedConflicts.recorded, + }); + } + + return await createTicketFromLinearIssueProjection({ + runtime: input.runtime, + issue: input.issue, + projection, + syncToggles: input.syncToggles, + }); } type TicketProjectionFromLinearIssue = { @@ -1984,26 +2766,26 @@ function resolveLinearParentTicketId(input: { } async function applyLinearIssueToExistingTicket(input: { - readonly runtime: SyncRuntime; + readonly runtime: LinearToHackRuntime; readonly issue: LinearIssue; readonly existingTicket: TicketSummary; readonly projection: TicketProjectionFromLinearIssue; readonly syncToggles: SyncToggles; + readonly authority: "hack" | "linear"; + readonly conflictsRecorded: number; }): Promise< - | { - readonly ok: true; - readonly operation: "updated"; - readonly ticketId: string; - readonly issueIdentifier: string; - } - | { readonly ok: false; readonly error: string } + SyncTicketFromLinearSuccess | { readonly ok: false; readonly error: string } > { + const resolvedAssignee = deriveTicketAssigneeFromLinearIssue({ + issue: input.issue, + }); const updated = await input.runtime.tickets.updateTicket({ ticketId: input.existingTicket.ticketId, - title: input.issue.title, - body: input.projection.body, - owner: "linear", - source: "linear", + ...(input.authority === "linear" ? { title: input.issue.title } : {}), + ...(input.authority === "linear" ? { body: input.projection.body } : {}), + ...(input.authority === "linear" ? { owner: "linear" as const } : {}), + ...(input.authority === "linear" ? { source: "linear" as const } : {}), + ...(resolvedAssignee ? { assignee: resolvedAssignee } : {}), tags: input.projection.tags, ...(input.projection.dependsOn !== undefined ? { dependsOn: input.projection.dependsOn } @@ -2020,7 +2802,7 @@ async function applyLinearIssueToExistingTicket(input: { return { ok: false, error: updated.error }; } - if (input.syncToggles.statuses) { + if (input.authority === "linear" && input.syncToggles.statuses) { const setStatus = await input.runtime.tickets.setStatus({ ticketId: input.existingTicket.ticketId, status: input.projection.status, @@ -2030,33 +2812,54 @@ async function applyLinearIssueToExistingTicket(input: { } } + const pulledComments = await pullLinearCommentsToTicket({ + runtime: input.runtime, + ticketId: input.existingTicket.ticketId, + issue: input.issue, + }); + if (!pulledComments.ok) { + return { ok: false, error: pulledComments.error }; + } + + const checkpoint = await recordLinearSyncCheckpoint({ + tickets: input.runtime.tickets, + ticketId: input.existingTicket.ticketId, + profileId: input.runtime.profileId, + direction: "linear_to_hack", + issue: input.issue, + }); + if (!checkpoint.ok) { + return { ok: false, error: checkpoint.error }; + } + return { ok: true, operation: "updated", ticketId: input.existingTicket.ticketId, issueIdentifier: input.issue.identifier, + commentsPulled: pulledComments.commentsPulled, + conflictsRecorded: input.conflictsRecorded, + checkpointRecorded: checkpoint.checkpointRecorded, }; } async function createTicketFromLinearIssueProjection(input: { - readonly runtime: SyncRuntime; + readonly runtime: LinearToHackRuntime; readonly issue: LinearIssue; readonly projection: TicketProjectionFromLinearIssue; readonly syncToggles: SyncToggles; }): Promise< - | { - readonly ok: true; - readonly operation: "created"; - readonly ticketId: string; - readonly issueIdentifier: string; - } - | { readonly ok: false; readonly error: string } + SyncTicketFromLinearSuccess | { readonly ok: false; readonly error: string } > { + const resolvedAssignee = deriveTicketAssigneeFromLinearIssue({ + issue: input.issue, + }); const created = await input.runtime.tickets.createTicket({ title: input.issue.title, body: input.projection.body, owner: "linear", source: "linear", + ...(resolvedAssignee ? { assignee: resolvedAssignee } : {}), tags: input.projection.tags, ...(input.projection.dependsOn !== undefined ? { dependsOn: input.projection.dependsOn } @@ -2083,16 +2886,39 @@ async function createTicketFromLinearIssueProjection(input: { } } + const pulledComments = await pullLinearCommentsToTicket({ + runtime: input.runtime, + ticketId: created.ticket.ticketId, + issue: input.issue, + }); + if (!pulledComments.ok) { + return { ok: false, error: pulledComments.error }; + } + + const checkpoint = await recordLinearSyncCheckpoint({ + tickets: input.runtime.tickets, + ticketId: created.ticket.ticketId, + profileId: input.runtime.profileId, + direction: "linear_to_hack", + issue: input.issue, + }); + if (!checkpoint.ok) { + return { ok: false, error: checkpoint.error }; + } + return { ok: true, operation: "created", ticketId: created.ticket.ticketId, issueIdentifier: input.issue.identifier, + commentsPulled: pulledComments.commentsPulled, + conflictsRecorded: 0, + checkpointRecorded: checkpoint.checkpointRecorded, }; } async function buildLinearDependencyIndex(input: { - readonly tickets: ReturnType; + readonly tickets: Pick; }): Promise<{ readonly byLinearId: Map; readonly byLinearIdentifier: Map; @@ -2932,16 +3758,20 @@ function buildAuthorizeUrl(input: { async function fetchJson(input: { readonly url: string; + readonly init?: RequestInit; }): Promise< | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string } > { try { const response = await fetch(input.url, { + method: input.init?.method, headers: { accept: "application/json", "user-agent": "hack-cli", + ...(input.init?.headers ?? {}), }, + body: input.init?.body, }); const bodyText = await response.text(); const payload = bodyText ? (JSON.parse(bodyText) as unknown) : null; @@ -2964,6 +3794,196 @@ async function fetchJson(input: { } } +async function listLinearDeliveries(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly status?: LinearDeliveryStatus; + readonly limit?: number; +}): Promise< + | { readonly ok: true; readonly data: BrokerListDeliveriesPayload } + | { readonly ok: false; readonly error: string } +> { + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const normalizedLimit = normalizePositiveInteger({ + value: input.limit, + fallback: DEFAULT_DELIVERY_LIST_LIMIT, + }); + const query = new URLSearchParams(); + if (input.status) { + query.set("status", input.status); + } + if (normalizedLimit > 0) { + query.set("limit", String(normalizedLimit)); + } + + const candidatePaths = [ + "/v1/linear/deliveries", + "/v1/linear/webhook-deliveries", + "/v1/auth/linear/deliveries", + ]; + + let lastError = "Unable to list Linear deliveries."; + for (const path of candidatePaths) { + const url = new URL(path, `${brokerConfig.baseUrl}/`); + if (query.size > 0) { + url.search = query.toString(); + } + const response = await fetchJson({ + url: url.toString(), + }); + if (!response.ok) { + lastError = response.error; + continue; + } + + const payload = parseLinearDeliveriesListPayload({ + payload: response.value, + requestedStatus: input.status, + requestedLimit: normalizedLimit, + }); + if (payload) { + return { ok: true, data: payload }; + } + lastError = "Linear delivery payload was invalid."; + } + + return { ok: false, error: lastError }; +} + +async function applyLinearDelivery(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly deliveryId: string; +}): Promise< + | { readonly ok: true; readonly data: BrokerApplyDeliveryPayload } + | { readonly ok: false; readonly error: string } +> { + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const candidatePaths = [ + `/v1/linear/deliveries/${encodeURIComponent(input.deliveryId)}/apply`, + `/v1/linear/webhook-deliveries/${encodeURIComponent(input.deliveryId)}/apply`, + `/v1/auth/linear/deliveries/${encodeURIComponent(input.deliveryId)}/apply`, + ]; + + let lastError = "Unable to apply Linear delivery."; + for (const path of candidatePaths) { + const url = new URL(path, `${brokerConfig.baseUrl}/`); + const response = await fetchJson({ + url: url.toString(), + init: { + method: "POST", + headers: { + "content-type": "application/json", + }, + }, + }); + if (!response.ok) { + lastError = response.error; + continue; + } + const payload = parseLinearDeliveryApplyPayload({ + payload: response.value, + deliveryId: input.deliveryId, + }); + if (payload) { + return { ok: true, data: payload }; + } + lastError = "Linear delivery apply payload was invalid."; + } + + return { ok: false, error: lastError }; +} + +function parseLinearDeliveriesListPayload(input: { + readonly payload: unknown; + readonly requestedStatus?: LinearDeliveryStatus; + readonly requestedLimit: number; +}): BrokerListDeliveriesPayload | null { + if (!isRecord(input.payload)) { + return null; + } + let deliveriesRaw: unknown[] | null = null; + if (Array.isArray(input.payload.deliveries)) { + deliveriesRaw = input.payload.deliveries; + } else if (Array.isArray(input.payload.items)) { + deliveriesRaw = input.payload.items; + } + if (!deliveriesRaw) { + return null; + } + const deliveries = deliveriesRaw + .map((value) => parseLinearDeliverySummary({ value })) + .filter((value): value is LinearDeliverySummary => value !== null) + .slice(0, input.requestedLimit); + return { + status: + readOptionalString(input.payload.status) ?? + input.requestedStatus ?? + undefined, + limit: + typeof input.payload.limit === "number" && + Number.isFinite(input.payload.limit) + ? input.payload.limit + : input.requestedLimit, + deliveries, + }; +} + +function parseLinearDeliverySummary(input: { + readonly value: unknown; +}): LinearDeliverySummary | null { + if (!isRecord(input.value)) { + return null; + } + const id = readOptionalString(input.value.id); + const status = readOptionalString(input.value.status); + if (!(id && status)) { + return null; + } + return { + id, + status, + ...(readOptionalString(input.value.eventType) + ? { eventType: readOptionalString(input.value.eventType) } + : {}), + ...(readOptionalString(input.value.action) + ? { action: readOptionalString(input.value.action) } + : {}), + ...(readOptionalString(input.value.receivedAt) + ? { receivedAt: readOptionalString(input.value.receivedAt) } + : {}), + ...(readOptionalString(input.value.updatedAt) + ? { updatedAt: readOptionalString(input.value.updatedAt) } + : {}), + ...(input.value.payload !== undefined + ? { payload: input.value.payload } + : {}), + }; +} + +function parseLinearDeliveryApplyPayload(input: { + readonly payload: unknown; + readonly deliveryId: string; +}): BrokerApplyDeliveryPayload | null { + if (!isRecord(input.payload)) { + return null; + } + const deliveryId = + readOptionalString(input.payload.deliveryId) ?? + readOptionalString(input.payload.id) ?? + input.deliveryId; + const status = readOptionalString(input.payload.status); + if (!status) { + return null; + } + return { + deliveryId, + status, + }; +} + async function exchangeLinearOAuthCode(input: { readonly tokenUrl: string; readonly clientId: string; @@ -3324,6 +4344,112 @@ type SyncProjectArgs = { json: boolean; }; +function parseDeliveriesArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: DeliveriesArgs } + | { readonly ok: false; readonly error: string } { + const value: DeliveriesArgs = { + status: "pending", + json: false, + }; + + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + if (token.startsWith("--limit=")) { + value.limit = Number.parseInt(token.slice("--limit=".length), 10); + continue; + } + if (token === "--limit") { + const next = input.args[i + 1]; + if (!next || next.startsWith("-")) { + return { ok: false, error: "--limit requires a value." }; + } + value.limit = Number.parseInt(next, 10); + i += 1; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + status: "status", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + + if ( + !( + value.status === "pending" || + value.status === "applied" || + value.status === "ignored" + ) + ) { + return { + ok: false, + error: `Invalid --status value: ${String(value.status)}. Expected pending|applied|ignored.`, + }; + } + + return { ok: true, value }; +} + +function parseApplyDeliveryArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ApplyDeliveryArgs } + | { readonly ok: false; readonly error: string } { + const value: Partial & { json: boolean } = { + json: false, + }; + + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + "delivery-id": "deliveryId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + + if (!value.deliveryId) { + return { + ok: false, + error: "Missing --delivery-id .", + }; + } + + return { + ok: true, + value: { + deliveryId: value.deliveryId, + json: value.json, + }, + }; +} + function parseSetupArgs(input: { readonly args: readonly string[]; }): @@ -3821,7 +4947,10 @@ function normalizeOAuthActor(value: string | undefined): "user" | "app" | null { export const __testOnly = { buildOAuthArgsFromConnectArgs, + detectAuthoritativeFieldConflicts, + parseApplyDeliveryArgs, parseConnectArgs, + parseDeliveriesArgs, parseProjectBindArgs, parseProjectsArgs, resolveOAuthBrokerRuntimeConfig, @@ -3829,6 +4958,10 @@ export const __testOnly = { parseStatusArgs, parseSyncIssueArgs, parseSyncProjectArgs, + selectLinearCommentsToAppend, + selectTicketCommentsToPush, shouldFallbackConnectToOAuth, shouldUseBrokerOAuthFlow, + syncIssueFromLinearToTicket, + syncTicketToLinearIssue, } as const; diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts index 7ebfd04d3..2b5dbf925 100644 --- a/src/control-plane/extensions/tickets/commands.ts +++ b/src/control-plane/extensions/tickets/commands.ts @@ -20,7 +20,10 @@ import { type TicketsRepoUntrackStatus, untrackTicketsRepo, } from "./repo-state.ts"; -import { createTicketsStore } from "./store.ts"; +import { + createTicketsStore, + type TicketSyncConflictResolution, +} from "./store.ts"; import { createGitTicketsChannel } from "./tickets-git-channel.ts"; import { checkTicketsSkill, @@ -264,6 +267,7 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ ...(blocksResult.refs.length > 0 ? { blocks: blocksResult.refs } : {}), ...(parsed.value.owner ? { owner: parsed.value.owner } : {}), ...(parsed.value.source ? { source: parsed.value.source } : {}), + ...(parsed.value.assignee ? { assignee: parsed.value.assignee } : {}), ...(parsed.value.tags.length > 0 ? { tags: parsed.value.tags } : {}), ...(parsed.value.externalSystem ? { externalSystem: parsed.value.externalSystem } @@ -366,6 +370,12 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ }); return 1; } + if (parsed.value.clearAssignee && parsed.value.assignee !== undefined) { + ctx.logger.error({ + message: "--clear-assignee cannot be combined with --assignee.", + }); + return 1; + } const bodyRequested = parsed.value.body !== undefined || @@ -439,6 +449,8 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ bodyRequested || dependsOn !== undefined || blocks !== undefined || + parsed.value.assignee !== undefined || + parsed.value.clearAssignee || tags !== undefined || parsed.value.owner !== undefined || parsed.value.source !== undefined || @@ -478,6 +490,10 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ ...(parsed.value.source !== undefined ? { source: parsed.value.source } : {}), + ...(parsed.value.clearAssignee ? { assignee: "" } : {}), + ...(!parsed.value.clearAssignee && parsed.value.assignee !== undefined + ? { assignee: parsed.value.assignee } + : {}), ...(parsed.value.externalSystem !== undefined ? { externalSystem: parsed.value.externalSystem } : {}), @@ -523,6 +539,80 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ return 0; }, }, + { + name: "comment", + summary: "Append an immutable ticket comment", + scope: "project", + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + + const parsed = parseTicketsArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const ticketId = (parsed.value.rest[0] ?? "").trim(); + if (!ticketId) { + ctx.logger.error({ + message: + 'Usage: hack x tickets comment [--body "..."] [--body-file ] [--body-stdin] [--source hack] [--json]', + }); + return 1; + } + + const body = await resolveTicketBody({ + body: parsed.value.body, + bodyFile: parsed.value.bodyFile, + bodyStdin: parsed.value.bodyStdin, + }); + if (!body) { + ctx.logger.error({ + message: + "Comment body is required. Use --body, --body-file, or --body-stdin.", + }); + return 1; + } + + await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); + + const store = createTicketsStore({ + projectRoot: ctx.project.projectRoot, + projectId: ctx.projectId, + projectName: ctx.projectName, + controlPlaneConfig: ctx.controlPlaneConfig, + logger: ctx.logger, + }); + + const appended = await store.appendComment({ + ticketId, + body, + ...(parsed.value.source ? { source: parsed.value.source } : {}), + actor: parsed.value.actor, + }); + if (!appended.ok) { + ctx.logger.error({ message: appended.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify({ comment: appended.comment }, null, 2)}\n` + ); + return 0; + } + + await display.panel({ + title: "Ticket comment", + tone: "success", + lines: [`${ticketId} comment appended`, appended.comment.body], + }); + return 0; + }, + }, { name: "list", summary: "List tickets", @@ -658,17 +748,26 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ logger: ctx.logger, }); - const ticket = await store.getTicket({ ticketId }); + const detail = await store.getTicketDetail({ ticketId }); + const ticket = detail.ticket; if (!ticket) { ctx.logger.error({ message: `Ticket not found: ${ticketId}` }); return 1; } - const events = await store.listEvents({ ticketId }); - if (parsed.value.json) { process.stdout.write( - `${JSON.stringify({ ticket, events }, null, 2)}\n` + `${JSON.stringify( + { + ticket, + comments: detail.comments, + syncCheckpoints: detail.syncCheckpoints, + conflicts: detail.conflicts, + events: detail.events, + }, + null, + 2 + )}\n` ); return 0; } @@ -680,6 +779,7 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ ["status", ticket.status], ["owner", ticket.owner], ["source", ticket.source], + ["assignee", ticket.assignee ?? ""], ["tags", ticket.tags.join(", ")], ["external_system", ticket.externalSystem ?? ""], ["external_id", ticket.externalId ?? ""], @@ -705,9 +805,133 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ }); } + if (detail.comments.length > 0) { + await display.table({ + columns: ["comment_id", "source", "actor", "created_at", "body"], + rows: detail.comments.map((comment) => [ + comment.commentId, + comment.source, + comment.actor, + comment.createdAt, + comment.body, + ]), + }); + } + + if (detail.syncCheckpoints.length > 0) { + await display.table({ + columns: [ + "checkpoint_id", + "provider", + "profile", + "direction", + "cursor", + ], + rows: detail.syncCheckpoints.map((checkpoint) => [ + checkpoint.checkpointId, + checkpoint.provider, + checkpoint.profileId ?? "", + checkpoint.direction ?? "", + checkpoint.remoteCursor ?? "", + ]), + }); + } + + if (detail.conflicts.length > 0) { + await display.table({ + columns: ["conflict_id", "field", "status", "provider", "resolution"], + rows: detail.conflicts.map((conflict) => [ + conflict.conflictId, + conflict.field, + conflict.status, + conflict.provider, + conflict.resolution ?? "", + ]), + }); + } + await display.table({ columns: ["ts", "type", "event_id"], - rows: events.map((event) => [event.tsIso, event.type, event.eventId]), + rows: detail.events.map((event) => [ + event.tsIso, + event.type, + event.eventId, + ]), + }); + return 0; + }, + }, + { + name: "resolve-conflict", + summary: "Resolve a recorded ticket sync conflict", + scope: "project", + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + + const parsed = parseResolveConflictArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const ticketId = (parsed.value.rest[0] ?? "").trim(); + if (!ticketId) { + ctx.logger.error({ + message: + 'Usage: hack x tickets resolve-conflict --conflict-id --resolution [--summary "..."] [--json]', + }); + return 1; + } + + await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); + + const store = createTicketsStore({ + projectRoot: ctx.project.projectRoot, + projectId: ctx.projectId, + projectName: ctx.projectName, + controlPlaneConfig: ctx.controlPlaneConfig, + logger: ctx.logger, + }); + + const resolved = await store.resolveSyncConflict({ + ticketId, + conflictId: parsed.value.conflictId, + resolution: parsed.value.resolution, + ...(parsed.value.summary !== undefined + ? { summary: parsed.value.summary } + : {}), + actor: parsed.value.actor, + }); + if (!resolved.ok) { + ctx.logger.error({ message: resolved.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify( + { + ok: true, + ticketId, + conflictId: parsed.value.conflictId, + resolution: parsed.value.resolution, + }, + null, + 2 + )}\n` + ); + return 0; + } + + await display.panel({ + title: "Ticket conflict resolved", + tone: "success", + lines: [ + `${ticketId} ${parsed.value.conflictId} → ${parsed.value.resolution}`, + ], }); return 0; }, @@ -848,6 +1072,8 @@ type TicketsArgs = { readonly clearBlocks: boolean; readonly owner?: string; readonly source?: string; + readonly assignee?: string; + readonly clearAssignee: boolean; readonly tags: readonly string[]; readonly clearTags: boolean; readonly externalSystem?: string; @@ -876,6 +1102,15 @@ type TicketsSetupArgs = { readonly json: boolean; }; +type ResolveConflictArgs = { + readonly conflictId: string; + readonly resolution: TicketSyncConflictResolution; + readonly summary?: string; + readonly actor?: string; + readonly json: boolean; + readonly rest: readonly string[]; +}; + type TicketsSetupParseResult = | { readonly ok: true; readonly value: TicketsSetupArgs } | { readonly ok: false; readonly error: string }; @@ -894,6 +1129,8 @@ function parseTicketsArgs(opts: { let clearBlocks = false; let owner: string | undefined; let source: string | undefined; + let assignee: string | undefined; + let clearAssignee = false; const tags: string[] = []; let clearTags = false; let externalSystem: string | undefined; @@ -992,6 +1229,10 @@ function parseTicketsArgs(opts: { clearTags = true; continue; } + if (token === "--clear-assignee") { + clearAssignee = true; + continue; + } if (token.startsWith("--depends-on=")) { dependsOn.push(...splitTicketRefs(token.slice("--depends-on=".length))); @@ -1054,6 +1295,19 @@ function parseTicketsArgs(opts: { i += 1; continue; } + if (token.startsWith("--assignee=")) { + assignee = token.slice("--assignee=".length); + continue; + } + if (token === "--assignee") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--assignee requires a value." }; + } + assignee = value; + i += 1; + continue; + } if (token.startsWith("--tags=")) { tags.push(...splitTags(token.slice("--tags=".length))); continue; @@ -1205,6 +1459,8 @@ function parseTicketsArgs(opts: { clearBlocks, ...(owner ? { owner } : {}), ...(source ? { source } : {}), + ...(assignee !== undefined ? { assignee } : {}), + clearAssignee, tags: normalizeTags(tags), clearTags, ...(externalSystem !== undefined ? { externalSystem } : {}), @@ -1221,6 +1477,132 @@ function parseTicketsArgs(opts: { }; } +function parseResolveConflictArgs(opts: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ResolveConflictArgs } + | { readonly ok: false; readonly error: string } { + const rest: string[] = []; + let conflictId: string | undefined; + let resolution: TicketSyncConflictResolution | undefined; + let summary: string | undefined; + let actor: string | undefined; + let json = false; + + const takeValue = (value: string | undefined): string | null => { + if (!value || value.startsWith("-")) { + return null; + } + return value; + }; + + for (let i = 0; i < opts.args.length; i += 1) { + const token = opts.args[i] ?? ""; + if (token === "--") { + rest.push(...opts.args.slice(i + 1)); + break; + } + if (token === "--json") { + json = true; + continue; + } + if (token.startsWith("--conflict-id=")) { + conflictId = token.slice("--conflict-id=".length); + continue; + } + if (token === "--conflict-id") { + const value = takeValue(opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--conflict-id requires a value." }; + } + conflictId = value; + i += 1; + continue; + } + if (token.startsWith("--resolution=")) { + resolution = parseConflictResolutionValue({ + value: token.slice("--resolution=".length), + }); + if (!resolution) { + return { + ok: false, + error: + "Invalid --resolution value. Expected accept_local|accept_remote|merged|ignore.", + }; + } + continue; + } + if (token === "--resolution") { + const value = takeValue(opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--resolution requires a value." }; + } + resolution = parseConflictResolutionValue({ value }); + if (!resolution) { + return { + ok: false, + error: + "Invalid --resolution value. Expected accept_local|accept_remote|merged|ignore.", + }; + } + i += 1; + continue; + } + if (token.startsWith("--summary=")) { + summary = token.slice("--summary=".length); + continue; + } + if (token === "--summary") { + const value = takeValue(opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--summary requires a value." }; + } + summary = value; + i += 1; + continue; + } + if (token.startsWith("--actor=")) { + actor = token.slice("--actor=".length); + continue; + } + if (token === "--actor") { + const value = takeValue(opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--actor requires a value." }; + } + actor = value; + i += 1; + continue; + } + if (token.startsWith("-")) { + return { ok: false, error: `Unknown option: ${token}` }; + } + rest.push(token); + } + + if (!conflictId) { + return { ok: false, error: "Missing --conflict-id ." }; + } + if (!resolution) { + return { + ok: false, + error: "Missing --resolution .", + }; + } + + return { + ok: true, + value: { + conflictId, + resolution, + ...(summary !== undefined ? { summary } : {}), + ...(actor ? { actor } : {}), + json, + rest, + }, + }; +} + async function resolveTicketBody(opts: { readonly body?: string; readonly bodyFile?: string; @@ -1261,6 +1643,21 @@ function splitTicketRefs(value: string): string[] { .filter((part) => part.length > 0); } +function parseConflictResolutionValue(input: { + readonly value: string; +}): TicketSyncConflictResolution | null { + const value = input.value.trim(); + if ( + value === "accept_local" || + value === "accept_remote" || + value === "merged" || + value === "ignore" + ) { + return value; + } + return null; +} + function splitTags(value: string): string[] { return value .split(TICKET_REF_SEPARATOR_PATTERN) diff --git a/src/control-plane/extensions/tickets/store.ts b/src/control-plane/extensions/tickets/store.ts index 0de63b10f..c1a92fc70 100644 --- a/src/control-plane/extensions/tickets/store.ts +++ b/src/control-plane/extensions/tickets/store.ts @@ -26,6 +26,7 @@ export type TicketSummary = { readonly blocks: readonly string[]; readonly owner: string; readonly source: string; + readonly assignee?: string; readonly tags: readonly string[]; readonly externalSystem?: string; readonly externalId?: string; @@ -38,11 +39,68 @@ export type TicketSummary = { readonly projectName?: string; }; +export type TicketMetadataValue = + | string + | number + | boolean + | null + | readonly TicketMetadataValue[] + | { readonly [key: string]: TicketMetadataValue }; + +export type TicketComment = { + readonly commentId: string; + readonly ticketId: string; + readonly body: string; + readonly source: string; + readonly actor: string; + readonly createdAt: string; + readonly externalId?: string; + readonly externalUrl?: string; +}; + +export type TicketSyncCheckpoint = { + readonly checkpointId: string; + readonly ticketId: string; + readonly provider: string; + readonly profileId?: string; + readonly direction?: string; + readonly remoteCursor?: string; + readonly remoteUpdatedAt?: string; + readonly localUpdatedAt?: string; + readonly actor: string; + readonly createdAt: string; +}; + +export type TicketSyncConflictResolution = + | "accept_local" + | "accept_remote" + | "merged" + | "ignore"; + +export type TicketSyncConflict = { + readonly conflictId: string; + readonly ticketId: string; + readonly provider: string; + readonly field: string; + readonly status: "open" | "resolved"; + readonly authority?: string; + readonly summary?: string; + readonly localValue?: TicketMetadataValue; + readonly remoteValue?: TicketMetadataValue; + readonly createdAt: string; + readonly updatedAt: string; + readonly resolution?: TicketSyncConflictResolution; + readonly resolutionSummary?: string; + readonly resolvedAt?: string; + readonly resolvedBy?: string; +}; + export type TicketEvent = { readonly eventId: string; readonly ts: number; readonly tsIso: string; readonly actor: string; + readonly orderKey?: string; readonly projectId?: string; readonly projectName?: string; readonly ticketId: string; @@ -50,6 +108,20 @@ export type TicketEvent = { readonly payload: Record; }; +export type TicketStoreSnapshot = { + readonly tickets: readonly TicketSummary[]; + readonly eventsByTicket: ReadonlyMap; + readonly commentsByTicket: ReadonlyMap; + readonly syncCheckpointsByTicket: ReadonlyMap< + string, + readonly TicketSyncCheckpoint[] + >; + readonly conflictsByTicket: ReadonlyMap< + string, + readonly TicketSyncConflict[] + >; +}; + type CreateTicketResult = | { readonly ok: true; readonly ticket: TicketSummary } | { readonly ok: false; readonly error: string }; @@ -64,6 +136,13 @@ type SyncResult = } | { readonly ok: false; readonly error: string }; +type MaterializedTicketState = { + readonly tickets: Map; + readonly commentsByTicket: Map; + readonly syncCheckpointsByTicket: Map; + readonly conflictsByTicket: Map; +}; + export function createTicketsStore(opts: { readonly projectRoot: string; readonly projectId?: string; @@ -81,6 +160,7 @@ export function createTicketsStore(opts: { readonly blocks?: readonly string[]; readonly owner?: string; readonly source?: string; + readonly assignee?: string; readonly tags?: readonly string[]; readonly externalSystem?: string; readonly externalId?: string; @@ -99,6 +179,7 @@ export function createTicketsStore(opts: { readonly blocks?: readonly string[]; readonly owner?: string; readonly source?: string; + readonly assignee?: string; readonly tags?: readonly string[]; readonly externalSystem?: string; readonly externalId?: string; @@ -118,10 +199,69 @@ export function createTicketsStore(opts: { readonly listEvents: (input: { readonly ticketId: string; }) => Promise; - readonly readSnapshot: () => Promise<{ - readonly tickets: readonly TicketSummary[]; - readonly eventsByTicket: ReadonlyMap; + readonly getTicketDetail: (input: { readonly ticketId: string }) => Promise<{ + readonly ticket: TicketSummary | null; + readonly events: readonly TicketEvent[]; + readonly comments: readonly TicketComment[]; + readonly syncCheckpoints: readonly TicketSyncCheckpoint[]; + readonly conflicts: readonly TicketSyncConflict[]; }>; + readonly appendComment: (input: { + readonly ticketId: string; + readonly body: string; + readonly source?: string; + readonly externalId?: string; + readonly externalUrl?: string; + readonly actor?: string; + }) => Promise< + | { readonly ok: true; readonly comment: TicketComment } + | { readonly ok: false; readonly error: string } + >; + readonly linkCommentExternalId: (input: { + readonly ticketId: string; + readonly commentId: string; + readonly externalId: string; + readonly externalUrl?: string; + readonly actor?: string; + }) => Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } + >; + readonly recordSyncCheckpoint: (input: { + readonly ticketId: string; + readonly provider: string; + readonly profileId?: string; + readonly direction?: string; + readonly remoteCursor?: string; + readonly remoteUpdatedAt?: string; + readonly localUpdatedAt?: string; + readonly actor?: string; + }) => Promise< + | { readonly ok: true; readonly checkpoint: TicketSyncCheckpoint } + | { readonly ok: false; readonly error: string } + >; + readonly recordSyncConflict: (input: { + readonly ticketId: string; + readonly provider: string; + readonly field: string; + readonly authority?: string; + readonly summary?: string; + readonly localValue?: TicketMetadataValue; + readonly remoteValue?: TicketMetadataValue; + readonly actor?: string; + }) => Promise< + | { readonly ok: true; readonly conflict: TicketSyncConflict } + | { readonly ok: false; readonly error: string } + >; + readonly resolveSyncConflict: (input: { + readonly ticketId: string; + readonly conflictId: string; + readonly resolution: TicketSyncConflictResolution; + readonly summary?: string; + readonly actor?: string; + }) => Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } + >; + readonly readSnapshot: () => Promise; readonly setStatus: (input: { readonly ticketId: string; readonly status: TicketStatus; @@ -136,6 +276,7 @@ export function createTicketsStore(opts: { config: opts.controlPlaneConfig.tickets.git, logger: opts.logger, }); + let eventSequence = 0; const resolveActor = (override?: string): string => { const trimmed = (override ?? "").trim(); @@ -153,11 +294,14 @@ export function createTicketsStore(opts: { readonly actor?: string; }): TicketEvent => { const ts = unixSeconds(); + const orderKey = `${Date.now()}-${String(eventSequence).padStart(6, "0")}`; + eventSequence += 1; return { eventId: randomUUID(), ts, tsIso: new Date(ts * 1000).toISOString(), actor: resolveActor(input.actor), + orderKey, ...(opts.projectId ? { projectId: opts.projectId } : {}), ...(opts.projectName ? { projectName: opts.projectName } : {}), ticketId: input.ticketId, @@ -200,33 +344,50 @@ export function createTicketsStore(opts: { if (a.ts !== b.ts) { return a.ts - b.ts; } - return a.eventId.localeCompare(b.eventId); + if (a.orderKey && b.orderKey && a.orderKey !== b.orderKey) { + return a.orderKey.localeCompare(b.orderKey); + } + return 0; }); return events; }; const materializeTickets = async (): Promise> => { const events = await readAllEvents(); - return materializeTicketsFromEvents({ events }); + return materializeSnapshotFromEvents({ events }).tickets; }; - const materializeTicketsFromEvents = (opts: { + const materializeSnapshotFromEvents = (input: { readonly events: readonly TicketEvent[]; - }): Map => { + }): MaterializedTicketState => { const tickets = new Map(); - - for (const event of opts.events) { - applyTicketEvent({ tickets, event }); + const commentsByTicket = new Map(); + const syncCheckpointsByTicket = new Map(); + const conflictsByTicket = new Map(); + + for (const event of input.events) { + applyTicketEvent({ + tickets, + commentsByTicket, + syncCheckpointsByTicket, + conflictsByTicket, + event, + }); } - return applyDerivedBlocks(tickets); + return { + tickets: applyDerivedBlocks(tickets), + commentsByTicket, + syncCheckpointsByTicket, + conflictsByTicket, + }; }; - const groupEventsByTicket = (opts: { + const groupEventsByTicket = (input: { readonly events: readonly TicketEvent[]; }): Map => { const grouped = new Map(); - for (const event of opts.events) { + for (const event of input.events) { const list = grouped.get(event.ticketId) ?? []; list.push(event); grouped.set(event.ticketId, list); @@ -234,10 +395,10 @@ export function createTicketsStore(opts: { return grouped; }; - const sortTickets = (opts: { + const sortTickets = (input: { readonly tickets: Iterable; }): TicketSummary[] => { - const out = [...opts.tickets]; + const out = [...input.tickets]; out.sort( (a, b) => (parseTicketNumber(a.ticketId) ?? 0) - @@ -248,9 +409,9 @@ export function createTicketsStore(opts: { const computeNextTicketId = async (): Promise => { const events = await readAllEvents(); - const tickets = materializeTicketsFromEvents({ events }); + const snapshot = materializeSnapshotFromEvents({ events }); let max = 0; - for (const ticketId of tickets.keys()) { + for (const ticketId of snapshot.tickets.keys()) { const n = parseTicketNumber(ticketId); if (n !== null && n > max) { max = n; @@ -295,6 +456,9 @@ export function createTicketsStore(opts: { value: input.source, fallback: "hack", }); + const assignee = normalizeOptionalMetadataString({ + value: input.assignee, + }); const tags = normalizeTags(input.tags ?? []); const externalSystem = normalizeOptionalMetadataString({ value: input.externalSystem, @@ -327,6 +491,7 @@ export function createTicketsStore(opts: { ...(blocks.length > 0 ? { blocks } : {}), owner, source, + ...(assignee ? { assignee } : {}), ...(tags.length > 0 ? { tags } : {}), ...(externalSystem ? { externalSystem } : {}), ...(externalId ? { externalId } : {}), @@ -358,6 +523,7 @@ export function createTicketsStore(opts: { blocks, owner, source, + ...(assignee ? { assignee } : {}), tags, ...(externalSystem ? { externalSystem } : {}), ...(externalId ? { externalId } : {}), @@ -408,6 +574,12 @@ export function createTicketsStore(opts: { fallback: "hack", }); } + if (input.assignee !== undefined) { + payload.assignee = + normalizeOptionalMetadataString({ + value: input.assignee, + }) ?? null; + } if (input.tags !== undefined) { payload.tags = normalizeTags(input.tags); } @@ -463,28 +635,298 @@ export function createTicketsStore(opts: { listTickets: async () => { const events = await readAllEvents(); - const tickets = materializeTicketsFromEvents({ events }); - return sortTickets({ tickets: tickets.values() }); + const snapshot = materializeSnapshotFromEvents({ events }); + return sortTickets({ tickets: snapshot.tickets.values() }); }, getTicket: async ({ ticketId }) => { const events = await readAllEvents(); - const tickets = materializeTicketsFromEvents({ events }); - return tickets.get(ticketId) ?? null; + const snapshot = materializeSnapshotFromEvents({ events }); + return snapshot.tickets.get(ticketId) ?? null; }, listEvents: async ({ ticketId }) => { const events = await readAllEvents(); - return events.filter((e) => e.ticketId === ticketId); + return events.filter((event) => event.ticketId === ticketId); + }, + + getTicketDetail: async ({ ticketId }) => { + const events = await readAllEvents(); + const snapshot = materializeSnapshotFromEvents({ events }); + return { + ticket: snapshot.tickets.get(ticketId) ?? null, + events: events.filter((event) => event.ticketId === ticketId), + comments: snapshot.commentsByTicket.get(ticketId) ?? [], + syncCheckpoints: snapshot.syncCheckpointsByTicket.get(ticketId) ?? [], + conflicts: snapshot.conflictsByTicket.get(ticketId) ?? [], + }; + }, + + appendComment: async (input) => { + const tickets = await materializeTickets(); + const current = tickets.get(input.ticketId); + if (!current) { + return { ok: false, error: `Ticket not found: ${input.ticketId}` }; + } + + const body = input.body.trim(); + if (!body) { + return { ok: false, error: "Comment body cannot be empty." }; + } + + const source = normalizeOwnerOrSource({ + value: input.source, + fallback: current.source, + }); + const externalId = normalizeOptionalMetadataString({ + value: input.externalId, + }); + const externalUrl = normalizeOptionalMetadataString({ + value: input.externalUrl, + }); + const commentId = randomUUID(); + const event = buildEvent({ + ticketId: input.ticketId, + type: "ticket.comment_appended", + payload: { + commentId, + body, + source, + ...(externalId ? { externalId } : {}), + ...(externalUrl ? { externalUrl } : {}), + }, + actor: input.actor, + }); + + const wrote = await git.appendEvents({ events: [event] }); + if (!wrote.ok) { + return wrote; + } + + return { + ok: true, + comment: { + commentId, + ticketId: input.ticketId, + body, + source, + actor: event.actor, + createdAt: event.tsIso, + ...(externalId ? { externalId } : {}), + ...(externalUrl ? { externalUrl } : {}), + }, + }; + }, + + linkCommentExternalId: async (input) => { + const detail = await readAllEvents(); + const snapshot = materializeSnapshotFromEvents({ events: detail }); + const comments = snapshot.commentsByTicket.get(input.ticketId) ?? []; + const current = comments.find( + (comment) => comment.commentId === input.commentId + ); + if (!current) { + return { ok: false, error: `Comment not found: ${input.commentId}` }; + } + + const externalId = normalizeOptionalMetadataString({ + value: input.externalId, + }); + if (!externalId) { + return { ok: false, error: "External comment id is required." }; + } + const externalUrl = normalizeOptionalMetadataString({ + value: input.externalUrl, + }); + const event = buildEvent({ + ticketId: input.ticketId, + type: "ticket.comment_linked", + payload: { + commentId: input.commentId, + externalId, + ...(externalUrl ? { externalUrl } : {}), + }, + actor: input.actor, + }); + + return await git.appendEvents({ events: [event] }); + }, + + recordSyncCheckpoint: async (input) => { + const tickets = await materializeTickets(); + if (!tickets.has(input.ticketId)) { + return { ok: false, error: `Ticket not found: ${input.ticketId}` }; + } + + const provider = normalizeOptionalMetadataString({ + value: input.provider, + }); + if (!provider) { + return { ok: false, error: "Provider is required." }; + } + + const checkpointId = randomUUID(); + const profileId = normalizeOptionalMetadataString({ + value: input.profileId, + }); + const direction = normalizeOptionalMetadataString({ + value: input.direction, + }); + const remoteCursor = normalizeOptionalMetadataString({ + value: input.remoteCursor, + }); + const remoteUpdatedAt = normalizeOptionalMetadataString({ + value: input.remoteUpdatedAt, + }); + const localUpdatedAt = normalizeOptionalMetadataString({ + value: input.localUpdatedAt, + }); + const event = buildEvent({ + ticketId: input.ticketId, + type: "ticket.sync_checkpoint_recorded", + payload: { + checkpointId, + provider, + ...(profileId ? { profileId } : {}), + ...(direction ? { direction } : {}), + ...(remoteCursor ? { remoteCursor } : {}), + ...(remoteUpdatedAt ? { remoteUpdatedAt } : {}), + ...(localUpdatedAt ? { localUpdatedAt } : {}), + }, + actor: input.actor, + }); + + const wrote = await git.appendEvents({ events: [event] }); + if (!wrote.ok) { + return wrote; + } + + return { + ok: true, + checkpoint: { + checkpointId, + ticketId: input.ticketId, + provider, + ...(profileId ? { profileId } : {}), + ...(direction ? { direction } : {}), + ...(remoteCursor ? { remoteCursor } : {}), + ...(remoteUpdatedAt ? { remoteUpdatedAt } : {}), + ...(localUpdatedAt ? { localUpdatedAt } : {}), + actor: event.actor, + createdAt: event.tsIso, + }, + }; + }, + + recordSyncConflict: async (input) => { + const tickets = await materializeTickets(); + if (!tickets.has(input.ticketId)) { + return { ok: false, error: `Ticket not found: ${input.ticketId}` }; + } + + const provider = normalizeOptionalMetadataString({ + value: input.provider, + }); + if (!provider) { + return { ok: false, error: "Provider is required." }; + } + const field = normalizeOptionalMetadataString({ + value: input.field, + }); + if (!field) { + return { ok: false, error: "Field is required." }; + } + + const conflictId = randomUUID(); + const authority = normalizeOptionalMetadataString({ + value: input.authority, + }); + const summary = normalizeOptionalMetadataString({ + value: input.summary, + }); + const event = buildEvent({ + ticketId: input.ticketId, + type: "ticket.sync_conflict_recorded", + payload: { + conflictId, + provider, + field, + ...(authority ? { authority } : {}), + ...(summary ? { summary } : {}), + ...(input.localValue !== undefined + ? { localValue: input.localValue } + : {}), + ...(input.remoteValue !== undefined + ? { remoteValue: input.remoteValue } + : {}), + }, + actor: input.actor, + }); + + const wrote = await git.appendEvents({ events: [event] }); + if (!wrote.ok) { + return wrote; + } + + return { + ok: true, + conflict: { + conflictId, + ticketId: input.ticketId, + provider, + field, + status: "open", + ...(authority ? { authority } : {}), + ...(summary ? { summary } : {}), + ...(input.localValue !== undefined + ? { localValue: input.localValue } + : {}), + ...(input.remoteValue !== undefined + ? { remoteValue: input.remoteValue } + : {}), + createdAt: event.tsIso, + updatedAt: event.tsIso, + }, + }; + }, + + resolveSyncConflict: async (input) => { + const detail = await readAllEvents(); + const snapshot = materializeSnapshotFromEvents({ events: detail }); + const conflicts = snapshot.conflictsByTicket.get(input.ticketId) ?? []; + const current = conflicts.find( + (conflict) => conflict.conflictId === input.conflictId + ); + if (!current) { + return { ok: false, error: `Conflict not found: ${input.conflictId}` }; + } + + const summary = normalizeOptionalMetadataString({ + value: input.summary, + }); + const event = buildEvent({ + ticketId: input.ticketId, + type: "ticket.sync_conflict_resolved", + payload: { + conflictId: input.conflictId, + resolution: input.resolution, + ...(summary ? { summary } : {}), + }, + actor: input.actor, + }); + + return await git.appendEvents({ events: [event] }); }, readSnapshot: async () => { const events = await readAllEvents(); - const tickets = materializeTicketsFromEvents({ events }); - const eventsByTicket = groupEventsByTicket({ events }); + const snapshot = materializeSnapshotFromEvents({ events }); return { - tickets: sortTickets({ tickets: tickets.values() }), - eventsByTicket, + tickets: sortTickets({ tickets: snapshot.tickets.values() }), + eventsByTicket: groupEventsByTicket({ events }), + commentsByTicket: snapshot.commentsByTicket, + syncCheckpointsByTicket: snapshot.syncCheckpointsByTicket, + conflictsByTicket: snapshot.conflictsByTicket, }; }, @@ -496,29 +938,70 @@ export function createTicketsStore(opts: { }; } -function applyTicketEvent(opts: { +function applyTicketEvent(input: { readonly tickets: Map; + readonly commentsByTicket: Map; + readonly syncCheckpointsByTicket: Map; + readonly conflictsByTicket: Map; readonly event: TicketEvent; }): void { - switch (opts.event.type) { + switch (input.event.type) { case "ticket.created": { applyTicketCreatedEvent({ - tickets: opts.tickets, - event: opts.event, + tickets: input.tickets, + event: input.event, }); break; } case "ticket.status_changed": { applyTicketStatusChangedEvent({ - tickets: opts.tickets, - event: opts.event, + tickets: input.tickets, + event: input.event, }); break; } case "ticket.updated": { applyTicketUpdatedEvent({ - tickets: opts.tickets, - event: opts.event, + tickets: input.tickets, + event: input.event, + }); + break; + } + case "ticket.comment_appended": { + applyTicketCommentAppendedEvent({ + tickets: input.tickets, + commentsByTicket: input.commentsByTicket, + event: input.event, + }); + break; + } + case "ticket.comment_linked": { + applyTicketCommentLinkedEvent({ + commentsByTicket: input.commentsByTicket, + event: input.event, + }); + break; + } + case "ticket.sync_checkpoint_recorded": { + applyTicketSyncCheckpointRecordedEvent({ + tickets: input.tickets, + syncCheckpointsByTicket: input.syncCheckpointsByTicket, + event: input.event, + }); + break; + } + case "ticket.sync_conflict_recorded": { + applyTicketSyncConflictRecordedEvent({ + tickets: input.tickets, + conflictsByTicket: input.conflictsByTicket, + event: input.event, + }); + break; + } + case "ticket.sync_conflict_resolved": { + applyTicketSyncConflictResolvedEvent({ + conflictsByTicket: input.conflictsByTicket, + event: input.event, }); break; } @@ -528,66 +1011,70 @@ function applyTicketEvent(opts: { } } -function applyTicketCreatedEvent(opts: { +function applyTicketCreatedEvent(input: { readonly tickets: Map; readonly event: TicketEvent; }): void { const title = - typeof opts.event.payload.title === "string" - ? opts.event.payload.title + typeof input.event.payload.title === "string" + ? input.event.payload.title : ""; const body = - typeof opts.event.payload.body === "string" - ? opts.event.payload.body + typeof input.event.payload.body === "string" + ? input.event.payload.body : undefined; const dependsOn = parseDependencyList({ - value: opts.event.payload.dependsOn, + value: input.event.payload.dependsOn, }); const blocks = parseDependencyList({ - value: opts.event.payload.blocks, + value: input.event.payload.blocks, }); const owner = normalizeOwnerOrSource({ - value: readOptionalStringPayload({ value: opts.event.payload.owner }), + value: readOptionalStringPayload({ value: input.event.payload.owner }), fallback: "hack", }); const source = normalizeOwnerOrSource({ - value: readOptionalStringPayload({ value: opts.event.payload.source }), + value: readOptionalStringPayload({ value: input.event.payload.source }), fallback: "hack", }); - const tags = parseTags({ value: opts.event.payload.tags }); + const assignee = readOptionalStringPayload({ + value: input.event.payload.assignee, + }); + const tags = parseTags({ value: input.event.payload.tags }); const externalSystem = readOptionalStringPayload({ - value: opts.event.payload.externalSystem, + value: input.event.payload.externalSystem, }); const externalId = readOptionalStringPayload({ - value: opts.event.payload.externalId, + value: input.event.payload.externalId, }); const externalKey = readOptionalStringPayload({ - value: opts.event.payload.externalKey, + value: input.event.payload.externalKey, }); const externalUrl = readOptionalStringPayload({ - value: opts.event.payload.externalUrl, + value: input.event.payload.externalUrl, }); const externalProjectId = readOptionalStringPayload({ - value: opts.event.payload.externalProjectId, + value: input.event.payload.externalProjectId, }); const externalProjectName = readOptionalStringPayload({ - value: opts.event.payload.externalProjectName, + value: input.event.payload.externalProjectName, }); const externalTeamId = readOptionalStringPayload({ - value: opts.event.payload.externalTeamId, + value: input.event.payload.externalTeamId, }); - opts.tickets.set(opts.event.ticketId, { - ticketId: opts.event.ticketId, + input.tickets.set(input.event.ticketId, { + ticketId: input.event.ticketId, title, body, status: "open", - createdAt: opts.event.tsIso, - updatedAt: opts.event.tsIso, + createdAt: input.event.tsIso, + updatedAt: input.event.tsIso, dependsOn, blocks, owner, source, + ...(assignee ? { assignee } : {}), tags, ...(externalSystem ? { externalSystem } : {}), ...(externalId ? { externalId } : {}), @@ -596,99 +1083,105 @@ function applyTicketCreatedEvent(opts: { ...(externalProjectId ? { externalProjectId } : {}), ...(externalProjectName ? { externalProjectName } : {}), ...(externalTeamId ? { externalTeamId } : {}), - ...(opts.event.projectId ? { projectId: opts.event.projectId } : {}), - ...(opts.event.projectName ? { projectName: opts.event.projectName } : {}), + ...(input.event.projectId ? { projectId: input.event.projectId } : {}), + ...(input.event.projectName + ? { projectName: input.event.projectName } + : {}), }); } -function applyTicketStatusChangedEvent(opts: { +function applyTicketStatusChangedEvent(input: { readonly tickets: Map; readonly event: TicketEvent; }): void { - const current = opts.tickets.get(opts.event.ticketId); + const current = input.tickets.get(input.event.ticketId); if (!current) { return; } - const status = parseTicketStatus({ value: opts.event.payload.status }); + const status = parseTicketStatus({ value: input.event.payload.status }); if (!status) { return; } - opts.tickets.set(opts.event.ticketId, { + input.tickets.set(input.event.ticketId, { ...current, status, - updatedAt: opts.event.tsIso, + updatedAt: input.event.tsIso, }); } -function applyTicketUpdatedEvent(opts: { +function applyTicketUpdatedEvent(input: { readonly tickets: Map; readonly event: TicketEvent; }): void { - const current = opts.tickets.get(opts.event.ticketId); + const current = input.tickets.get(input.event.ticketId); if (!current) { return; } const title = - typeof opts.event.payload.title === "string" - ? opts.event.payload.title + typeof input.event.payload.title === "string" + ? input.event.payload.title : undefined; const body = - typeof opts.event.payload.body === "string" - ? opts.event.payload.body + typeof input.event.payload.body === "string" + ? input.event.payload.body : undefined; const dependsOn = readDependencyUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "dependsOn", }); const blocks = readDependencyUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "blocks", }); const owner = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "owner", }); const source = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "source", }); + const assignee = readOptionalStringUpdate({ + payload: input.event.payload, + key: "assignee", + }); const tags = readTagsUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "tags", }); const externalSystem = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "externalSystem", }); const externalId = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "externalId", }); const externalKey = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "externalKey", }); const externalUrl = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "externalUrl", }); const externalProjectId = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "externalProjectId", }); const externalProjectName = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "externalProjectName", }); const externalTeamId = readOptionalStringUpdate({ - payload: opts.event.payload, + payload: input.event.payload, key: "externalTeamId", }); - opts.tickets.set(opts.event.ticketId, { + input.tickets.set(input.event.ticketId, { ...current, ...(title ? { title } : {}), ...(body !== undefined ? { body } : {}), @@ -710,6 +1203,7 @@ function applyTicketUpdatedEvent(opts: { }), } : {}), + ...(assignee !== null ? { assignee: assignee || undefined } : {}), ...(tags !== null ? { tags } : {}), ...(externalSystem !== null ? { externalSystem: externalSystem || undefined } @@ -726,67 +1220,285 @@ function applyTicketUpdatedEvent(opts: { ...(externalTeamId !== null ? { externalTeamId: externalTeamId || undefined } : {}), - updatedAt: opts.event.tsIso, + updatedAt: input.event.tsIso, + }); +} + +function applyTicketCommentAppendedEvent(input: { + readonly tickets: Map; + readonly commentsByTicket: Map; + readonly event: TicketEvent; +}): void { + if (!input.tickets.has(input.event.ticketId)) { + return; + } + + const body = readOptionalStringPayload({ value: input.event.payload.body }); + if (!body) { + return; + } + + const source = normalizeOwnerOrSource({ + value: readOptionalStringPayload({ value: input.event.payload.source }), + fallback: "hack", + }); + const externalId = readOptionalStringPayload({ + value: input.event.payload.externalId, + }); + const externalUrl = readOptionalStringPayload({ + value: input.event.payload.externalUrl, + }); + + appendMapValue({ + map: input.commentsByTicket, + key: input.event.ticketId, + value: { + commentId: + readOptionalStringPayload({ value: input.event.payload.commentId }) ?? + input.event.eventId, + ticketId: input.event.ticketId, + body, + source, + actor: input.event.actor, + createdAt: input.event.tsIso, + ...(externalId ? { externalId } : {}), + ...(externalUrl ? { externalUrl } : {}), + }, }); } -function parseTicketStatus(opts: { +function applyTicketCommentLinkedEvent(input: { + readonly commentsByTicket: Map; + readonly event: TicketEvent; +}): void { + const commentId = readOptionalStringPayload({ + value: input.event.payload.commentId, + }); + const externalId = readOptionalStringPayload({ + value: input.event.payload.externalId, + }); + if (!(commentId && externalId)) { + return; + } + const comments = input.commentsByTicket.get(input.event.ticketId); + if (!comments) { + return; + } + const externalUrl = readOptionalStringPayload({ + value: input.event.payload.externalUrl, + }); + input.commentsByTicket.set( + input.event.ticketId, + comments.map((comment) => + comment.commentId === commentId + ? { + ...comment, + externalId, + ...(externalUrl ? { externalUrl } : {}), + } + : comment + ) + ); +} + +function applyTicketSyncCheckpointRecordedEvent(input: { + readonly tickets: Map; + readonly syncCheckpointsByTicket: Map; + readonly event: TicketEvent; +}): void { + if (!input.tickets.has(input.event.ticketId)) { + return; + } + + const provider = readOptionalStringPayload({ + value: input.event.payload.provider, + }); + if (!provider) { + return; + } + + const profileId = readOptionalStringPayload({ + value: input.event.payload.profileId, + }); + const direction = readOptionalStringPayload({ + value: input.event.payload.direction, + }); + const remoteCursor = readOptionalStringPayload({ + value: input.event.payload.remoteCursor, + }); + const remoteUpdatedAt = readOptionalStringPayload({ + value: input.event.payload.remoteUpdatedAt, + }); + const localUpdatedAt = readOptionalStringPayload({ + value: input.event.payload.localUpdatedAt, + }); + + appendMapValue({ + map: input.syncCheckpointsByTicket, + key: input.event.ticketId, + value: { + checkpointId: + readOptionalStringPayload({ + value: input.event.payload.checkpointId, + }) ?? input.event.eventId, + ticketId: input.event.ticketId, + provider, + ...(profileId ? { profileId } : {}), + ...(direction ? { direction } : {}), + ...(remoteCursor ? { remoteCursor } : {}), + ...(remoteUpdatedAt ? { remoteUpdatedAt } : {}), + ...(localUpdatedAt ? { localUpdatedAt } : {}), + actor: input.event.actor, + createdAt: input.event.tsIso, + }, + }); +} + +function applyTicketSyncConflictRecordedEvent(input: { + readonly tickets: Map; + readonly conflictsByTicket: Map; + readonly event: TicketEvent; +}): void { + if (!input.tickets.has(input.event.ticketId)) { + return; + } + + const provider = readOptionalStringPayload({ + value: input.event.payload.provider, + }); + const field = readOptionalStringPayload({ value: input.event.payload.field }); + if (!(provider && field)) { + return; + } + + const authority = readOptionalStringPayload({ + value: input.event.payload.authority, + }); + const summary = readOptionalStringPayload({ + value: input.event.payload.summary, + }); + const localValue = parseTicketMetadataValue(input.event.payload.localValue); + const remoteValue = parseTicketMetadataValue(input.event.payload.remoteValue); + + appendMapValue({ + map: input.conflictsByTicket, + key: input.event.ticketId, + value: { + conflictId: + readOptionalStringPayload({ value: input.event.payload.conflictId }) ?? + input.event.eventId, + ticketId: input.event.ticketId, + provider, + field, + status: "open", + ...(authority ? { authority } : {}), + ...(summary ? { summary } : {}), + ...(localValue !== undefined ? { localValue } : {}), + ...(remoteValue !== undefined ? { remoteValue } : {}), + createdAt: input.event.tsIso, + updatedAt: input.event.tsIso, + }, + }); +} + +function applyTicketSyncConflictResolvedEvent(input: { + readonly conflictsByTicket: Map; + readonly event: TicketEvent; +}): void { + const conflictId = readOptionalStringPayload({ + value: input.event.payload.conflictId, + }); + const resolution = parseTicketSyncConflictResolution({ + value: input.event.payload.resolution, + }); + if (!(conflictId && resolution)) { + return; + } + + const conflicts = input.conflictsByTicket.get(input.event.ticketId); + if (!conflicts) { + return; + } + + const summary = readOptionalStringPayload({ + value: input.event.payload.summary, + }); + input.conflictsByTicket.set( + input.event.ticketId, + conflicts.map((conflict) => + conflict.conflictId === conflictId + ? { + ...conflict, + status: "resolved" as const, + resolution, + ...(summary ? { resolutionSummary: summary } : {}), + resolvedAt: input.event.tsIso, + resolvedBy: input.event.actor, + updatedAt: input.event.tsIso, + } + : conflict + ) + ); +} + +function parseTicketStatus(input: { readonly value: unknown; }): TicketStatus | null { if ( - opts.value === "open" || - opts.value === "in_progress" || - opts.value === "blocked" || - opts.value === "done" + input.value === "open" || + input.value === "in_progress" || + input.value === "blocked" || + input.value === "done" ) { - return opts.value; + return input.value; } return null; } -function parseDependencyList(opts: { readonly value: unknown }): string[] { - if (!Array.isArray(opts.value)) { +function parseDependencyList(input: { readonly value: unknown }): string[] { + if (!Array.isArray(input.value)) { return []; } - const values = opts.value.filter( + const values = input.value.filter( (item): item is string => typeof item === "string" ); return normalizeTicketRefs(values); } -function parseTags(opts: { readonly value: unknown }): string[] { - if (!Array.isArray(opts.value)) { +function parseTags(input: { readonly value: unknown }): string[] { + if (!Array.isArray(input.value)) { return []; } - const tags = opts.value.filter( + const tags = input.value.filter( (item): item is string => typeof item === "string" ); return normalizeTags(tags); } -function readDependencyUpdate(opts: { +function readDependencyUpdate(input: { readonly payload: Record; readonly key: "dependsOn" | "blocks"; }): string[] | null { - if (!Object.hasOwn(opts.payload, opts.key)) { + if (!Object.hasOwn(input.payload, input.key)) { return null; } - return parseDependencyList({ value: opts.payload[opts.key] }); + return parseDependencyList({ value: input.payload[input.key] }); } -function readTagsUpdate(opts: { +function readTagsUpdate(input: { readonly payload: Record; readonly key: "tags"; }): string[] | null { - if (!Object.hasOwn(opts.payload, opts.key)) { + if (!Object.hasOwn(input.payload, input.key)) { return null; } - return parseTags({ value: opts.payload[opts.key] }); + return parseTags({ value: input.payload[input.key] }); } -function readOptionalStringUpdate(opts: { +function readOptionalStringUpdate(input: { readonly payload: Record; readonly key: + | "assignee" | "owner" | "source" | "externalSystem" @@ -797,10 +1509,73 @@ function readOptionalStringUpdate(opts: { | "externalProjectName" | "externalTeamId"; }): string | null { - if (!Object.hasOwn(opts.payload, opts.key)) { + if (!Object.hasOwn(input.payload, input.key)) { return null; } - return readOptionalStringPayload({ value: opts.payload[opts.key] }) ?? ""; + return readOptionalStringPayload({ value: input.payload[input.key] }) ?? ""; +} + +function appendMapValue(input: { + readonly map: Map; + readonly key: string; + readonly value: T; +}): void { + const current = input.map.get(input.key) ?? []; + current.push(input.value); + input.map.set(input.key, current); +} + +function parseTicketMetadataValue( + value: unknown +): TicketMetadataValue | undefined { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + if (Array.isArray(value)) { + const out: TicketMetadataValue[] = []; + for (const item of value) { + const parsed = parseTicketMetadataValue(item); + if (parsed === undefined) { + return undefined; + } + out.push(parsed); + } + return out; + } + + if (isRecord(value)) { + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + const parsed = parseTicketMetadataValue(item); + if (parsed === undefined) { + return undefined; + } + out[key] = parsed; + } + return out; + } + + return undefined; +} + +function parseTicketSyncConflictResolution(input: { + readonly value: unknown; +}): TicketSyncConflictResolution | null { + if ( + input.value === "accept_local" || + input.value === "accept_remote" || + input.value === "merged" || + input.value === "ignore" + ) { + return input.value; + } + return null; } function applyDerivedBlocks( @@ -830,28 +1605,28 @@ function applyDerivedBlocks( return tickets; } -function readOptionalStringPayload(opts: { +function readOptionalStringPayload(input: { readonly value: unknown; }): string | undefined { - if (typeof opts.value !== "string") { + if (typeof input.value !== "string") { return undefined; } - const trimmed = opts.value.trim(); + const trimmed = input.value.trim(); return trimmed.length > 0 ? trimmed : undefined; } -function normalizeOwnerOrSource(opts: { +function normalizeOwnerOrSource(input: { readonly value: string | undefined; readonly fallback: string; }): string { - const trimmed = (opts.value ?? "").trim(); - return trimmed || opts.fallback; + const trimmed = (input.value ?? "").trim(); + return trimmed || input.fallback; } -function normalizeOptionalMetadataString(opts: { +function normalizeOptionalMetadataString(input: { readonly value: string | undefined; }): string | undefined { - const trimmed = (opts.value ?? "").trim(); + const trimmed = (input.value ?? "").trim(); return trimmed.length > 0 ? trimmed : undefined; } @@ -885,6 +1660,8 @@ function parseEvent(value: unknown): TicketEvent | null { const eventId = typeof value.eventId === "string" ? value.eventId : ""; const ts = typeof value.ts === "number" ? value.ts : Number.NaN; const actor = typeof value.actor === "string" ? value.actor : ""; + const orderKey = + typeof value.orderKey === "string" ? value.orderKey : undefined; const ticketId = typeof value.ticketId === "string" ? value.ticketId : ""; const type = typeof value.type === "string" ? value.type : ""; const payload = isRecord(value.payload) @@ -907,6 +1684,7 @@ function parseEvent(value: unknown): TicketEvent | null { ts, tsIso: new Date(ts * 1000).toISOString(), actor, + ...(orderKey ? { orderKey } : {}), ...(projectId ? { projectId } : {}), ...(projectName ? { projectName } : {}), ticketId, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2ebafd1f0..01b88d824 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -930,6 +930,74 @@ function registerTools(opts: { readonly server: McpServer }): void { }); } ); + + opts.server.registerTool( + "hack.linear.deliveries.list", + { + title: "List pending Linear deliveries", + description: + "List Linear webhook deliveries from the auth broker for manual review/apply flows.", + inputSchema: { + status: z + .enum(["pending", "applied", "ignored"]) + .describe("Delivery status filter") + .optional(), + limit: z.number().describe("Max deliveries to return").optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const args = [ + "linear", + "deliveries", + "--json", + ...(input.status ? ["--status", input.status] : []), + ...(input.limit ? ["--limit", String(input.limit)] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.deliveries.list", + args, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.deliveries.apply", + { + title: "Apply pending Linear delivery", + description: + "Mark a pending Linear webhook delivery as applied after manual handling.", + inputSchema: { + deliveryId: z.string().describe("Recorded Linear delivery id"), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const args = [ + "linear", + "apply-delivery", + "--json", + "--delivery-id", + input.deliveryId, + ]; + + const result = await runHackCommand({ + tool: "hack.linear.deliveries.apply", + args, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); } function buildToolResult(opts: { diff --git a/tests/linear-client.test.ts b/tests/linear-client.test.ts new file mode 100644 index 000000000..19833f56f --- /dev/null +++ b/tests/linear-client.test.ts @@ -0,0 +1,301 @@ +import { afterEach, expect, test } from "bun:test"; + +import { createLinearClient } from "../src/control-plane/extensions/linear/client.ts"; + +const ORIGINAL_FETCH = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; +}); + +test("getIssueByIdentifier includes assignee data when present", async () => { + let requestBody: Record | null = null; + globalThis.fetch = async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response( + JSON.stringify({ + data: { + issue: { + id: "issue_123", + identifier: "ENG-123", + title: "Ship assignee sync", + description: "Make assignee sync work", + url: "https://linear.app/issue/ENG-123", + state: { + id: "state_123", + name: "In Progress", + type: "started", + }, + team: { + id: "team_123", + key: "ENG", + name: "Engineering", + }, + project: { + id: "project_123", + name: "Linear Sync", + }, + assignee: { + id: "user_123", + name: "Alice Example", + displayName: "Alice", + email: "alice@example.com", + active: true, + }, + labels: { + nodes: [], + }, + parent: null, + }, + }, + }), + { status: 200 } + ); + }; + + const client = createLinearClient({ token: "linear-token" }); + const result = await client.getIssueByIdentifier({ identifier: "ENG-123" }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(String(requestBody?.query)).toContain("assignee {"); + expect(result.data?.assigneeId).toBe("user_123"); + expect(result.data?.assigneeName).toBe("Alice Example"); + expect(result.data?.assigneeDisplayName).toBe("Alice"); + expect(result.data?.assigneeEmail).toBe("alice@example.com"); + expect(result.data?.assigneeActive).toBe(true); +}); + +test("listTeamUsers returns membership users for assignee mapping", async () => { + let requestBody: Record | null = null; + globalThis.fetch = async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response( + JSON.stringify({ + data: { + team: { + memberships: { + nodes: [ + { + user: { + id: "user_123", + name: "Alice Example", + displayName: "Alice", + email: "alice@example.com", + active: true, + }, + }, + { + user: { + id: "user_456", + name: "Bob Example", + email: "bob@example.com", + active: false, + }, + }, + ], + }, + }, + }, + }), + { status: 200 } + ); + }; + + const client = createLinearClient({ token: "linear-token" }); + const result = await client.listTeamUsers({ teamId: "team_123" }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(String(requestBody?.query)).toContain("memberships"); + expect(result.data).toEqual([ + { + id: "user_123", + name: "Alice Example", + displayName: "Alice", + email: "alice@example.com", + active: true, + }, + { + id: "user_456", + name: "Bob Example", + email: "bob@example.com", + active: false, + }, + ]); +}); + +test("listIssueComments parses issue comments", async () => { + let requestBody: Record | null = null; + globalThis.fetch = async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response( + JSON.stringify({ + data: { + issue: { + comments: { + nodes: [ + { + id: "comment_123", + body: "First sync comment", + createdAt: "2026-03-05T10:00:00.000Z", + updatedAt: "2026-03-05T10:00:00.000Z", + user: { + id: "user_123", + name: "Alice Example", + displayName: "Alice", + email: "alice@example.com", + }, + }, + ], + }, + }, + }, + }), + { status: 200 } + ); + }; + + const client = createLinearClient({ token: "linear-token" }); + const result = await client.listIssueComments({ issueId: "issue_123" }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(String(requestBody?.query)).toContain("comments("); + expect(result.data).toEqual([ + { + id: "comment_123", + body: "First sync comment", + createdAt: "2026-03-05T10:00:00.000Z", + updatedAt: "2026-03-05T10:00:00.000Z", + userId: "user_123", + userName: "Alice Example", + userDisplayName: "Alice", + userEmail: "alice@example.com", + }, + ]); +}); + +test("createComment returns created comment payload", async () => { + let requestBody: Record | null = null; + globalThis.fetch = async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response( + JSON.stringify({ + data: { + commentCreate: { + success: true, + comment: { + id: "comment_123", + body: "Ship it", + createdAt: "2026-03-05T11:00:00.000Z", + updatedAt: "2026-03-05T11:00:00.000Z", + user: { + id: "user_123", + displayName: "Alice", + }, + }, + }, + }, + }), + { status: 200 } + ); + }; + + const client = createLinearClient({ token: "linear-token" }); + const result = await client.createComment({ + issueId: "issue_123", + body: "Ship it", + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(String(requestBody?.query)).toContain("commentCreate"); + expect(requestBody?.variables).toEqual({ + input: { + issueId: "issue_123", + body: "Ship it", + }, + }); + expect(result.data).toEqual({ + id: "comment_123", + body: "Ship it", + createdAt: "2026-03-05T11:00:00.000Z", + updatedAt: "2026-03-05T11:00:00.000Z", + userId: "user_123", + userDisplayName: "Alice", + }); +}); + +test("updateIssue forwards assigneeId when provided", async () => { + let requestBody: Record | null = null; + globalThis.fetch = async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response( + JSON.stringify({ + data: { + issueUpdate: { + success: true, + issue: { + id: "issue_123", + identifier: "ENG-123", + title: "Ship assignee sync", + description: "Make assignee sync work", + state: { + id: "state_123", + name: "In Progress", + type: "started", + }, + team: { + id: "team_123", + }, + assignee: { + id: "user_123", + displayName: "Alice", + email: "alice@example.com", + }, + labels: { + nodes: [], + }, + parent: null, + }, + }, + }, + }), + { status: 200 } + ); + }; + + const client = createLinearClient({ token: "linear-token" }); + const result = await client.updateIssue({ + issueId: "issue_123", + title: "Ship assignee sync", + assigneeId: "user_123", + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(requestBody?.variables).toEqual({ + id: "issue_123", + input: { + title: "Ship assignee sync", + assigneeId: "user_123", + }, + }); + expect(result.data?.assigneeId).toBe("user_123"); +}); diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index 5d5a4e33b..e82b91b19 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -220,3 +220,532 @@ test("buildOAuthArgsFromConnectArgs maps connect defaults into oauth args", () = "hack-linear-auth", ]); }); + +test("parseDeliveriesArgs parses status, limit, and json flags", () => { + const parsed = __testOnly.parseDeliveriesArgs({ + args: ["--status", "applied", "--limit", "10", "--json"], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + status: "applied", + limit: 10, + json: true, + }); +}); + +test("parseApplyDeliveryArgs requires a delivery id", () => { + const parsed = __testOnly.parseApplyDeliveryArgs({ + args: [], + }); + + expect(parsed.ok).toBe(false); + if (parsed.ok) { + return; + } + + expect(parsed.error).toContain("--delivery-id"); +}); + +test("detectAuthoritativeFieldConflicts reports divergence for hack-owned tickets", () => { + const conflicts = __testOnly.detectAuthoritativeFieldConflicts({ + authority: "hack", + ticket: { + ticketId: "T-00001", + title: "Local title", + body: "Local body", + status: "in_progress", + createdAt: "2026-03-05T10:00:00.000Z", + updatedAt: "2026-03-05T10:00:00.000Z", + dependsOn: [], + blocks: [], + owner: "hack", + source: "hack", + tags: [], + projectId: "proj-local", + projectName: "Local Project", + }, + issue: { + id: "issue-1", + identifier: "ENG-123", + title: "Remote title", + description: "Remote body", + url: "https://linear.app/issue/ENG-123", + state: { + id: "state-1", + name: "Started", + type: "started", + }, + teamId: "team-1", + projectId: "proj-remote", + projectName: "Remote Project", + labels: [], + }, + remoteProjection: { + body: "Remote body\n\n---\n\nLinear Issue: ENG-123\n\nLinear URL: https://linear.app/issue/ENG-123", + status: "done", + }, + }); + + expect(conflicts.map((conflict) => conflict.field)).toEqual([ + "title", + "body", + "status", + "project", + ]); +}); + +test("selectLinearCommentsToAppend keeps unmatched remote ids in FIFO order", () => { + const selected = __testOnly.selectLinearCommentsToAppend({ + localComments: [ + { + commentId: "comment-1", + ticketId: "T-00001", + body: "Already synced", + source: "linear", + actor: "linear", + createdAt: "2026-03-05T10:00:00.000Z", + externalId: "linear-comment-1", + }, + ], + remoteComments: [ + { + id: "linear-comment-1", + body: "Already synced", + createdAt: "2026-03-05T09:00:00.000Z", + }, + { + id: "linear-comment-2", + body: "Same body duplicate", + createdAt: "2026-03-05T09:05:00.000Z", + }, + { + id: "linear-comment-3", + body: "Same body duplicate", + createdAt: "2026-03-05T09:06:00.000Z", + }, + ], + }); + + expect(selected.map((comment) => comment.id)).toEqual([ + "linear-comment-2", + "linear-comment-3", + ]); +}); + +test("selectTicketCommentsToPush keeps unsynced local comments even when bodies repeat", () => { + const selected = __testOnly.selectTicketCommentsToPush({ + localComments: [ + { + commentId: "comment-1", + ticketId: "T-00001", + body: "Push me", + source: "hack", + actor: "dio", + createdAt: "2026-03-05T10:00:00.000Z", + externalId: "linear-comment-1", + }, + { + commentId: "comment-2", + ticketId: "T-00001", + body: "Push me", + source: "hack", + actor: "dio", + createdAt: "2026-03-05T10:01:00.000Z", + }, + { + commentId: "comment-3", + ticketId: "T-00001", + body: "Came from Linear", + source: "linear", + actor: "linear", + createdAt: "2026-03-05T10:02:00.000Z", + externalId: "linear-comment-3", + }, + ], + remoteComments: [], + }); + + expect(selected.map((comment) => comment.commentId)).toEqual(["comment-2"]); +}); + +test("syncIssueFromLinearToTicket preserves hack authority, appends comments, and records checkpoints", async () => { + const conflicts: string[] = []; + const appendedBodies: string[] = []; + const checkpoints: string[] = []; + const updatedTickets: Record[] = []; + + const runtime = { + profileId: "default", + apiUrl: "https://api.linear.app/graphql", + projectBinding: {}, + tickets: { + listTickets: async () => [ + { + ticketId: "T-00001", + title: "Hack-owned title", + body: "Hack-owned body", + status: "open" as const, + createdAt: "2026-03-05T10:00:00.000Z", + updatedAt: "2026-03-05T10:00:00.000Z", + dependsOn: [], + blocks: [], + owner: "hack", + source: "hack", + assignee: "Local Owner", + tags: [], + externalSystem: "linear", + externalId: "issue-1", + externalKey: "ENG-123", + externalProjectId: "proj-local", + }, + ], + updateTicket: async (input: Record) => { + updatedTickets.push(input); + return { ok: true as const }; + }, + setStatus: async () => ({ ok: true as const }), + createTicket: async () => { + throw new Error("createTicket should not be called"); + }, + getTicket: async () => null, + getTicketDetail: async () => ({ + ticket: null, + events: [], + comments: [ + { + commentId: "comment-1", + ticketId: "T-00001", + body: "Already synced", + source: "linear", + actor: "linear", + createdAt: "2026-03-05T10:00:00.000Z", + externalId: "linear-comment-1", + }, + ], + syncCheckpoints: [], + conflicts: [], + }), + appendComment: async (input: { readonly body: string }) => { + appendedBodies.push(input.body); + return { + ok: true as const, + comment: { + commentId: "comment-new", + ticketId: "T-00001", + body: input.body, + source: "linear", + actor: "linear", + createdAt: "2026-03-05T10:05:00.000Z", + }, + }; + }, + recordSyncCheckpoint: async (input: { readonly direction?: string }) => { + checkpoints.push(input.direction ?? ""); + return { + ok: true as const, + checkpoint: { + checkpointId: "checkpoint-1", + ticketId: "T-00001", + provider: "linear", + direction: input.direction, + actor: "test", + createdAt: "2026-03-05T10:10:00.000Z", + }, + }; + }, + recordSyncConflict: async (input: { readonly field: string }) => { + conflicts.push(input.field); + return { + ok: true as const, + conflict: { + conflictId: `conflict-${input.field}`, + ticketId: "T-00001", + provider: "linear", + field: input.field, + status: "open", + createdAt: "2026-03-05T10:10:00.000Z", + updatedAt: "2026-03-05T10:10:00.000Z", + }, + }; + }, + }, + linear: { + getIssueByIdentifier: async () => ({ + ok: true as const, + data: { + id: "issue-1", + identifier: "ENG-123", + title: "Linear title", + description: "Linear body", + url: "https://linear.app/issue/ENG-123", + state: { + id: "state-1", + name: "Done", + type: "completed" as const, + }, + teamId: "team-1", + projectId: "proj-remote", + projectName: "Remote Project", + assigneeDisplayName: "Remote Owner", + labels: [], + }, + }), + listIssueComments: async () => ({ + ok: true as const, + data: [ + { + id: "linear-comment-1", + body: "Already synced", + createdAt: "2026-03-05T09:00:00.000Z", + }, + { + id: "linear-comment-2", + body: "Fresh remote note", + createdAt: "2026-03-05T09:10:00.000Z", + userDisplayName: "Remote Owner", + }, + ], + }), + }, + }; + + const result = await __testOnly.syncIssueFromLinearToTicket({ + runtime, + issueIdentifier: "ENG-123", + syncToggles: { + labels: false, + statuses: true, + dependencies: false, + projects: true, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.commentsPulled).toBe(1); + expect(result.conflictsRecorded).toBe(4); + expect(appendedBodies).toEqual(["Fresh remote note"]); + expect(conflicts).toEqual(["title", "body", "status", "project"]); + expect(checkpoints).toEqual(["linear_to_hack"]); + expect(updatedTickets).toHaveLength(1); + expect(updatedTickets[0]?.title).toBeUndefined(); + expect(updatedTickets[0]?.body).toBeUndefined(); + expect(updatedTickets[0]?.assignee).toBe("Remote Owner"); +}); + +test("syncTicketToLinearIssue pushes missing local comments and records a checkpoint", async () => { + const pushedBodies: string[] = []; + const checkpoints: string[] = []; + const linkedComments: Array<{ + readonly commentId: string; + readonly externalId: string; + }> = []; + const updatedIssues: Record[] = []; + + const runtime = { + profileId: "default", + apiUrl: "https://api.linear.app/graphql", + projectBinding: { + teamId: "team-1", + }, + tickets: { + getTicket: async () => ({ + ticketId: "T-00001", + title: "Hack title", + body: "Hack body", + status: "open" as const, + createdAt: "2026-03-05T10:00:00.000Z", + updatedAt: "2026-03-05T10:00:00.000Z", + dependsOn: [], + blocks: [], + owner: "hack", + source: "hack", + assignee: "alice@example.com", + tags: [], + externalSystem: "linear", + externalId: "issue-1", + externalKey: "ENG-123", + externalTeamId: "team-1", + }), + updateTicket: async () => ({ ok: true as const }), + listTickets: async () => [], + getTicketDetail: async () => ({ + ticket: null, + events: [], + comments: [ + { + commentId: "comment-1", + ticketId: "T-00001", + body: "Already remote", + source: "hack", + actor: "dio", + createdAt: "2026-03-05T10:00:00.000Z", + }, + { + commentId: "comment-2", + ticketId: "T-00001", + body: "Push me", + source: "hack", + actor: "dio", + createdAt: "2026-03-05T10:01:00.000Z", + }, + ], + syncCheckpoints: [], + conflicts: [], + }), + linkCommentExternalId: async (input: { + readonly commentId: string; + readonly externalId: string; + }) => { + linkedComments.push({ + commentId: input.commentId, + externalId: input.externalId, + }); + return { ok: true as const }; + }, + recordSyncCheckpoint: async (input: { readonly direction?: string }) => { + checkpoints.push(input.direction ?? ""); + return { + ok: true as const, + checkpoint: { + checkpointId: "checkpoint-1", + ticketId: "T-00001", + provider: "linear", + direction: input.direction, + actor: "test", + createdAt: "2026-03-05T10:10:00.000Z", + }, + }; + }, + recordSyncConflict: async () => { + throw new Error("recordSyncConflict should not be called"); + }, + }, + linear: { + getIssueById: async () => ({ + ok: true as const, + data: { + id: "issue-1", + identifier: "ENG-123", + title: "Hack title", + description: "Hack body", + state: { + id: "state-1", + name: "Todo", + type: "unstarted" as const, + }, + teamId: "team-1", + assigneeEmail: "alice@example.com", + labels: [], + }, + }), + updateIssue: async (input: Record) => { + updatedIssues.push(input); + return { + ok: true as const, + data: { + id: "issue-1", + identifier: "ENG-123", + title: "Hack title", + description: "Hack body", + state: { + id: "state-1", + name: "Todo", + type: "unstarted" as const, + }, + teamId: "team-1", + assigneeId: "user-1", + labels: [], + }, + }; + }, + createIssue: async () => { + throw new Error("createIssue should not be called"); + }, + listTeamStates: async () => ({ + ok: true as const, + data: [ + { + id: "state-1", + name: "Todo", + type: "unstarted" as const, + }, + ], + }), + listTeamLabels: async () => ({ + ok: true as const, + data: [], + }), + listIssueComments: async () => ({ + ok: true as const, + data: [ + { + id: "linear-comment-1", + body: "Already remote", + createdAt: "2026-03-05T09:00:00.000Z", + }, + ], + }), + createComment: async (input: { readonly body: string }) => { + pushedBodies.push(input.body); + return { + ok: true as const, + data: { + id: `linear-${pushedBodies.length}`, + body: input.body, + createdAt: "2026-03-05T10:15:00.000Z", + }, + }; + }, + listTeamUsers: async () => ({ + ok: true as const, + data: [ + { + id: "user-1", + email: "alice@example.com", + displayName: "Alice", + }, + ], + }), + getIssueByIdentifier: async () => ({ ok: true as const, data: null }), + getProject: async () => ({ ok: true as const, data: null }), + }, + }; + + const result = await __testOnly.syncTicketToLinearIssue({ + runtime, + ticketId: "T-00001", + syncToggles: { + labels: false, + statuses: true, + dependencies: false, + projects: true, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.commentsPushed).toBe(1); + expect(result.assignee.matchedUserId).toBe("user-1"); + expect(result.assignee.applied).toBe(true); + expect(pushedBodies).toEqual(["Push me"]); + expect(linkedComments).toEqual([ + { + commentId: "comment-2", + externalId: "linear-1", + }, + ]); + expect(checkpoints).toEqual(["hack_to_linear"]); + expect(updatedIssues[0]?.assigneeId).toBe("user-1"); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index b2ca4b0e9..cb2b0604d 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -41,6 +41,8 @@ testIntegration( expect(names).toContain("hack.project.open"); expect(names).toContain("hack.linear.status"); expect(names).toContain("hack.linear.sync.project"); + expect(names).toContain("hack.linear.deliveries.list"); + expect(names).toContain("hack.linear.deliveries.apply"); } ); @@ -90,6 +92,60 @@ testIntegration( } ); +testIntegration( + "hack.linear.deliveries.list forwards status and limit flags", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.deliveries.list", + arguments: { + status: "pending", + limit: 5, + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + status: "pending", + limit: 5, + deliveries: [{ id: "delivery-1", status: "pending" }], + }); + } +); + +testIntegration( + "hack.linear.deliveries.apply forwards delivery id", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.deliveries.apply", + arguments: { + deliveryId: "delivery-1", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + deliveryId: "delivery-1", + status: "applied", + }); + } +); + async function startMcpClient(): Promise { tempDir = await mkdtemp(join(tmpdir(), "hack-mcp-")); const homeDir = join(tempDir, "home"); @@ -135,6 +191,20 @@ function buildHackStubScript(): string { ' console.log(JSON.stringify({ extensionId: "dance.hack.linear", selectedProfile: "default", tokenResolved: true }))', " process.exit(0)", " }", + ' if (sub === "deliveries") {', + ' const statusIndex = args.indexOf("--status")', + ' const limitIndex = args.indexOf("--limit")', + ' const status = statusIndex === -1 ? "pending" : args[statusIndex + 1]', + " const limit = limitIndex === -1 ? null : Number(args[limitIndex + 1])", + ' console.log(JSON.stringify({ status, limit, deliveries: [{ id: "delivery-1", status }] }))', + " process.exit(0)", + " }", + ' if (sub === "apply-delivery") {', + ' const idIndex = args.indexOf("--delivery-id")', + " const deliveryId = idIndex === -1 ? null : args[idIndex + 1]", + ' console.log(JSON.stringify({ deliveryId, status: "applied" }))', + " process.exit(0)", + " }", "}", "console.error(`unknown command: ${cmd}`)", "process.exit(1)", diff --git a/tests/tickets-extension.test.ts b/tests/tickets-extension.test.ts index f677f2f5a..8e0a0ee4a 100644 --- a/tests/tickets-extension.test.ts +++ b/tests/tickets-extension.test.ts @@ -3,6 +3,8 @@ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { createTicketsStore } from "../src/control-plane/extensions/tickets/store.ts"; +import { readControlPlaneConfig } from "../src/control-plane/sdk/config.ts"; import { testIntegration } from "./helpers/ci.ts"; const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; @@ -119,6 +121,125 @@ testIntegration( } ); +testIntegration( + "tickets extension: assignee, comment, and conflict resolution commands work end to end", + { timeout: 60_000 }, + async () => { + const root = await mkdirTempDir({ prefix: "hack-cli-tickets-sync-e2e-" }); + const projectDir = join(root, "project"); + + await mkdir(projectDir, { recursive: true }); + await copyDir({ + from: resolve(import.meta.dir, "../examples/tickets"), + to: projectDir, + }); + + await run({ cwd: projectDir, cmd: ["git", "init"] }); + await run({ + cwd: projectDir, + cmd: ["git", "config", "user.email", "tests@hack"], + }); + await run({ + cwd: projectDir, + cmd: ["git", "config", "user.name", "hack-cli-tests"], + }); + await run({ cwd: projectDir, cmd: ["git", "add", "-A"] }); + await run({ cwd: projectDir, cmd: ["git", "commit", "-m", "init"] }); + + const created = await runHack({ + cwd: projectDir, + args: [ + "tickets", + "create", + "--title", + "Sync lifecycle ticket", + "--assignee", + "alice@hack", + "--json", + ], + }); + const createdJson = JSON.parse(created.stdout) as { + ticket: { ticketId: string }; + }; + const ticketId = createdJson.ticket.ticketId; + + const commented = await runHack({ + cwd: projectDir, + args: [ + "tickets", + "comment", + ticketId, + "--body", + "Append only note", + "--source", + "hack", + "--json", + ], + }); + const commentJson = JSON.parse(commented.stdout) as { + comment: { body: string; source: string }; + }; + expect(commentJson.comment.body).toBe("Append only note"); + expect(commentJson.comment.source).toBe("hack"); + + const store = await createStore({ projectRoot: projectDir }); + const conflict = await store.recordSyncConflict({ + ticketId, + provider: "linear", + field: "title", + authority: "origin", + localValue: "Local title", + remoteValue: "Remote title", + summary: "Title diverged during sync.", + actor: "sync@app", + }); + expect(conflict.ok).toBe(true); + if (!conflict.ok) { + throw new Error(conflict.error); + } + + const resolved = await runHack({ + cwd: projectDir, + args: [ + "tickets", + "resolve-conflict", + ticketId, + "--conflict-id", + conflict.conflict.conflictId, + "--resolution", + "accept_remote", + "--summary", + "Remote remains authoritative.", + "--json", + ], + }); + const resolvedJson = JSON.parse(resolved.stdout) as { + ok: boolean; + resolution: string; + }; + expect(resolvedJson.ok).toBe(true); + expect(resolvedJson.resolution).toBe("accept_remote"); + + const shown = await runHack({ + cwd: projectDir, + args: ["tickets", "show", ticketId, "--json"], + }); + const showJson = JSON.parse(shown.stdout) as { + ticket: { assignee?: string }; + comments: Array<{ body: string }>; + conflicts: Array<{ status: string; resolution?: string }>; + }; + expect(showJson.ticket.assignee).toBe("alice@hack"); + expect(showJson.comments.map((comment) => comment.body)).toContain( + "Append only note" + ); + expect(showJson.conflicts[0]?.status).toBe("resolved"); + expect(showJson.conflicts[0]?.resolution).toBe("accept_remote"); + + await rm(root, { recursive: true, force: true }); + } +); + interface RunResult { readonly stdout: string; readonly stderr: string; @@ -167,6 +288,18 @@ async function runHack(opts: { }); } +async function createStore(opts: { readonly projectRoot: string }) { + const result = await readControlPlaneConfig({}); + return createTicketsStore({ + projectRoot: opts.projectRoot, + controlPlaneConfig: result.config, + logger: { + info: () => {}, + warn: () => {}, + }, + }); +} + async function mkdirTempDir(opts: { readonly prefix: string; }): Promise { diff --git a/tests/tickets-store.test.ts b/tests/tickets-store.test.ts new file mode 100644 index 000000000..452d9a7e4 --- /dev/null +++ b/tests/tickets-store.test.ts @@ -0,0 +1,388 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createTicketsStore } from "../src/control-plane/extensions/tickets/store.ts"; +import { readControlPlaneConfig } from "../src/control-plane/sdk/config.ts"; + +const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; +const logger = { + info: (_input: { message: string }) => {}, + warn: (_input: { message: string }) => {}, +}; + +let tempGlobalConfigPath: string | null = null; +let tempRoots: string[] = []; + +beforeEach(() => { + tempGlobalConfigPath = join( + tmpdir(), + `hack-global-config-${Date.now()}-${Math.random()}.json` + ); + process.env.HACK_GLOBAL_CONFIG_PATH = tempGlobalConfigPath; +}); + +afterEach(async () => { + for (const root of tempRoots) { + await rm(root, { recursive: true, force: true }); + } + tempRoots = []; + + if (tempGlobalConfigPath) { + await rm(tempGlobalConfigPath, { force: true }); + tempGlobalConfigPath = null; + } + + if (originalGlobalConfigPath === undefined) { + process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + } else { + process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + } +}); + +test( + "tickets store materializes assignee, comments, checkpoints, and conflicts", + { timeout: 20_000 }, + async () => { + const projectRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-store-", + }); + const store = await createStore({ projectRoot }); + + const created = await store.createTicket({ + title: "Sync metadata ticket", + owner: "hack", + source: "hack", + actor: "creator@hack", + }); + expect(created.ok).toBe(true); + if (!created.ok) { + throw new Error(created.error); + } + + const ticketId = created.ticket.ticketId; + + const updated = await store.updateTicket({ + ticketId, + assignee: "alice@hack", + actor: "router@hack", + }); + expect(updated.ok).toBe(true); + + const firstComment = await store.appendComment({ + ticketId, + body: "Imported from Linear.", + source: "linear", + externalId: "comment-1", + actor: "linear@app", + }); + expect(firstComment.ok).toBe(true); + if (!firstComment.ok) { + throw new Error(firstComment.error); + } + + const secondComment = await store.appendComment({ + ticketId, + body: "Local follow-up.", + source: "hack", + actor: "alice@hack", + }); + expect(secondComment.ok).toBe(true); + if (!secondComment.ok) { + throw new Error(secondComment.error); + } + + const linkedComment = await store.linkCommentExternalId({ + ticketId, + commentId: secondComment.comment.commentId, + externalId: "linear-comment-2", + externalUrl: "https://linear.app/issue/HACK-1#comment-2", + actor: "sync@app", + }); + expect(linkedComment.ok).toBe(true); + + const checkpoint = await store.recordSyncCheckpoint({ + ticketId, + provider: "linear", + profileId: "default", + direction: "pull", + remoteCursor: "issue/LIN-123#v2", + remoteUpdatedAt: "2026-03-05T18:15:00.000Z", + actor: "sync@app", + }); + expect(checkpoint.ok).toBe(true); + if (!checkpoint.ok) { + throw new Error(checkpoint.error); + } + + const conflict = await store.recordSyncConflict({ + ticketId, + provider: "linear", + field: "assignee", + localValue: "alice@hack", + remoteValue: "bob@linear", + authority: "origin", + summary: "Assignee diverged during pull.", + actor: "sync@app", + }); + expect(conflict.ok).toBe(true); + if (!conflict.ok) { + throw new Error(conflict.error); + } + + const resolved = await store.resolveSyncConflict({ + ticketId, + conflictId: conflict.conflict.conflictId, + resolution: "accept_remote", + summary: "Linear remains source of truth for this field.", + actor: "alice@hack", + }); + expect(resolved.ok).toBe(true); + + const snapshot = await store.readSnapshot(); + const ticket = snapshot.tickets.find((item) => item.ticketId === ticketId); + expect(ticket?.assignee).toBe("alice@hack"); + + const comments = snapshot.commentsByTicket.get(ticketId) ?? []; + expect(comments).toHaveLength(2); + expect(comments.map((item) => item.body)).toEqual([ + "Imported from Linear.", + "Local follow-up.", + ]); + expect(comments[0]).toMatchObject({ + source: "linear", + externalId: "comment-1", + actor: "linear@app", + }); + expect(comments[1]).toMatchObject({ + source: "hack", + externalId: "linear-comment-2", + externalUrl: "https://linear.app/issue/HACK-1#comment-2", + }); + + const checkpoints = snapshot.syncCheckpointsByTicket.get(ticketId) ?? []; + expect(checkpoints).toHaveLength(1); + expect(checkpoints[0]).toMatchObject({ + provider: "linear", + profileId: "default", + direction: "pull", + remoteCursor: "issue/LIN-123#v2", + remoteUpdatedAt: "2026-03-05T18:15:00.000Z", + }); + + const conflicts = snapshot.conflictsByTicket.get(ticketId) ?? []; + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ + provider: "linear", + field: "assignee", + status: "resolved", + authority: "origin", + localValue: "alice@hack", + remoteValue: "bob@linear", + resolution: "accept_remote", + resolutionSummary: "Linear remains source of truth for this field.", + }); + + const events = await store.listEvents({ ticketId }); + expect(events.map((item) => item.type)).toEqual([ + "ticket.created", + "ticket.updated", + "ticket.comment_appended", + "ticket.comment_appended", + "ticket.comment_linked", + "ticket.sync_checkpoint_recorded", + "ticket.sync_conflict_recorded", + "ticket.sync_conflict_resolved", + ]); + } +); + +test("tickets show json includes materialized sync metadata", async () => { + const projectRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-show-", + }); + const store = await createStore({ projectRoot }); + + const created = await store.createTicket({ + title: "Show metadata ticket", + owner: "hack", + source: "hack", + actor: "creator@hack", + }); + expect(created.ok).toBe(true); + if (!created.ok) { + throw new Error(created.error); + } + + const ticketId = created.ticket.ticketId; + const updated = await store.updateTicket({ + ticketId, + assignee: "alice@hack", + actor: "router@hack", + }); + expect(updated.ok).toBe(true); + + const comment = await store.appendComment({ + ticketId, + body: "Needs review.", + source: "linear", + actor: "linear@app", + }); + expect(comment.ok).toBe(true); + + const checkpoint = await store.recordSyncCheckpoint({ + ticketId, + provider: "linear", + profileId: "default", + direction: "pull", + remoteCursor: "issue/LIN-321#v3", + actor: "sync@app", + }); + expect(checkpoint.ok).toBe(true); + + const conflict = await store.recordSyncConflict({ + ticketId, + provider: "linear", + field: "status", + localValue: "in_progress", + remoteValue: "done", + authority: "origin", + summary: "Status diverged during sync.", + actor: "sync@app", + }); + expect(conflict.ok).toBe(true); + + const shown = await runHack({ + cwd: projectRoot, + args: ["tickets", "show", ticketId, "--json"], + }); + expect(shown.exitCode).toBe(0); + + const payload = JSON.parse(shown.stdout) as { + ticket: { ticketId: string; assignee?: string }; + comments: { body: string }[]; + syncCheckpoints: { provider: string; remoteCursor?: string }[]; + conflicts: { field: string; status: string }[]; + events: { type: string }[]; + }; + + expect(payload.ticket.ticketId).toBe(ticketId); + expect(payload.ticket.assignee).toBe("alice@hack"); + expect(payload.comments).toHaveLength(1); + expect(payload.comments[0]?.body).toBe("Needs review."); + expect(payload.syncCheckpoints[0]).toMatchObject({ + provider: "linear", + remoteCursor: "issue/LIN-321#v3", + }); + expect(payload.conflicts[0]).toMatchObject({ + field: "status", + status: "open", + }); + expect( + payload.events.some((event) => event.type === "ticket.comment_appended") + ).toBe(true); +}); + +async function createStore(opts: { readonly projectRoot: string }) { + const configResult = await readControlPlaneConfig({ + projectDir: join(opts.projectRoot, ".hack"), + }); + return createTicketsStore({ + projectRoot: opts.projectRoot, + controlPlaneConfig: configResult.config, + logger, + }); +} + +async function createTempGitProject(opts: { + readonly prefix: string; +}): Promise { + const root = await mkdtemp(join(tmpdir(), opts.prefix)); + tempRoots.push(root); + await copyDir({ + from: resolve(import.meta.dir, "../examples/tickets"), + to: root, + }); + await run({ cwd: root, cmd: ["git", "init"] }); + await run({ cwd: root, cmd: ["git", "config", "user.email", "tests@hack"] }); + await run({ + cwd: root, + cmd: ["git", "config", "user.name", "hack-cli-tests"], + }); + await run({ cwd: root, cmd: ["git", "add", "-A"] }); + await run({ cwd: root, cmd: ["git", "commit", "-m", "init"] }); + return root; +} + +type RunResult = { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +}; + +async function run(opts: { + readonly cwd: string; + readonly cmd: readonly string[]; +}): Promise { + const result = await runAllowFail(opts); + if (result.exitCode !== 0) { + throw new Error( + `Command failed (${result.exitCode}): ${opts.cmd.join(" ")}\n${result.stderr || result.stdout}` + ); + } + return result; +} + +async function runAllowFail(opts: { + readonly cwd: string; + readonly cmd: readonly string[]; +}): Promise { + const proc = Bun.spawn([...opts.cmd], { + cwd: opts.cwd, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: process.env, + }); + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + return { stdout, stderr, exitCode }; +} + +async function runHack(opts: { + readonly cwd: string; + readonly args: readonly string[]; +}): Promise { + return await runAllowFail({ + cwd: opts.cwd, + cmd: ["bun", resolve(import.meta.dir, "../index.ts"), ...opts.args], + }); +} + +async function copyDir(opts: { + readonly from: string; + readonly to: string; +}): Promise { + await mkdir(opts.to, { recursive: true }); + const entries = await readdir(opts.from, { withFileTypes: true }); + for (const entry of entries) { + const fromPath = join(opts.from, entry.name); + const toPath = join(opts.to, entry.name); + if (entry.isDirectory()) { + await copyDir({ from: fromPath, to: toPath }); + } else if (entry.isFile()) { + const data = await readFile(fromPath); + await writeFile(toPath, data); + } + } +} From b65955b3e8cfa3eb3ebf52c6e3ba71df75ef8c59 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 5 Mar 2026 19:43:00 -0500 Subject: [PATCH 03/54] Finish Linear broker ownership and review queue --- .../DashboardFeature/CommandPalette.swift | 32 + .../DashboardFeature/DashboardModel.swift | 71 ++ .../SettingsOverlayView.swift | 314 ++++++++ .../DashboardFeature/TicketsView.swift | 636 +++++++++++++++- .../HackCLIService/HackCLIClient.swift | 99 +++ .../Sources/HackDesktopModels/Models.swift | 201 +++++ .../TicketDetailReviewModelTests.swift | 96 +++ services/auth-broker/src/app.ts | 2 + services/auth-broker/src/db.ts | 8 + services/auth-broker/src/db/schema.ts | 47 ++ .../src/modules/better-auth/session.ts | 91 +++ .../src/modules/linear-agent/plugin.ts | 4 + .../src/modules/linear-connections/plugin.ts | 35 +- .../src/modules/linear-connections/service.ts | 117 ++- .../linear-oauth/callback-routes-plugin.ts | 3 +- .../src/modules/linear-oauth/service.ts | 8 + .../src/modules/linear-sync-store/plugin.ts | 54 +- .../src/modules/linear-sync-store/service.ts | 86 ++- .../src/modules/providers/plugin.ts | 81 +- services/auth-broker/tests/index.test.ts | 637 +++++++++++++++- .../extensions/linear/commands.ts | 699 +++++++++++++++++- .../extensions/tickets/commands.ts | 10 +- src/mcp/server.ts | 124 ++++ tests/linear-client.test.ts | 94 ++- tests/linear-commands.test.ts | 132 +++- tests/mcp.test.ts | 141 ++++ tests/tickets-store.test.ts | 296 ++++---- 27 files changed, 3841 insertions(+), 277 deletions(-) create mode 100644 services/auth-broker/src/db.ts create mode 100644 services/auth-broker/src/db/schema.ts create mode 100644 services/auth-broker/src/modules/better-auth/session.ts diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift index f12d4472c..6f3f54b89 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift @@ -7,6 +7,7 @@ extension Notification.Name { public static let hackCommandPaletteRequested = Notification.Name("hack.commandPalette.requested") public static let hackRefreshRequested = Notification.Name("hack.refresh.requested") public static let hackProjectNavigationRequested = Notification.Name("hack.projectNavigation.requested") + public static let hackTicketReviewQueueRequested = Notification.Name("hack.ticketReviewQueue.requested") } enum ProjectNavigationRequest { @@ -15,6 +16,10 @@ enum ProjectNavigationRequest { static let sidebarKey = "sidebar" } +enum TicketReviewQueueRequest { + static let projectIdKey = "projectId" +} + struct CommandPaletteAction: Identifiable { let id = UUID() let title: String @@ -160,6 +165,14 @@ struct CommandPaletteView: View { openProjectTickets(project) } ) + actions.append( + CommandPaletteAction( + title: "Open Review Queue: \(project.name)", + subtitle: "Conflicts, comments, and merge follow-up" + ) { + openProjectTicketReviewQueue(project) + } + ) actions.append( CommandPaletteAction( title: "Open Linear routing: \(project.name)", @@ -224,6 +237,14 @@ struct CommandPaletteView: View { openProjectTickets(project) } ) + actions.append( + CommandPaletteAction( + title: "Project: Review queue", + subtitle: project.name + ) { + openProjectTicketReviewQueue(project) + } + ) } actions.append( @@ -315,6 +336,17 @@ struct CommandPaletteView: View { model.selectedProjectTab = .tickets } + private func openProjectTicketReviewQueue(_ project: ProjectSummary) { + openProjectTickets(project) + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .hackTicketReviewQueueRequested, + object: nil, + userInfo: [TicketReviewQueueRequest.projectIdKey: project.id] + ) + } + } + private func openProjectRouting(_ project: ProjectSummary) { openProjectOverview(project) NotificationCenter.default.post( diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 30f553da7..84fc7173c 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -320,6 +320,28 @@ public final class DashboardModel { } } + public func appendTicketComment( + for project: ProjectSummary, + ticketId: String, + body: String, + source: String? = nil, + actor: String? = nil + ) async -> TicketCommentAppendResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + return await runActionResult(message: "Appending ticket comment…") { + try await self.ticketsClient.appendTicketComment( + path: path, + ticketId: ticketId, + body: body, + source: source, + actor: actor + ) + } + } + public func syncTickets(for project: ProjectSummary) async -> TicketsSyncResult? { guard let path = resolveProjectPath(project) else { errorMessage = "Missing project path for \(project.name)" @@ -686,6 +708,55 @@ public final class DashboardModel { } } + public func listLinearAssigneeMappings( + profileId: String? = nil, + teamId: String? = nil + ) async -> LinearAssigneeMappingsResponse? { + do { + return try await client.listLinearAssigneeMappings( + profileId: profileId, + teamId: teamId + ) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + public func setLinearAssigneeMapping( + profileId: String? = nil, + teamId: String? = nil, + localAssignee: String, + linearUserId: String? = nil, + linearUserName: String? = nil, + linearUserEmail: String? = nil + ) async -> LinearAssigneeMappingMutationResponse? { + return await runActionResult(message: "Saving Linear assignee mapping…") { + try await self.client.setLinearAssigneeMapping( + profileId: profileId, + teamId: teamId, + localAssignee: localAssignee, + linearUserId: linearUserId, + linearUserName: linearUserName, + linearUserEmail: linearUserEmail + ) + } + } + + public func removeLinearAssigneeMapping( + profileId: String? = nil, + teamId: String? = nil, + localAssignee: String + ) async -> LinearAssigneeMappingRemovalResponse? { + return await runActionResult(message: "Removing Linear assignee mapping…") { + try await self.client.removeLinearAssigneeMapping( + profileId: profileId, + teamId: teamId, + localAssignee: localAssignee + ) + } + } + public func bindLinearProject( for project: ProjectSummary, profileId: String?, diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index 6e18c75d6..b84299a79 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -2650,6 +2650,7 @@ private struct TailscaleOAuthCredentialControl: View { private struct LinearExtensionSettingsView: View { @Environment(DashboardModel.self) private var model + @Environment(\.colorScheme) private var colorScheme @State private var isLoadingConfig = false @State private var isSavingConfig = false @State private var isLoadingDiagnostics = false @@ -2667,6 +2668,15 @@ private struct LinearExtensionSettingsView: View { @State private var authPollingTask: Task? = nil @State private var authFlowStatus: LinearOAuthFlowStatusResponse? = nil @State private var disconnectingLinearProfiles: Set = [] + @State private var assigneeMappings: [LinearAssigneeMapping] = [] + @State private var assigneeMappingProfile = "" + @State private var assigneeMappingTeamId = "" + @State private var assigneeMappingLocalAssignee = "" + @State private var assigneeMappingLinearUserId = "" + @State private var assigneeMappingLinearUserName = "" + @State private var assigneeMappingLinearUserEmail = "" + @State private var isLoadingAssigneeMappings = false + @State private var isSavingAssigneeMapping = false @State private var message = "" @State private var lastDiagnosticsRefreshAt: Date? = nil @@ -2957,6 +2967,163 @@ private struct LinearExtensionSettingsView: View { } } + GlassCard(title: "Assignee mapping", systemImage: "person.crop.rectangle.stack") { + VStack(alignment: .leading, spacing: 12) { + InlineCallout( + tone: .neutral, + title: "Best-effort assignee translation", + message: "Use explicit mappings when Hack assignee names do not line up cleanly with Linear users. Sync uses the profile and optional team scope here before it falls back to fuzzy email/name matching.", + actions: [] + ) + + if linearProfiles.isEmpty { + Text("Connect a Linear account before managing assignee mappings.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 6) { + Text("Profile") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Picker("Profile", selection: $assigneeMappingProfile) { + ForEach(linearProfiles, id: \.id) { profile in + Text(displayNameForRemoteProfileId(profile.id)) + .tag(profile.id) + } + } + .pickerStyle(.menu) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Team scope") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + TextField("Optional team id", text: $assigneeMappingTeamId) + .textFieldStyle(.roundedBorder) + } + + Spacer() + + Button { + Task { await refreshLinearAssigneeMappings() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + .disabled(isLoadingAssigneeMappings || isSavingAssigneeMapping) + + if isLoadingAssigneeMappings || isSavingAssigneeMapping { + ProgressView() + .controlSize(.small) + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Saved mappings") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + StatusPill( + text: "\(assigneeMappings.count) mapping\(assigneeMappings.count == 1 ? "" : "s")", + tone: assigneeMappings.isEmpty ? .neutral : .good + ) + } + + if assigneeMappings.isEmpty { + Text("No explicit mappings for this profile/team scope yet.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(assigneeMappings) { mapping in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(mapping.localAssignee) + .font(.mono(.subheadline, weight: .semibold)) + if let teamId = mapping.teamId, !teamId.isEmpty { + StatusPill(text: "Team \(teamId)", tone: .neutral) + } else { + StatusPill(text: "All teams", tone: .neutral) + } + Spacer() + Button { + populateAssigneeMappingDraft(from: mapping) + } label: { + Label("Use", systemImage: "arrow.down.left.circle") + } + .adaptiveToolbarButton() + Button(role: .destructive) { + Task { await removeLinearAssigneeMapping(mapping) } + } label: { + Label("Remove", systemImage: "trash") + } + .adaptiveToolbarButton() + .disabled(isSavingAssigneeMapping) + } + Text(mappingDestinationLabel(mapping)) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + } + } + } + + Divider() + .opacity(0.2) + + VStack(alignment: .leading, spacing: 10) { + Text("Edit mapping") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + + TextField("Local assignee (required)", text: $assigneeMappingLocalAssignee) + .textFieldStyle(.roundedBorder) + TextField("Linear user id", text: $assigneeMappingLinearUserId) + .textFieldStyle(.roundedBorder) + TextField("Linear user name", text: $assigneeMappingLinearUserName) + .textFieldStyle(.roundedBorder) + TextField("Linear user email", text: $assigneeMappingLinearUserEmail) + .textFieldStyle(.roundedBorder) + + Text("Provide at least one Linear user field. Email is usually the safest stable fallback when user ids are not obvious.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + + HStack(spacing: 8) { + Button { + Task { await saveLinearAssigneeMapping() } + } label: { + Label("Save mapping", systemImage: "tray.and.arrow.down") + } + .adaptiveToolbarButtonProminent() + .disabled(!canSaveAssigneeMapping || isSavingAssigneeMapping) + + Button("Clear form") { + clearAssigneeMappingDraft() + } + .adaptiveToolbarButton() + .disabled(isSavingAssigneeMapping) + } + } + } + } + } + } + if !message.isEmpty { InlineCallout( tone: .neutral, @@ -2974,6 +3141,9 @@ private struct LinearExtensionSettingsView: View { .onChange(of: model.lastUpdated) { _, _ in Task { await loadConfigFromDisk() } } + .onChange(of: assigneeMappingProfile) { _, _ in + Task { await refreshLinearAssigneeMappings() } + } .onDisappear { cancelLinearAuthFlow(userInitiated: false) } @@ -3092,6 +3262,8 @@ private struct LinearExtensionSettingsView: View { defaultProfile = diagnostics.defaultProfile } await refreshLinearProfileStatuses() + refreshSelectedAssigneeMappingProfile() + await refreshLinearAssigneeMappings() lastDiagnosticsRefreshAt = Date() } @@ -3109,6 +3281,142 @@ private struct LinearExtensionSettingsView: View { profileStatusById = statuses } + private var normalizedAssigneeMappingProfile: String { + assigneeMappingProfile.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var normalizedAssigneeMappingTeamId: String? { + let trimmed = assigneeMappingTeamId.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private var canSaveAssigneeMapping: Bool { + guard !normalizedAssigneeMappingProfile.isEmpty else { + return false + } + let localAssignee = assigneeMappingLocalAssignee.trimmingCharacters(in: .whitespacesAndNewlines) + guard !localAssignee.isEmpty else { + return false + } + let linearUserId = assigneeMappingLinearUserId.trimmingCharacters(in: .whitespacesAndNewlines) + let linearUserName = assigneeMappingLinearUserName.trimmingCharacters(in: .whitespacesAndNewlines) + let linearUserEmail = assigneeMappingLinearUserEmail.trimmingCharacters(in: .whitespacesAndNewlines) + return !linearUserId.isEmpty || !linearUserName.isEmpty || !linearUserEmail.isEmpty + } + + private func refreshSelectedAssigneeMappingProfile() { + let current = normalizedAssigneeMappingProfile + if !current.isEmpty, linearProfiles.contains(where: { $0.id == current }) { + return + } + let fallback = resolvedDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) + if !fallback.isEmpty, linearProfiles.contains(where: { $0.id == fallback }) { + assigneeMappingProfile = fallback + return + } + assigneeMappingProfile = linearProfiles.first?.id ?? "" + } + + private func refreshLinearAssigneeMappings() async { + refreshSelectedAssigneeMappingProfile() + let profileId = normalizedAssigneeMappingProfile + guard !profileId.isEmpty else { + assigneeMappings = [] + return + } + isLoadingAssigneeMappings = true + defer { isLoadingAssigneeMappings = false } + let response = await model.listLinearAssigneeMappings( + profileId: profileId, + teamId: normalizedAssigneeMappingTeamId + ) + if let response { + assigneeMappings = response.mappings.sorted { + $0.localAssignee.localizedCaseInsensitiveCompare($1.localAssignee) == .orderedAscending + } + } else { + assigneeMappings = [] + } + } + + private func clearAssigneeMappingDraft() { + assigneeMappingLocalAssignee = "" + assigneeMappingLinearUserId = "" + assigneeMappingLinearUserName = "" + assigneeMappingLinearUserEmail = "" + } + + private func populateAssigneeMappingDraft(from mapping: LinearAssigneeMapping) { + assigneeMappingProfile = mapping.profileId + assigneeMappingTeamId = mapping.teamId ?? "" + assigneeMappingLocalAssignee = mapping.localAssignee + assigneeMappingLinearUserId = mapping.linearUserId ?? "" + assigneeMappingLinearUserName = mapping.linearUserName ?? "" + assigneeMappingLinearUserEmail = mapping.linearUserEmail ?? "" + } + + private func mappingDestinationLabel(_ mapping: LinearAssigneeMapping) -> String { + if let email = mapping.linearUserEmail, !email.isEmpty { + return "\(mapping.linearUserName ?? mapping.linearUserId ?? "Linear user") • \(email)" + } + if let name = mapping.linearUserName, !name.isEmpty { + return "\(name) • \(mapping.linearUserId ?? "no id")" + } + if let userId = mapping.linearUserId, !userId.isEmpty { + return userId + } + return "Missing Linear user details" + } + + private func saveLinearAssigneeMapping() async { + guard canSaveAssigneeMapping else { + message = "Fill in a local assignee and at least one Linear user field." + return + } + isSavingAssigneeMapping = true + defer { isSavingAssigneeMapping = false } + + let response = await model.setLinearAssigneeMapping( + profileId: normalizedAssigneeMappingProfile, + teamId: normalizedAssigneeMappingTeamId, + localAssignee: assigneeMappingLocalAssignee.trimmingCharacters(in: .whitespacesAndNewlines), + linearUserId: assigneeMappingLinearUserId.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + linearUserName: assigneeMappingLinearUserName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + linearUserEmail: assigneeMappingLinearUserEmail.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ) + guard let response else { + message = model.errorMessage ?? "Failed to save Linear assignee mapping." + return + } + message = response.replacedExisting + ? "Updated the assignee mapping for \(response.mapping.localAssignee)." + : "Saved a new assignee mapping for \(response.mapping.localAssignee)." + await refreshLinearAssigneeMappings() + clearAssigneeMappingDraft() + } + + private func removeLinearAssigneeMapping(_ mapping: LinearAssigneeMapping) async { + isSavingAssigneeMapping = true + defer { isSavingAssigneeMapping = false } + + let response = await model.removeLinearAssigneeMapping( + profileId: mapping.profileId, + teamId: mapping.teamId, + localAssignee: mapping.localAssignee + ) + guard let response, response.removed else { + message = model.errorMessage ?? "Failed to remove Linear assignee mapping." + return + } + if assigneeMappingLocalAssignee.trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare(mapping.localAssignee) == .orderedSame + { + clearAssigneeMappingDraft() + } + message = "Removed the assignee mapping for \(mapping.localAssignee)." + await refreshLinearAssigneeMappings() + } + private func applyLinearEnabledToggle(_ newValue: Bool) async { isSavingConfig = true defer { isSavingConfig = false } @@ -3401,6 +3709,12 @@ private struct LinearExtensionSettingsView: View { } } +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} + private struct GitHubExtensionSettingsView: View { @Environment(DashboardModel.self) private var model @State private var isLoadingConfig = false diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index f6c608b4b..c828c92fc 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -1,9 +1,11 @@ +import AppKit import MarkdownUI import SwiftUI import HackDesktopModels struct TicketsView: View { + @AppStorage("hackDesktop.linearReviewNotes.v1") private var reviewNotesStorage = "{}" @Environment(DashboardModel.self) private var model @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL @@ -34,6 +36,11 @@ struct TicketsView: View { @State private var pendingBulkSyncAction: TicketBulkSyncAction? = nil @State private var activeSyncAction: TicketSyncAction? = nil @State private var resolvingConflictIds: Set = [] + @State private var postingCommentTicketIds: Set = [] + @State private var ticketDetailCache: [String: TicketDetailResponse] = [:] + @State private var reviewNotesByTicketKey: [String: [TicketLocalReviewNote]] = [:] + @State private var reviewComposerDrafts: [String: String] = [:] + @State private var isReviewNotesExpanded = true @FocusState private var ticketsListFocused: Bool var body: some View { @@ -52,6 +59,7 @@ struct TicketsView: View { .padding(.bottom, 24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { + loadPersistedReviewNotes() await refreshLinearRouting() loadCachedTickets() await refreshTickets() @@ -69,6 +77,21 @@ struct TicketsView: View { .onChange(of: selectedOriginFilter) { _, _ in updateSelectionAfterRefresh() } + .onChange(of: reviewNotesStorage) { _, _ in + loadPersistedReviewNotes() + } + .onReceive( + NotificationCenter.default.publisher(for: .hackTicketReviewQueueRequested) + ) { notification in + guard + let userInfo = notification.userInfo, + let requestedProjectId = userInfo[TicketReviewQueueRequest.projectIdKey] as? String, + requestedProjectId == project.id + else { + return + } + activateReviewQueue() + } .sheet(isPresented: $showCreateSheet) { TicketCreateSheet( projectName: project.name, @@ -122,6 +145,18 @@ struct TicketsView: View { ) } .buttonStyle(.plain) + if reviewQueueCount > 0 { + Button { + activateReviewQueue() + } label: { + ticketHeaderBadge( + title: reviewQueueLabel, + systemImage: "bubble.left.and.exclamationmark.bubble.right", + tone: selectedFilter == .reviewQueue ? .orange : .secondary + ) + } + .buttonStyle(.plain) + } Spacer(minLength: 12) if let activeSyncAction { HStack(spacing: 8) { @@ -215,6 +250,42 @@ struct TicketsView: View { return tickets.first(where: { $0.ticketId == selectedTicketId }) } + private var reviewQueueEntries: [TicketReviewQueueEntry] { + tickets.compactMap { ticket in + TicketReviewQueueEntry( + ticket: ticket, + detail: ticketDetailCache[ticket.ticketId], + localNoteCount: reviewNotes(for: ticket).count + ) + } + .sorted { lhs, rhs in + if lhs.openConflictCount != rhs.openConflictCount { + return lhs.openConflictCount > rhs.openConflictCount + } + return lhs.updatedAt > rhs.updatedAt + } + } + + private var filteredReviewQueueEntries: [TicketReviewQueueEntry] { + let visibleTicketIds = Set(filteredTickets.map(\.ticketId)) + return reviewQueueEntries.filter { visibleTicketIds.contains($0.ticketId) } + } + + private var reviewQueueCount: Int { + reviewQueueEntries.count + } + + private var reviewQueueLabel: String { + reviewQueueCount == 1 ? "1 review" : "\(reviewQueueCount) reviews" + } + + private var nextReviewQueueEntry: TicketReviewQueueEntry? { + let remaining = filteredReviewQueueEntries.filter { + $0.ticketId != selectedTicketId + } + return remaining.first + } + private var linearRouteStatusLabel: String { if linearRouteStatus?.tokenResolved != true { return "Linear not connected" @@ -398,9 +469,9 @@ struct TicketsView: View { selectedFilter = filter } label: { if selectedFilter == filter { - Label("\(filter.label) (\(filter.count(in: tickets)))", systemImage: "checkmark") + Label("\(filter.label) (\(ticketCount(for: filter)))", systemImage: "checkmark") } else { - Text("\(filter.label) (\(filter.count(in: tickets)))") + Text("\(filter.label) (\(ticketCount(for: filter)))") } } } @@ -410,7 +481,7 @@ struct TicketsView: View { .font(.system(size: 13, weight: .semibold)) Text(selectedFilter.label) .font(.system(size: 13, weight: .semibold)) - Text("\(selectedFilter.count(in: tickets))") + Text("\(ticketCount(for: selectedFilter))") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) } @@ -504,23 +575,18 @@ struct TicketsView: View { private var ticketsListCard: some View { ticketPanel(contentPadding: 0) { VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 8) { - Text("\(filteredTickets.count) issue\(filteredTickets.count == 1 ? "" : "s")") - .font(.system(size: 14, weight: .semibold)) - Spacer() - if !filteredTickets.isEmpty { - Text("Use ↑ ↓ to navigate") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } + if selectedFilter == .reviewQueue { + reviewQueueListHeader + } else { + standardTicketsListHeader } - .padding(.horizontal, 14) - .padding(.vertical, 12) Divider() .opacity(0.22) ScrollView { if isLoading && !hasLoadedOnce { skeletonList + } else if selectedFilter == .reviewQueue && filteredReviewQueueEntries.isEmpty { + reviewQueueEmptyView } else if filteredTickets.isEmpty { emptyTicketsView } else if selectedFilter == .all { @@ -529,6 +595,12 @@ struct TicketsView: View { ticketSection(status: status) } } + } else if selectedFilter == .reviewQueue { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredReviewQueueEntries) { entry in + reviewQueueRow(entry) + } + } } else { LazyVStack(alignment: .leading, spacing: 0) { ForEach(filteredTickets) { ticket in @@ -545,6 +617,70 @@ struct TicketsView: View { .focusEffectDisabled() } + private var standardTicketsListHeader: some View { + HStack(spacing: 8) { + Text("\(filteredTickets.count) issue\(filteredTickets.count == 1 ? "" : "s")") + .font(.system(size: 14, weight: .semibold)) + Spacer() + if !filteredTickets.isEmpty { + Text("Use ↑ ↓ to navigate") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + } + + private var reviewQueueListHeader: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text( + "\(filteredReviewQueueEntries.count) review item\(filteredReviewQueueEntries.count == 1 ? "" : "s")" + ) + .font(.system(size: 14, weight: .semibold)) + Spacer() + Text("Use ↑ ↓ to triage") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ticketMetaPill( + "\(filteredReviewQueueEntries.reduce(0) { $0 + $1.openConflictCount }) open conflict\(filteredReviewQueueEntries.reduce(0) { $0 + $1.openConflictCount } == 1 ? "" : "s")", + tone: filteredReviewQueueEntries.reduce(0) { $0 + $1.openConflictCount } > 0 ? .orange : .secondary + ) + ticketMetaPill( + "\(filteredReviewQueueEntries.reduce(0) { $0 + $1.commentCount }) comment\(filteredReviewQueueEntries.reduce(0) { $0 + $1.commentCount } == 1 ? "" : "s")", + tone: .secondary + ) + ticketMetaPill( + "\(filteredReviewQueueEntries.reduce(0) { $0 + $1.localNoteCount }) local note\(filteredReviewQueueEntries.reduce(0) { $0 + $1.localNoteCount } == 1 ? "" : "s")", + tone: .secondary + ) + } + } + HStack(spacing: 8) { + if let nextReviewQueueEntry { + Button { + selectedTicketId = nextReviewQueueEntry.ticketId + ticketsListFocused = true + } label: { + Label("Next item", systemImage: "arrow.right.circle") + } + .adaptiveToolbarButton() + } + Button("All tickets") { + selectedFilter = .all + ticketsListFocused = true + } + .adaptiveToolbarButton() + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + } + private var ticketDetailCard: some View { ticketDetailPanel { if let detailErrorMessage { @@ -621,6 +757,32 @@ struct TicketsView: View { .padding(.top, 8) } + private var reviewQueueEmptyView: some View { + VStack(alignment: .leading, spacing: 8) { + Text(reviewQueueCount == 0 ? "No tickets need sync review." : "No review items match the current filters.") + .font(.system(size: 15, weight: .semibold)) + Text( + reviewQueueCount == 0 + ? "When conflicts, mergeable-field ambiguity, or follow-up notes land, they will appear here for triage." + : "Clear the search text, widen the origin filter, or switch back to the full ticket board." + ) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + Button("All tickets") { + selectedFilter = .all + } + .adaptiveToolbarButton() + Button("Refresh") { + Task { await refreshTickets() } + } + .adaptiveToolbarButton() + } + } + .padding(.horizontal, 14) + .padding(.top, 8) + } + private func ticketRow(_ ticket: TicketSummary) -> some View { Button { if selectedTicketId == ticket.ticketId { @@ -674,6 +836,99 @@ struct TicketsView: View { .animation(.easeInOut(duration: 0.12), value: hoveredTicketId) } + private func reviewQueueRow(_ entry: TicketReviewQueueEntry) -> some View { + let ticket = tickets.first(where: { $0.ticketId == entry.ticketId }) + return Button { + if selectedTicketId == entry.ticketId { + selectedTicketId = nil + ticketDetail = nil + detailErrorMessage = nil + } else { + selectedTicketId = entry.ticketId + } + ticketsListFocused = true + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: entry.openConflictCount > 0 ? "exclamationmark.triangle.fill" : "arrow.triangle.branch") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(entry.openConflictCount > 0 ? .orange : .secondary) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(entry.ticketId) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + ticketMetaPill( + entry.badgeLabel, + tone: entry.openConflictCount > 0 ? .orange : .secondary + ) + if let ticket { + ticketMetaPill( + ticketAuthorityBadgeLabel(for: ticket), + tone: ticketAuthorityBadgeColor(for: ticket) + ) + ticketMetaPill( + ticket.owner, + tone: ticket.owner == "linear" ? .orange : .secondary + ) + } + Spacer(minLength: 8) + Text(humanReadableListDate(entry.updatedAt)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + Text(entry.title) + .font(.system(size: 15, weight: .semibold)) + .multilineTextAlignment(.leading) + .lineLimit(2) + Text(entry.summary) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(3) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ticketMetaPill( + "\(entry.openConflictCount) open", + tone: entry.openConflictCount > 0 ? .orange : .secondary + ) + ticketMetaPill( + "\(entry.commentCount) comment\(entry.commentCount == 1 ? "" : "s")", + tone: .secondary + ) + if entry.localNoteCount > 0 { + ticketMetaPill( + "\(entry.localNoteCount) local note\(entry.localNoteCount == 1 ? "" : "s")", + tone: .secondary + ) + } + if let checkpointSummary = ticketDetailCache[entry.ticketId]?.linearSyncReviewState.checkpointSummary { + ticketMetaPill(checkpointSummary, tone: .secondary) + } + } + } + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(selectionBackground(for: entry.ticketId)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(selectionBorder(for: entry.ticketId)) + .frame(height: 1) + } + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .onHover { hovering in + hoveredTicketId = hovering ? entry.ticketId : nil + } + .animation(.easeInOut(duration: 0.12), value: hoveredTicketId) + } + private func ticketRowMetadata(_ ticket: TicketSummary) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { @@ -726,6 +981,7 @@ struct TicketsView: View { ticketSyncGuidanceCallout(detail) ticketSyncSnapshotStrip(detail) ticketCommentsSection(detail) + ticketReviewNotesSection(detail) ticketSyncCheckpointSection(detail) ticketConflictSection(detail) } @@ -787,6 +1043,10 @@ struct TicketsView: View { ticketMetaPill("\(review.commentCount) comment\(review.commentCount == 1 ? "" : "s")", tone: .secondary) ticketMetaPill("\(review.openConflictCount) open", tone: review.openConflictCount > 0 ? .orange : .secondary) ticketMetaPill("\(review.resolvedConflictCount) resolved", tone: review.resolvedConflictCount > 0 ? .green : .secondary) + if reviewNotes(for: detail.ticket).count > 0 { + let noteCount = reviewNotes(for: detail.ticket).count + ticketMetaPill("\(noteCount) local note\(noteCount == 1 ? "" : "s")", tone: .secondary) + } if let checkpointSummary = review.checkpointSummary { ticketMetaPill(checkpointSummary, tone: .secondary) } @@ -825,6 +1085,12 @@ struct TicketsView: View { } .adaptiveToolbarButton() } + Button { + appendQuotedCommentToReviewDraft(comment, ticket: detail.ticket) + } label: { + Label("Quote", systemImage: "text.quote") + } + .adaptiveToolbarButton() } markdownBody(comment.body) } @@ -844,6 +1110,149 @@ struct TicketsView: View { } } + @ViewBuilder + private func ticketReviewNotesSection(_ detail: TicketDetailResponse) -> some View { + let savedNotes = reviewNotes(for: detail.ticket) + let isRelevant = detail.ticket.linearSyncUXState.isLinkedToLinear || detail.linearSyncReviewState.needsReview || !savedNotes.isEmpty + if isRelevant { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Review notes") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + ticketMetaPill("Local to Hack Desktop", tone: .secondary) + if !savedNotes.isEmpty { + ticketMetaPill("\(savedNotes.count) saved", tone: .secondary) + } + } + + InlineCallout( + tone: detail.linearSyncReviewState.needsReview ? .warn : .neutral, + title: "Compose a review comment", + message: "Draft follow-up from conflicts and comment history here. You can keep it local as a review note or append it to the ticket as an immutable Hack comment that sync can push to Linear later.", + actions: [ + InlineCalloutAction(label: "Use review summary", systemImage: "sparkles") { + applyReviewDraftTemplate(for: detail) + }, + InlineCalloutAction(label: "Copy draft", systemImage: "doc.on.doc") { + copyReviewDraft(for: detail.ticket) + }, + ] + ) + + if !savedNotes.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(savedNotes.sorted(by: { $0.createdAt > $1.createdAt })) { note in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + ticketMetaPill("Saved note", tone: .secondary) + Text(humanReadableDetailDate(note.createdAt)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Button { + copyToPasteboard(note.markdown) + loadNotice = "Copied saved review note." + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .adaptiveToolbarButton() + } + markdownBody(note.markdown) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Draft") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) + TextEditor(text: reviewDraftBinding(for: detail.ticket)) + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(minHeight: 128) + if reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Draft a local review comment, quote an imported comment, or stage a conflict summary.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 14) + .allowsHitTesting(false) + } + } + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + + HStack(spacing: 8) { + Button { + applyReviewDraftTemplate(for: detail) + } label: { + Label("Insert summary", systemImage: "text.append") + } + .adaptiveToolbarButton() + + if let latestComment = detail.comments.last { + Button { + appendQuotedCommentToReviewDraft(latestComment, ticket: detail.ticket) + } label: { + Label("Quote latest", systemImage: "text.quote") + } + .adaptiveToolbarButton() + } + + Button { + saveReviewDraft(for: detail.ticket) + } label: { + Label("Save local note", systemImage: "tray.and.arrow.down") + } + .adaptiveToolbarButtonProminent() + .disabled(reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button { + Task { await postReviewDraftAsTicketComment(for: detail.ticket) } + } label: { + Label("Post ticket comment", systemImage: "plus.bubble") + } + .adaptiveToolbarButton() + .disabled( + reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + postingCommentTicketIds.contains(detail.ticket.ticketId) + ) + + Button("Clear") { + clearReviewDraft(for: detail.ticket) + } + .adaptiveToolbarButton() + .disabled(reviewDraft(for: detail.ticket).isEmpty) + + if postingCommentTicketIds.contains(detail.ticket.ticketId) { + ProgressView() + .controlSize(.small) + } + } + } + } + } + } + @ViewBuilder private func ticketSyncCheckpointSection(_ detail: TicketDetailResponse) -> some View { if !detail.syncCheckpoints.isEmpty { @@ -961,6 +1370,14 @@ struct TicketsView: View { private func ticketConflictActions(_ conflict: TicketSyncConflict) -> some View { let isResolving = resolvingConflictIds.contains(conflict.conflictId) HStack(spacing: 8) { + Button { + stageConflictReviewDraft(conflict) + } label: { + Label("Draft note", systemImage: "square.and.pencil") + } + .adaptiveToolbarButton() + .disabled(isResolving) + Button { Task { await resolveTicketConflict( @@ -1163,6 +1580,16 @@ struct TicketsView: View { private func ticketDetailFooter(_ detail: TicketDetailResponse) -> some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { + if let nextReviewQueueEntry { + Button { + selectedFilter = .reviewQueue + selectedTicketId = nextReviewQueueEntry.ticketId + } label: { + Label("Next review", systemImage: "arrow.right.circle") + } + .adaptiveToolbarButton() + } + Button { Task { await syncSelectedTicketToLinear(detail.ticket) } } label: { @@ -1429,7 +1856,9 @@ struct TicketsView: View { isHistoryExpanded = false isPropertiesExpanded = false do { - ticketDetail = try await model.showTicket(for: project, ticketId: selectedTicketId) + let detail = try await model.showTicket(for: project, ticketId: selectedTicketId) + ticketDetail = detail + ticketDetailCache[selectedTicketId] = detail } catch { detailErrorMessage = error.localizedDescription } @@ -1660,6 +2089,161 @@ struct TicketsView: View { } } + private func activateReviewQueue() { + selectedFilter = .reviewQueue + if selectedTicketId == nil || !filteredTickets.contains(where: { $0.ticketId == selectedTicketId }) { + selectedTicketId = reviewQueueEntries.first?.ticketId + } + ticketsListFocused = true + } + + private func ticketReviewStorageKey(for ticket: TicketSummary) -> String { + "\(project.id):\(ticket.ticketId)" + } + + private func reviewNotes(for ticket: TicketSummary) -> [TicketLocalReviewNote] { + reviewNotesByTicketKey[ticketReviewStorageKey(for: ticket)] ?? [] + } + + private func reviewDraft(for ticket: TicketSummary) -> String { + reviewComposerDrafts[ticketReviewStorageKey(for: ticket)] ?? "" + } + + private func reviewDraftBinding(for ticket: TicketSummary) -> Binding { + let key = ticketReviewStorageKey(for: ticket) + return Binding( + get: { reviewComposerDrafts[key] ?? "" }, + set: { newValue in + reviewComposerDrafts[key] = newValue + } + ) + } + + private func loadPersistedReviewNotes() { + guard let data = reviewNotesStorage.data(using: .utf8) else { + reviewNotesByTicketKey = [:] + return + } + do { + reviewNotesByTicketKey = try JSONDecoder().decode([String: [TicketLocalReviewNote]].self, from: data) + } catch { + reviewNotesByTicketKey = [:] + } + } + + private func persistReviewNotes() { + do { + let data = try JSONEncoder().encode(reviewNotesByTicketKey) + reviewNotesStorage = String(decoding: data, as: UTF8.self) + } catch { + loadNotice = "Failed to save local review notes." + } + } + + private func applyReviewDraftTemplate(for detail: TicketDetailResponse) { + reviewComposerDrafts[ticketReviewStorageKey(for: detail.ticket)] = TicketReviewComposer.draft(for: detail) + isReviewNotesExpanded = true + } + + private func appendQuotedCommentToReviewDraft(_ comment: TicketComment, ticket: TicketSummary) { + let quote = TicketReviewComposer.quote(comment: comment) + appendToReviewDraft(quote, ticket: ticket) + } + + private func stageConflictReviewDraft(_ conflict: TicketSyncConflict) { + guard let detail = ticketDetail, detail.ticket.ticketId == conflict.ticketId else { + return + } + let draft = TicketReviewComposer.draft(for: detail, highlightedConflict: conflict) + reviewComposerDrafts[ticketReviewStorageKey(for: detail.ticket)] = draft + isReviewNotesExpanded = true + } + + private func appendToReviewDraft(_ text: String, ticket: TicketSummary) { + let key = ticketReviewStorageKey(for: ticket) + let existing = reviewComposerDrafts[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + if existing.isEmpty { + reviewComposerDrafts[key] = trimmed + } else { + reviewComposerDrafts[key] = "\(existing)\n\n\(trimmed)" + } + isReviewNotesExpanded = true + } + + private func saveReviewDraft(for ticket: TicketSummary) { + let key = ticketReviewStorageKey(for: ticket) + let trimmed = (reviewComposerDrafts[key] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + let note = TicketLocalReviewNote( + noteId: UUID().uuidString, + createdAt: TicketDateFormatter.isoBasic.string(from: Date()), + markdown: trimmed + ) + var existing = reviewNotesByTicketKey[key] ?? [] + existing.append(note) + reviewNotesByTicketKey[key] = existing + reviewComposerDrafts[key] = "" + persistReviewNotes() + loadNotice = "Saved a local review note." + } + + private func postReviewDraftAsTicketComment(for ticket: TicketSummary) async { + let key = ticketReviewStorageKey(for: ticket) + let trimmed = (reviewComposerDrafts[key] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + loadNotice = "Draft a review comment before posting it." + return + } + guard !postingCommentTicketIds.contains(ticket.ticketId) else { + return + } + + postingCommentTicketIds.insert(ticket.ticketId) + defer { postingCommentTicketIds.remove(ticket.ticketId) } + + let response = await model.appendTicketComment( + for: project, + ticketId: ticket.ticketId, + body: trimmed, + source: "hack" + ) + guard response != nil else { + loadNotice = model.errorMessage ?? "Failed to append ticket comment." + return + } + + reviewComposerDrafts[key] = "" + loadNotice = "Posted an immutable Hack comment to \(ticket.ticketId)." + await refreshTickets() + await loadTicketDetail() + } + + private func clearReviewDraft(for ticket: TicketSummary) { + reviewComposerDrafts[ticketReviewStorageKey(for: ticket)] = "" + } + + private func copyReviewDraft(for ticket: TicketSummary) { + let trimmed = reviewDraft(for: ticket).trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + loadNotice = "Draft a review comment before copying it." + return + } + copyToPasteboard(trimmed) + loadNotice = "Copied review draft." + } + + private func copyToPasteboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + private func parseTicketRefs(_ text: String) -> [String] { let parts = text .split { $0 == "," || $0 == " " || $0 == "\n" || $0 == "\t" } @@ -1668,6 +2252,15 @@ struct TicketsView: View { return Array(Set(parts)).sorted() } + private func ticketCount(for filter: TicketFilter) -> Int { + switch filter { + case .reviewQueue: + return reviewQueueCount + default: + return filter.count(in: tickets) + } + } + private var filteredTickets: [TicketSummary] { let originFiltered = tickets.filter { selectedOriginFilter.matches(ticket: $0) } let base: [TicketSummary] @@ -1682,13 +2275,17 @@ struct TicketsView: View { base = originFiltered.filter { $0.status == .blocked } case .done: base = originFiltered.filter { $0.status == .done } + case .reviewQueue: + let visibleTicketsById = Dictionary( + uniqueKeysWithValues: originFiltered.map { ($0.ticketId, $0) } + ) + base = reviewQueueEntries.compactMap { visibleTicketsById[$0.ticketId] } } let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return base } - let lower = trimmed.lowercased() return base.filter { - $0.title.lowercased().contains(lower) || $0.ticketId.lowercased().contains(lower) + $0.title.localizedStandardContains(trimmed) || $0.ticketId.localizedStandardContains(trimmed) } } @@ -2145,6 +2742,7 @@ private enum TicketFilter: String, CaseIterable { case inProgress = "in_progress" case blocked case done + case reviewQueue = "review_queue" var label: String { switch self { @@ -2158,6 +2756,8 @@ private enum TicketFilter: String, CaseIterable { return "Blocked" case .done: return "Done" + case .reviewQueue: + return "Review queue" } } @@ -2173,6 +2773,8 @@ private enum TicketFilter: String, CaseIterable { return tickets.filter { $0.status == .blocked }.count case .done: return tickets.filter { $0.status == .done }.count + case .reviewQueue: + return tickets.filter { $0.linearSyncUXState.reviewHint != nil }.count } } } diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 9c865bd6b..0506ce7f3 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -477,6 +477,79 @@ public actor HackCLIClient { return try decodeJsonOrThrow(LinearProjectsResponse.self, result: result) } + public func listLinearAssigneeMappings( + profileId: String? = nil, + teamId: String? = nil + ) async throws -> LinearAssigneeMappingsResponse { + var args = ["x", "linear", "assignee-mappings", "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearAssigneeMappingsResponse.self, result: result) + } + + public func setLinearAssigneeMapping( + profileId: String? = nil, + teamId: String? = nil, + localAssignee: String, + linearUserId: String? = nil, + linearUserName: String? = nil, + linearUserEmail: String? = nil + ) async throws -> LinearAssigneeMappingMutationResponse { + var args = [ + "x", + "linear", + "set-assignee-mapping", + "--json", + "--local-assignee", + localAssignee, + ] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + if let linearUserId, !linearUserId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--linear-user-id", linearUserId]) + } + if let linearUserName, !linearUserName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--linear-user-name", linearUserName]) + } + if let linearUserEmail, !linearUserEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--linear-user-email", linearUserEmail]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearAssigneeMappingMutationResponse.self, result: result) + } + + public func removeLinearAssigneeMapping( + profileId: String? = nil, + teamId: String? = nil, + localAssignee: String + ) async throws -> LinearAssigneeMappingRemovalResponse { + var args = [ + "x", + "linear", + "remove-assignee-mapping", + "--json", + "--local-assignee", + localAssignee, + ] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearAssigneeMappingRemovalResponse.self, result: result) + } + public func bindLinearProject( path: String, profileId: String?, @@ -1013,6 +1086,32 @@ public actor HackCLIClient { return try decodeLenient(TicketStatusResponse.self, from: result.stdout) } + public func appendTicketComment( + path: String, + ticketId: String, + body: String, + source: String? = nil, + actor: String? = nil + ) async throws -> TicketCommentAppendResponse { + var args = [ + "x", + "tickets", + "comment", + ticketId, + "--body", + body, + "--json", + ] + if let source, !source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--source", source]) + } + if let actor, !actor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--actor", actor]) + } + let result = try await run(args, cwd: path) + return try decodeLenient(TicketCommentAppendResponse.self, from: result.stdout) + } + public func resolveTicketConflict( path: String, ticketId: String, diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 158d67bf0..14381cd08 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -1402,6 +1402,86 @@ public struct LinearProjectsResponse: Decodable, Hashable { } } +public struct LinearAssigneeMapping: Decodable, Hashable, Identifiable { + public let profileId: String + public let teamId: String? + public let localAssignee: String + public let linearUserId: String? + public let linearUserName: String? + public let linearUserEmail: String? + + public var id: String { + [profileId, teamId ?? "*", localAssignee].joined(separator: "::") + } + + public init( + profileId: String, + teamId: String?, + localAssignee: String, + linearUserId: String?, + linearUserName: String?, + linearUserEmail: String? + ) { + self.profileId = profileId + self.teamId = teamId + self.localAssignee = localAssignee + self.linearUserId = linearUserId + self.linearUserName = linearUserName + self.linearUserEmail = linearUserEmail + } +} + +public struct LinearAssigneeMappingsResponse: Decodable, Hashable { + public let profileId: String + public let teamId: String? + public let mappings: [LinearAssigneeMapping] + + public init( + profileId: String, + teamId: String?, + mappings: [LinearAssigneeMapping] + ) { + self.profileId = profileId + self.teamId = teamId + self.mappings = mappings + } +} + +public struct LinearAssigneeMappingMutationResponse: Decodable, Hashable { + public let upserted: Bool + public let replacedExisting: Bool + public let mapping: LinearAssigneeMapping + + public init( + upserted: Bool, + replacedExisting: Bool, + mapping: LinearAssigneeMapping + ) { + self.upserted = upserted + self.replacedExisting = replacedExisting + self.mapping = mapping + } +} + +public struct LinearAssigneeMappingRemovalResponse: Decodable, Hashable { + public let removed: Bool + public let profileId: String + public let teamId: String? + public let localAssignee: String + + public init( + removed: Bool, + profileId: String, + teamId: String?, + localAssignee: String + ) { + self.removed = removed + self.profileId = profileId + self.teamId = teamId + self.localAssignee = localAssignee + } +} + public struct LinearProjectBindingResponse: Decodable, Hashable { public let ok: Bool public let cleared: Bool? @@ -2419,6 +2499,119 @@ public struct TicketSyncReviewState: Hashable { } } +public struct TicketReviewQueueEntry: Hashable, Identifiable { + public let ticketId: String + public let title: String + public let badgeLabel: String + public let summary: String + public let commentCount: Int + public let openConflictCount: Int + public let localNoteCount: Int + public let updatedAt: String + + public var id: String { ticketId } + + public init?( + ticket: TicketSummary, + detail: TicketDetailResponse? = nil, + localNoteCount: Int = 0 + ) { + let normalizedLocalNoteCount = max(localNoteCount, 0) + let summary: String + let badgeLabel: String + let commentCount: Int + let openConflictCount: Int + + if let detail, detail.linearSyncReviewState.needsReview { + let review = detail.linearSyncReviewState + summary = review.message + badgeLabel = review.badgeLabel + commentCount = review.commentCount + openConflictCount = review.openConflictCount + } else if let reviewHint = ticket.linearSyncUXState.reviewHint { + summary = reviewHint + badgeLabel = "Needs review" + commentCount = detail?.comments.count ?? 0 + openConflictCount = detail?.openSyncConflicts.count ?? 0 + } else { + return nil + } + + ticketId = ticket.ticketId + title = ticket.title + self.badgeLabel = badgeLabel + self.summary = summary + self.commentCount = commentCount + self.openConflictCount = openConflictCount + self.localNoteCount = normalizedLocalNoteCount + updatedAt = detail?.ticket.updatedAt ?? ticket.updatedAt + } +} + +public struct TicketLocalReviewNote: Codable, Hashable, Identifiable { + public let noteId: String + public let createdAt: String + public let markdown: String + + public var id: String { noteId } + + public init(noteId: String, createdAt: String, markdown: String) { + self.noteId = noteId + self.createdAt = createdAt + self.markdown = markdown + } +} + +public enum TicketReviewComposer { + public static func draft( + for detail: TicketDetailResponse, + highlightedConflict: TicketSyncConflict? = nil + ) -> String { + var lines = ["Review follow-up", ""] + + if let highlightedConflict { + lines.append("Conflict to resolve:") + lines.append("- Field: \(highlightedConflict.field)") + if let summary = highlightedConflict.summary, !summary.isEmpty { + lines.append("- Summary: \(summary)") + } + if let localValue = highlightedConflict.localValue?.displayText, !localValue.isEmpty { + lines.append("- Hack: \(localValue)") + } + if let remoteValue = highlightedConflict.remoteValue?.displayText, !remoteValue.isEmpty { + lines.append("- Linear: \(remoteValue)") + } + } else if !detail.openSyncConflicts.isEmpty { + lines.append("Open conflicts:") + for conflict in detail.openSyncConflicts.prefix(3) { + let summary = conflict.summary?.trimmingCharacters(in: .whitespacesAndNewlines) + let summarySuffix = (summary?.isEmpty == false) ? ": \(summary!)" : "" + lines.append("- \(conflict.field)\(summarySuffix)") + } + } else { + lines.append(detail.linearSyncReviewState.message) + } + + if let latestComment = detail.comments.last { + lines.append("") + lines.append("Latest comment context:") + lines.append(quote(comment: latestComment)) + } + + return lines.joined(separator: "\n") + .replacingOccurrences(of: "\n\n\n", with: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + public static func quote(comment: TicketComment) -> String { + let header = "> \(comment.actor) • \(comment.source) • \(comment.createdAt)" + let bodyLines = comment.body + .split(separator: "\n", omittingEmptySubsequences: false) + .map { "> \($0)" } + return ([header] + bodyLines).joined(separator: "\n") + } +} + private enum LinearSyncActor: String { case hack case linear @@ -2588,6 +2781,14 @@ public struct TicketConflictResolutionResponse: Decodable { } } +public struct TicketCommentAppendResponse: Decodable { + public let comment: TicketComment + + public init(comment: TicketComment) { + self.comment = comment + } +} + public struct TicketsSyncResponse: Decodable { public let sync: TicketsSyncResult diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift index e72b5f0ff..3bcaa0058 100644 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift @@ -244,6 +244,102 @@ final class TicketDetailReviewModelTests: XCTestCase { XCTAssertEqual(review.checkpointSummary, "Last sync: push via Linear (profile default).") } + func testReviewQueueEntryUsesDetailedReviewStateWhenAvailable() { + let detail = makeDetail( + ticket: makeTicketSummary(owner: "linear", source: "hack", assignee: "alice@hack"), + comments: [ + TicketComment( + commentId: "c-1", + ticketId: "T-00042", + body: "Imported from Linear.", + source: "linear", + actor: "linear@app", + createdAt: "2026-03-05T16:10:00Z", + externalId: "comment-1", + externalUrl: nil + ) + ], + syncCheckpoints: [], + conflicts: [ + TicketSyncConflict( + conflictId: "conflict-1", + ticketId: "T-00042", + provider: "linear", + field: "assignee", + status: .open, + authority: "origin", + summary: "Assignee diverged during pull.", + localValue: .string("alice@hack"), + remoteValue: .string("bob@linear"), + createdAt: "2026-03-05T17:00:02Z", + updatedAt: "2026-03-05T17:00:02Z", + resolution: nil, + resolutionSummary: nil, + resolvedAt: nil, + resolvedBy: nil + ) + ] + ) + + let entry = TicketReviewQueueEntry(ticket: detail.ticket, detail: detail, localNoteCount: 2) + + XCTAssertEqual(entry?.ticketId, detail.ticket.ticketId) + XCTAssertEqual(entry?.badgeLabel, "1 open conflict") + XCTAssertEqual(entry?.commentCount, 1) + XCTAssertEqual(entry?.openConflictCount, 1) + XCTAssertEqual(entry?.localNoteCount, 2) + } + + func testReviewComposerDraftIncludesConflictAndLatestComment() { + let detail = makeDetail( + ticket: makeTicketSummary(owner: "linear", source: "hack", assignee: "alice@hack"), + comments: [ + TicketComment( + commentId: "c-1", + ticketId: "T-00042", + body: "Imported from Linear.", + source: "linear", + actor: "linear@app", + createdAt: "2026-03-05T16:10:00Z", + externalId: "comment-1", + externalUrl: nil + ) + ], + syncCheckpoints: [], + conflicts: [ + TicketSyncConflict( + conflictId: "conflict-1", + ticketId: "T-00042", + provider: "linear", + field: "assignee", + status: .open, + authority: "origin", + summary: "Assignee diverged during pull.", + localValue: .string("alice@hack"), + remoteValue: .string("bob@linear"), + createdAt: "2026-03-05T17:00:02Z", + updatedAt: "2026-03-05T17:00:02Z", + resolution: nil, + resolutionSummary: nil, + resolvedAt: nil, + resolvedBy: nil + ) + ] + ) + + let draft = TicketReviewComposer.draft( + for: detail, + highlightedConflict: detail.openSyncConflicts.first + ) + + XCTAssertTrue(draft.contains("Conflict to resolve:")) + XCTAssertTrue(draft.contains("Field: assignee")) + XCTAssertTrue(draft.contains("Hack: alice@hack")) + XCTAssertTrue(draft.contains("Linear: bob@linear")) + XCTAssertTrue(draft.contains("> linear@app")) + XCTAssertTrue(draft.contains("> Imported from Linear.")) + } + private func makeDetail( ticket: TicketSummary, comments: [TicketComment], diff --git a/services/auth-broker/src/app.ts b/services/auth-broker/src/app.ts index e2f542a05..acc6af843 100644 --- a/services/auth-broker/src/app.ts +++ b/services/auth-broker/src/app.ts @@ -104,11 +104,13 @@ export function createAuthBrokerApp({ .use( createLinearConnectionsPlugin({ connectionStore: linearConnectionStore, + betterAuthRuntime, }) ) .use( createLinearSyncStorePlugin({ syncStore: linearSyncStore, + betterAuthRuntime, }) ) .use( diff --git a/services/auth-broker/src/db.ts b/services/auth-broker/src/db.ts new file mode 100644 index 000000000..aaaff8a27 --- /dev/null +++ b/services/auth-broker/src/db.ts @@ -0,0 +1,8 @@ +import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-http"; + +import * as schema from "./db/schema.ts"; + +export function createDbClient(input: { readonly databaseUrl: string }) { + return drizzle(neon(input.databaseUrl), { schema }); +} diff --git a/services/auth-broker/src/db/schema.ts b/services/auth-broker/src/db/schema.ts new file mode 100644 index 000000000..38d1ec222 --- /dev/null +++ b/services/auth-broker/src/db/schema.ts @@ -0,0 +1,47 @@ +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const linearConnections = pgTable("linear_connections", { + id: uuid("id").defaultRandom().primaryKey(), + connectionKey: text("connection_key").notNull().unique(), + profileId: text("profile_id"), + accountId: text("account_id"), + accountName: text("account_name"), + accountEmail: text("account_email"), + authRef: text("auth_ref"), + betterAuthUserId: text("better_auth_user_id"), + organizationId: text("organization_id"), + teamId: text("team_id"), + metadataJson: text("metadata_json").notNull().default("{}"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const linearWebhookEvents = pgTable("linear_webhook_events", { + id: uuid("id").defaultRandom().primaryKey(), + deliveryKey: text("delivery_key").notNull().unique(), + profileId: text("profile_id"), + projectId: text("project_id"), + teamId: text("team_id"), + issueId: text("issue_id"), + issueIdentifier: text("issue_identifier"), + eventType: text("event_type"), + action: text("action"), + status: text("status").notNull().default("pending"), + payloadJson: text("payload_json").notNull().default("{}"), + applyError: text("apply_error"), + claimedBy: text("claimed_by"), + betterAuthUserId: text("better_auth_user_id"), + organizationId: text("organization_id"), + ownerTeamId: text("owner_team_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + appliedAt: timestamp("applied_at", { withTimezone: true }), +}); diff --git a/services/auth-broker/src/modules/better-auth/session.ts b/services/auth-broker/src/modules/better-auth/session.ts new file mode 100644 index 000000000..6e2c60b92 --- /dev/null +++ b/services/auth-broker/src/modules/better-auth/session.ts @@ -0,0 +1,91 @@ +import type { BetterAuthRuntime } from "../../better-auth.ts"; + +export type BrokerBetterAuthSession = { + readonly userId: string; + readonly email: string | null; + readonly name: string | null; + readonly organizationId: string | null; +}; + +export type BetterAuthSessionResolution = + | { + readonly enabled: false; + readonly accessControlMode: "manual_unenforced"; + readonly session: null; + } + | { + readonly enabled: true; + readonly accessControlMode: + | "better_auth_session_owned" + | "better_auth_organization_owned"; + readonly session: BrokerBetterAuthSession | null; + }; + +/** + * Resolve the current Better Auth session from an inbound broker request. + * + * When Better Auth is disabled we intentionally fall back to permissive local + * behavior so dev/test environments can still exercise the broker without + * having to provision auth state. + */ +export async function resolveBetterAuthSession(input: { + readonly runtime: BetterAuthRuntime; + readonly request: Request; +}): Promise { + if (!(input.runtime.enabled && input.runtime.auth)) { + return { + enabled: false, + accessControlMode: "manual_unenforced", + session: null, + }; + } + const session = await input.runtime.auth.api.getSession({ + headers: input.request.headers, + }); + const resolvedSession = session?.user?.id + ? { + userId: session.user.id, + email: normalizeOptionalString(session.user.email), + name: normalizeOptionalString(session.user.name), + organizationId: extractBetterAuthOrganizationId(session), + } + : null; + return { + enabled: true, + accessControlMode: resolvedSession?.organizationId + ? "better_auth_organization_owned" + : "better_auth_session_owned", + session: resolvedSession, + }; +} + +function extractBetterAuthOrganizationId(session: unknown): string | null { + const record = readRecord(session); + const sessionRecord = readRecord(record?.session); + return ( + normalizeOptionalString(sessionRecord?.activeOrganizationId) ?? + normalizeOptionalString(sessionRecord?.organizationId) ?? + readNestedRecordId(sessionRecord?.activeOrganization) ?? + normalizeOptionalString(record?.activeOrganizationId) ?? + normalizeOptionalString(record?.organizationId) ?? + readNestedRecordId(record?.activeOrganization) ?? + null + ); +} + +function readRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function readNestedRecordId(value: unknown): string | null { + const record = readRecord(value); + return normalizeOptionalString(record?.id); +} + +function normalizeOptionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} diff --git a/services/auth-broker/src/modules/linear-agent/plugin.ts b/services/auth-broker/src/modules/linear-agent/plugin.ts index e60c16840..360d7fb38 100644 --- a/services/auth-broker/src/modules/linear-agent/plugin.ts +++ b/services/auth-broker/src/modules/linear-agent/plugin.ts @@ -186,6 +186,10 @@ async function persistWebhookDelivery(input: { issueIdentifier: input.prepared.event.issueIdentifier ?? null, betterAuthUserId: ownership.status === "matched" ? ownership.betterAuthUserId : null, + betterAuthOrganizationId: + ownership.status === "matched" + ? ownership.betterAuthOrganizationId + : null, organizationId: input.prepared.event.organizationId ?? ownership.organizationId ?? null, teamId: input.prepared.event.teamId ?? ownership.teamId ?? null, diff --git a/services/auth-broker/src/modules/linear-connections/plugin.ts b/services/auth-broker/src/modules/linear-connections/plugin.ts index 1523caf45..dac5b9ddb 100644 --- a/services/auth-broker/src/modules/linear-connections/plugin.ts +++ b/services/auth-broker/src/modules/linear-connections/plugin.ts @@ -1,5 +1,7 @@ import { Elysia, t } from "elysia"; +import type { BetterAuthRuntime } from "../../better-auth.ts"; +import { resolveBetterAuthSession } from "../better-auth/session.ts"; import type { LinearConnectionStore } from "./service.ts"; const listConnectionsQuerySchema = t.Object({ @@ -9,23 +11,40 @@ const listConnectionsQuerySchema = t.Object({ type CreateLinearConnectionsPluginOptions = { readonly connectionStore: LinearConnectionStore; + readonly betterAuthRuntime: BetterAuthRuntime; }; export function createLinearConnectionsPlugin({ connectionStore, + betterAuthRuntime, }: CreateLinearConnectionsPluginOptions) { return new Elysia({ name: "hack-auth-broker.linear-connections", }).get( "/v1/auth/linear/connections", - async ({ query }) => ({ - ok: true, - accessControlMode: "manual_unenforced", - connections: await connectionStore.listConnections({ - profileId: normalizeOptionalQueryValue(query.profileId), - organizationId: normalizeOptionalQueryValue(query.organizationId), - }), - }), + async ({ query, request, set }) => { + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } + return { + ok: true, + accessControlMode: session.accessControlMode, + connections: await connectionStore.listConnections({ + profileId: normalizeOptionalQueryValue(query.profileId), + organizationId: normalizeOptionalQueryValue(query.organizationId), + betterAuthUserId: session.session?.userId ?? null, + betterAuthOrganizationId: session.session?.organizationId ?? null, + }), + } as const; + }, { query: listConnectionsQuerySchema } ); } diff --git a/services/auth-broker/src/modules/linear-connections/service.ts b/services/auth-broker/src/modules/linear-connections/service.ts index fd628ef87..0309fde9f 100644 --- a/services/auth-broker/src/modules/linear-connections/service.ts +++ b/services/auth-broker/src/modules/linear-connections/service.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { createDbClient } from "@hack/db"; -import { linearConnections } from "@hack/db/schema/core"; import { and, desc, eq, type SQL } from "drizzle-orm"; +import { linearConnections } from "../../db/schema.ts"; +import { createDbClient } from "../../db.ts"; + export type LinearConnectionMetadata = Record; export type LinearConnectionRecord = { @@ -15,6 +16,7 @@ export type LinearConnectionRecord = { readonly accountEmail: string | null; readonly authRef: string | null; readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId: string | null; readonly organizationId: string | null; readonly teamId: string | null; readonly metadata: LinearConnectionMetadata; @@ -29,6 +31,7 @@ export type UpsertLinearConnectionInput = { readonly accountEmail?: string | null; readonly authRef?: string | null; readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; readonly organizationId?: string | null; readonly teamId?: string | null; readonly metadata?: LinearConnectionMetadata; @@ -37,12 +40,15 @@ export type UpsertLinearConnectionInput = { export type ListLinearConnectionsInput = { readonly profileId?: string | null; readonly organizationId?: string | null; + readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; }; export type LinearWebhookOwnership = { readonly status: "matched" | "ambiguous" | "unmatched"; readonly profileId: string | null; readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId: string | null; readonly organizationId: string | null; readonly teamId: string | null; readonly connectionId?: string; @@ -70,6 +76,10 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { const connectionKey = buildConnectionKey(input); const existing = this.recordsByKey.get(connectionKey); const now = new Date().toISOString(); + const metadata = composeConnectionMetadata({ + metadata: input.metadata, + betterAuthOrganizationId: input.betterAuthOrganizationId, + }); const record: LinearConnectionRecord = { id: existing?.id ?? randomUUID(), connectionKey, @@ -79,9 +89,10 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { accountEmail: normalizeText(input.accountEmail), authRef: normalizeText(input.authRef), betterAuthUserId: normalizeText(input.betterAuthUserId), + betterAuthOrganizationId: normalizeText(input.betterAuthOrganizationId), organizationId: normalizeText(input.organizationId), teamId: normalizeText(input.teamId), - metadata: input.metadata ?? {}, + metadata, createdAt: existing?.createdAt ?? now, updatedAt: now, }; @@ -121,6 +132,7 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { status: "unmatched", profileId: null, betterAuthUserId: null, + betterAuthOrganizationId: null, organizationId: null, teamId: null, }; @@ -142,6 +154,12 @@ export function createLinearConnectionStoreFromDb(input: { upsertConnection: async (connection) => { const connectionKey = buildConnectionKey(connection); const now = new Date(); + const metadataJson = JSON.stringify( + composeConnectionMetadata({ + metadata: connection.metadata, + betterAuthOrganizationId: connection.betterAuthOrganizationId, + }) + ); const inserted = await db .insert(linearConnections) .values({ @@ -154,7 +172,7 @@ export function createLinearConnectionStoreFromDb(input: { betterAuthUserId: normalizeText(connection.betterAuthUserId), organizationId: normalizeText(connection.organizationId), teamId: normalizeText(connection.teamId), - metadataJson: JSON.stringify(connection.metadata ?? {}), + metadataJson, updatedAt: now, }) .onConflictDoUpdate({ @@ -168,7 +186,7 @@ export function createLinearConnectionStoreFromDb(input: { betterAuthUserId: normalizeText(connection.betterAuthUserId), organizationId: normalizeText(connection.organizationId), teamId: normalizeText(connection.teamId), - metadataJson: JSON.stringify(connection.metadata ?? {}), + metadataJson, updatedAt: now, }, }) @@ -193,7 +211,9 @@ export function createLinearConnectionStoreFromDb(input: { .from(linearConnections) .where(and(...filters)) .orderBy(desc(linearConnections.updatedAt)); - return rows.map((row) => toConnectionRecord({ row })); + return rows + .map((row) => toConnectionRecord({ row })) + .filter((record) => matchesConnectionFilter({ record, filter: input })); }, resolveWebhookOwnership: async ({ profileId, organizationId }) => { @@ -219,6 +239,7 @@ export function createLinearConnectionStoreFromDb(input: { status: "unmatched", profileId: null, betterAuthUserId: null, + betterAuthOrganizationId: null, organizationId: null, teamId: null, }; @@ -268,6 +289,13 @@ function buildListFilters(input: { if (organizationId) { filters.push(eq(linearConnections.organizationId, organizationId)); } + const betterAuthUserId = normalizeText(input.input.betterAuthUserId); + if ( + betterAuthUserId && + normalizeText(input.input.betterAuthOrganizationId) == null + ) { + filters.push(eq(linearConnections.betterAuthUserId, betterAuthUserId)); + } return filters; } @@ -283,6 +311,30 @@ function matchesConnectionFilter(input: { if (organizationId && input.record.organizationId !== organizationId) { return false; } + const betterAuthOrganizationId = normalizeText( + input.filter.betterAuthOrganizationId + ); + if ( + betterAuthOrganizationId && + input.record.betterAuthOrganizationId !== betterAuthOrganizationId + ) { + const fallbackBetterAuthUserId = normalizeText( + input.filter.betterAuthUserId + ); + if ( + input.record.betterAuthOrganizationId != null || + fallbackBetterAuthUserId == null || + input.record.betterAuthUserId !== fallbackBetterAuthUserId + ) { + return false; + } + } else if (betterAuthOrganizationId) { + return true; + } + const betterAuthUserId = normalizeText(input.filter.betterAuthUserId); + if (betterAuthUserId && input.record.betterAuthUserId !== betterAuthUserId) { + return false; + } return true; } @@ -298,6 +350,9 @@ function toConnectionRecord(input: { accountEmail: input.row.accountEmail ?? null, authRef: input.row.authRef ?? null, betterAuthUserId: input.row.betterAuthUserId ?? null, + betterAuthOrganizationId: parseStoredBetterAuthOrganizationId({ + raw: input.row.metadataJson, + }), organizationId: input.row.organizationId ?? null, teamId: input.row.teamId ?? null, metadata: parseMetadata({ raw: input.row.metadataJson }), @@ -311,9 +366,14 @@ function parseMetadata(input: { }): LinearConnectionMetadata { try { const parsed = JSON.parse(input.raw) as unknown; - return typeof parsed === "object" && parsed !== null - ? (parsed as LinearConnectionMetadata) - : {}; + if (typeof parsed !== "object" || parsed === null) { + return {}; + } + const metadata = { + ...(parsed as LinearConnectionMetadata), + }; + delete metadata[BETTER_AUTH_ORGANIZATION_METADATA_KEY]; + return metadata; } catch { return {}; } @@ -330,6 +390,7 @@ function toWebhookOwnership(input: { status: "matched", profileId: preferred.profileId, betterAuthUserId: preferred.betterAuthUserId, + betterAuthOrganizationId: preferred.betterAuthOrganizationId, organizationId: preferred.organizationId ?? input.fallbackOrganizationId, teamId: preferred.teamId, connectionId: preferred.id, @@ -340,6 +401,7 @@ function toWebhookOwnership(input: { status: "ambiguous", profileId: null, betterAuthUserId: null, + betterAuthOrganizationId: null, organizationId: input.fallbackOrganizationId, teamId: null, }; @@ -348,12 +410,47 @@ function toWebhookOwnership(input: { status: "unmatched", profileId: null, betterAuthUserId: null, + betterAuthOrganizationId: null, organizationId: input.fallbackOrganizationId, teamId: null, }; } -function normalizeText(value: string | null | undefined): string | null { +const BETTER_AUTH_ORGANIZATION_METADATA_KEY = "_betterAuthOrganizationId"; + +function composeConnectionMetadata(input: { + readonly metadata?: LinearConnectionMetadata; + readonly betterAuthOrganizationId?: string | null; +}): LinearConnectionMetadata { + const metadata = { ...(input.metadata ?? {}) }; + const betterAuthOrganizationId = normalizeText( + input.betterAuthOrganizationId + ); + if (betterAuthOrganizationId) { + metadata[BETTER_AUTH_ORGANIZATION_METADATA_KEY] = betterAuthOrganizationId; + } else { + delete metadata[BETTER_AUTH_ORGANIZATION_METADATA_KEY]; + } + return metadata; +} + +function parseStoredBetterAuthOrganizationId(input: { + readonly raw: string; +}): string | null { + try { + const parsed = JSON.parse(input.raw) as unknown; + if (typeof parsed !== "object" || parsed === null) { + return null; + } + return normalizeText( + (parsed as Record)[BETTER_AUTH_ORGANIZATION_METADATA_KEY] + ); + } catch { + return null; + } +} + +function normalizeText(value: unknown): string | null { if (typeof value !== "string") { return null; } diff --git a/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts b/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts index 0037489f7..6085aff0b 100644 --- a/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts +++ b/services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts @@ -24,12 +24,13 @@ export function createLinearOAuthCallbackRoutesPlugin({ name: "hack-auth-broker.linear-oauth.callback-routes", }).get( "/linear/callback", - ({ query }) => + ({ query, request }) => handleLinearCallback({ config, flowStore, connectionStore, betterAuthRuntime, + request, query, }), { query: LinearOAuthModel.callbackQuery } diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts index f72362a0f..69605b22e 100644 --- a/services/auth-broker/src/modules/linear-oauth/service.ts +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -11,6 +11,7 @@ import { refreshAccessToken, } from "../../linear.ts"; import type { OAuthFlow } from "../../types.ts"; +import { resolveBetterAuthSession } from "../better-auth/session.ts"; import type { LinearConnectionStore } from "../linear-connections/service.ts"; import type { LinearOAuthModel } from "./model.ts"; @@ -89,6 +90,7 @@ export async function handleLinearCallback(input: { readonly flowStore: FlowStore; readonly connectionStore: LinearConnectionStore; readonly betterAuthRuntime: BetterAuthRuntime; + readonly request: Request; readonly query: LinearOAuthModel["callbackQuery"]; }): Promise { const flowResult = resolveLinearCallbackFlow({ @@ -187,6 +189,10 @@ export async function handleLinearCallback(input: { account: identity.account, autoProvision: input.config.betterAuthLinearAutoProvisionUsers, }); + const betterAuthSession = await resolveBetterAuthSession({ + runtime: input.betterAuthRuntime, + request: input.request, + }); const linkedAccount = { ...identity.account, ...(betterAuthLink.userId @@ -212,6 +218,8 @@ export async function handleLinearCallback(input: { accountName: identity.account.accountName, accountEmail: identity.account.accountEmail, betterAuthUserId: betterAuthLink.userId ?? null, + betterAuthOrganizationId: + betterAuthSession.session?.organizationId ?? null, organizationId: identity.account.organizationId, teamId: identity.account.teamIds?.[0] ?? null, metadata: { diff --git a/services/auth-broker/src/modules/linear-sync-store/plugin.ts b/services/auth-broker/src/modules/linear-sync-store/plugin.ts index b21f49e1c..a891038b5 100644 --- a/services/auth-broker/src/modules/linear-sync-store/plugin.ts +++ b/services/auth-broker/src/modules/linear-sync-store/plugin.ts @@ -1,5 +1,7 @@ import { Elysia, t } from "elysia"; +import type { BetterAuthRuntime } from "../../better-auth.ts"; +import { resolveBetterAuthSession } from "../better-auth/session.ts"; import type { LinearSyncStore, LinearWebhookDeliveryStatus, @@ -9,6 +11,7 @@ const LINEAR_DELIVERY_STATUSES = ["pending", "applied", "ignored"] as const; type CreateLinearSyncStorePluginOptions = { readonly syncStore: LinearSyncStore; + readonly betterAuthRuntime: BetterAuthRuntime; }; const listDeliveriesQuerySchema = t.Object({ @@ -27,13 +30,14 @@ const applyDeliveryParamsSchema = t.Object({ */ export function createLinearSyncStorePlugin({ syncStore, + betterAuthRuntime, }: CreateLinearSyncStorePluginOptions) { return new Elysia({ name: "hack-auth-broker.linear-sync-store", }) .get( "/v1/auth/linear/deliveries", - async ({ query, set }) => { + async ({ query, request, set }) => { const statusResult = normalizeStatusQuery({ value: query.status }); if (!statusResult.ok) { set.status = 400; @@ -42,15 +46,29 @@ export function createLinearSyncStorePlugin({ error: statusResult.error, } as const; } + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } const deliveries = await syncStore.listWebhookDeliveries({ status: statusResult.status, profileId: normalizeOptionalQueryValue({ value: query.profileId }), projectId: normalizeOptionalQueryValue({ value: query.projectId }), teamId: normalizeOptionalQueryValue({ value: query.teamId }), + betterAuthUserId: session.session?.userId ?? null, + betterAuthOrganizationId: session.session?.organizationId ?? null, }); return { ok: true, + accessControlMode: session.accessControlMode, deliveries, } as const; }, @@ -60,7 +78,39 @@ export function createLinearSyncStorePlugin({ ) .post( "/v1/auth/linear/deliveries/:deliveryId/apply", - async ({ params, set }) => { + async ({ params, request, set }) => { + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } + if (session.session) { + const existing = await syncStore.getWebhookDelivery({ + deliveryId: params.deliveryId, + }); + const hasOrganizationAccess = + session.session.organizationId != null && + (existing?.betterAuthOrganizationId === + session.session.organizationId || + (existing?.betterAuthOrganizationId == null && + existing?.betterAuthUserId === session.session.userId)); + const hasUserAccess = + session.session.organizationId == null && + existing?.betterAuthUserId === session.session.userId; + if (!(existing && (hasOrganizationAccess || hasUserAccess))) { + set.status = 404; + return { + ok: false, + error: "linear_delivery_not_found", + } as const; + } + } const delivery = await syncStore.markWebhookDeliveryApplied({ deliveryId: params.deliveryId, }); diff --git a/services/auth-broker/src/modules/linear-sync-store/service.ts b/services/auth-broker/src/modules/linear-sync-store/service.ts index bb1e16cd0..f79eeabf9 100644 --- a/services/auth-broker/src/modules/linear-sync-store/service.ts +++ b/services/auth-broker/src/modules/linear-sync-store/service.ts @@ -1,9 +1,10 @@ import { createHash, randomUUID } from "node:crypto"; -import { createDbClient } from "@hack/db"; -import { linearWebhookEvents } from "@hack/db/schema/core"; import { and, desc, eq, type SQL, sql } from "drizzle-orm"; +import { linearWebhookEvents } from "../../db/schema.ts"; +import { createDbClient } from "../../db.ts"; + export type LinearWebhookDeliveryStatus = "pending" | "applied" | "ignored"; export type RecordLinearWebhookDeliveryInput = { @@ -20,6 +21,7 @@ export type RecordLinearWebhookDeliveryInput = { readonly issueId?: string | null; readonly issueIdentifier?: string | null; readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId?: string | null; readonly organizationId: string | null; readonly teamId: string | null; }; @@ -29,6 +31,8 @@ export type ListLinearWebhookDeliveriesInput = { readonly profileId?: string | null; readonly projectId?: string | null; readonly teamId?: string | null; + readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; }; export type LinearWebhookDelivery = { @@ -46,6 +50,7 @@ export type LinearWebhookDelivery = { readonly issueId: string | null; readonly issueIdentifier: string | null; readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId: string | null; readonly organizationId: string | null; readonly ownerTeamId: string | null; readonly status: LinearWebhookDeliveryStatus; @@ -61,6 +66,9 @@ export type LinearSyncStore = { readonly listWebhookDeliveries: ( input?: ListLinearWebhookDeliveriesInput ) => Promise; + readonly getWebhookDelivery: (input: { + readonly deliveryId: string; + }) => Promise; readonly markWebhookDeliveryApplied: (input: { readonly deliveryId: string; }) => Promise; @@ -94,6 +102,7 @@ export class InMemoryLinearSyncStore implements LinearSyncStore { issueId: normalizeText(input.issueId), issueIdentifier: normalizeText(input.issueIdentifier), betterAuthUserId: input.betterAuthUserId, + betterAuthOrganizationId: input.betterAuthOrganizationId ?? null, organizationId: input.organizationId, ownerTeamId: normalizeText(input.teamId), status: "pending", @@ -117,6 +126,12 @@ export class InMemoryLinearSyncStore implements LinearSyncStore { ); } + getWebhookDelivery(input: { + readonly deliveryId: string; + }): Promise { + return Promise.resolve(this.deliveriesById.get(input.deliveryId) ?? null); + } + markWebhookDeliveryApplied(input: { readonly deliveryId: string; }): Promise { @@ -157,6 +172,7 @@ export function createLinearSyncStoreFromDb(input: { issueId, issueIdentifier, betterAuthUserId, + betterAuthOrganizationId, organizationId, teamId, }) => { @@ -187,6 +203,7 @@ export function createLinearSyncStoreFromDb(input: { payload: payloadJson, signatureVerified, webhookTimestamp, + [BETTER_AUTH_ORGANIZATION_PAYLOAD_KEY]: betterAuthOrganizationId, }), applyError: null, claimedBy: null, @@ -227,7 +244,20 @@ export function createLinearSyncStoreFromDb(input: { .from(linearWebhookEvents) .where(and(...filters)) .orderBy(desc(linearWebhookEvents.createdAt)); - return rows.map((row) => toWebhookDelivery({ row })); + return rows + .map((row) => toWebhookDelivery({ row })) + .filter((delivery) => + matchesDeliveryFilter({ delivery, filter: input }) + ); + }, + + getWebhookDelivery: async ({ deliveryId }) => { + const rows = await db + .select() + .from(linearWebhookEvents) + .where(eq(linearWebhookEvents.id, deliveryId)) + .limit(1); + return rows[0] ? toWebhookDelivery({ row: rows[0] }) : null; }, markWebhookDeliveryApplied: async ({ deliveryId }) => { @@ -270,6 +300,9 @@ function toWebhookDelivery(input: { issueId: input.row.issueId ?? null, issueIdentifier: input.row.issueIdentifier ?? null, betterAuthUserId: input.row.betterAuthUserId ?? null, + betterAuthOrganizationId: readStoredBetterAuthOrganizationId({ + payloadJson: input.row.payloadJson, + }), organizationId: input.row.organizationId ?? null, ownerTeamId: input.row.ownerTeamId ?? null, status: normalizeDeliveryStatus({ value: input.row.status }), @@ -298,6 +331,13 @@ function buildListFilters(input: { if (teamId) { filters.push(eq(linearWebhookEvents.teamId, teamId)); } + const betterAuthUserId = normalizeText(input.input.betterAuthUserId); + if ( + betterAuthUserId && + normalizeText(input.input.betterAuthOrganizationId) == null + ) { + filters.push(eq(linearWebhookEvents.betterAuthUserId, betterAuthUserId)); + } return filters; } @@ -320,6 +360,33 @@ function matchesDeliveryFilter(input: { if (teamId && input.delivery.teamId !== teamId) { return false; } + const betterAuthOrganizationId = normalizeText( + input.filter.betterAuthOrganizationId + ); + if ( + betterAuthOrganizationId && + input.delivery.betterAuthOrganizationId !== betterAuthOrganizationId + ) { + const fallbackBetterAuthUserId = normalizeText( + input.filter.betterAuthUserId + ); + if ( + input.delivery.betterAuthOrganizationId != null || + fallbackBetterAuthUserId == null || + input.delivery.betterAuthUserId !== fallbackBetterAuthUserId + ) { + return false; + } + } else if (betterAuthOrganizationId) { + return true; + } + const betterAuthUserId = normalizeText(input.filter.betterAuthUserId); + if ( + betterAuthUserId && + input.delivery.betterAuthUserId !== betterAuthUserId + ) { + return false; + } return true; } @@ -344,6 +411,8 @@ function buildDeliveryKey(input: { .digest("hex"); } +const BETTER_AUTH_ORGANIZATION_PAYLOAD_KEY = "_betterAuthOrganizationId"; + function readStoredEnvelope(input: { readonly payloadJson: string; }): Record | null { @@ -380,6 +449,15 @@ function readStoredWebhookTimestamp(input: { }); } +function readStoredBetterAuthOrganizationId(input: { + readonly payloadJson: string; +}): string | null { + return readStringField({ + record: readStoredEnvelope(input), + key: BETTER_AUTH_ORGANIZATION_PAYLOAD_KEY, + }); +} + function readStoredSignatureVerified(input: { readonly payloadJson: string; }): boolean { @@ -403,7 +481,7 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } -function normalizeText(value: string | null | undefined): string | null { +function normalizeText(value: unknown): string | null { if (typeof value !== "string") { return null; } diff --git a/services/auth-broker/src/modules/providers/plugin.ts b/services/auth-broker/src/modules/providers/plugin.ts index 5b4ce78db..dfa514a7e 100644 --- a/services/auth-broker/src/modules/providers/plugin.ts +++ b/services/auth-broker/src/modules/providers/plugin.ts @@ -2,6 +2,7 @@ import { Elysia } from "elysia"; import type { BetterAuthRuntime } from "../../better-auth.ts"; import type { BrokerConfig } from "../../config.ts"; +import { resolveBetterAuthSession } from "../better-auth/session.ts"; type CreateProvidersPluginOptions = { readonly config: BrokerConfig; @@ -17,41 +18,47 @@ export function createProvidersPlugin({ }: CreateProvidersPluginOptions) { return new Elysia({ name: "hack-auth-broker.providers", - }).all("/v1/auth/providers", () => ({ - providers: [ - { - id: "better-auth", - enabled: betterAuthRuntime.enabled, - mode: "session", - basePath: "/api/auth", - }, - { - id: "github", - enabled: true, - mode: "oauth", - requestedScopes: config.githubScopes, - redirectUri: config.githubRedirectUri, - appId: config.githubAppId, - appSlug: config.githubAppSlug, - appInstallUrl: config.githubAppInstallUrl, - }, - { - id: "linear", - enabled: Boolean(config.linearClientId), - mode: "oauth+agent", - requestedScopes: config.linearScopes, - redirectUri: config.linearRedirectUri, - authorizeUrl: config.linearAuthorizeUrl, - tokenUrl: config.linearTokenUrl, - apiBaseUrl: config.linearApiBaseUrl, - webhookPath: config.linearWebhookPath, - connectionsPath: "/v1/auth/linear/connections", - accessControlMode: "manual_unenforced", - developerAppTokenConfigured: Boolean(config.linearDeveloperAppToken), - webhookSignatureVerification: config.linearWebhookSigningSecret - ? "hmac-sha256" - : "disabled", - }, - ] as const, - })); + }).all("/v1/auth/providers", async ({ request }) => { + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + return { + providers: [ + { + id: "better-auth", + enabled: betterAuthRuntime.enabled, + mode: "session", + basePath: "/api/auth", + }, + { + id: "github", + enabled: true, + mode: "oauth", + requestedScopes: config.githubScopes, + redirectUri: config.githubRedirectUri, + appId: config.githubAppId, + appSlug: config.githubAppSlug, + appInstallUrl: config.githubAppInstallUrl, + }, + { + id: "linear", + enabled: Boolean(config.linearClientId), + mode: "oauth+agent", + requestedScopes: config.linearScopes, + redirectUri: config.linearRedirectUri, + authorizeUrl: config.linearAuthorizeUrl, + tokenUrl: config.linearTokenUrl, + apiBaseUrl: config.linearApiBaseUrl, + webhookPath: config.linearWebhookPath, + connectionsPath: "/v1/auth/linear/connections", + accessControlMode: session.accessControlMode, + developerAppTokenConfigured: Boolean(config.linearDeveloperAppToken), + webhookSignatureVerification: config.linearWebhookSigningSecret + ? "hmac-sha256" + : "disabled", + }, + ] as const, + }; + }); } diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 9d9b893f6..24cd41e57 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -11,6 +11,10 @@ import { } from "../src/modules/linear-sync-store/service.ts"; type BetterAuthDb = NonNullable; +type BetterAuthAuth = NonNullable; +type BetterAuthSession = Awaited< + ReturnType +>; type StartFlowResponse = { readonly ok: true; @@ -31,6 +35,38 @@ function createBetterAuthDb( return { execute } as unknown as BetterAuthDb; } +function createBetterAuthRuntimeWithSession( + session: BetterAuthSession, + db?: BetterAuthDb +): BetterAuthRuntime { + return { + enabled: true, + ...(db ? { db } : {}), + auth: { + api: { + getSession: async () => session, + }, + } as unknown as BetterAuthAuth["api"], + } as unknown as BetterAuthRuntime; +} + +function withActiveOrganization( + session: NonNullable, + organizationId: string +): BetterAuthSession { + const base = session as unknown as { + readonly session: Record; + readonly user: Record; + }; + return { + user: base.user, + session: { + ...base.session, + activeOrganizationId: organizationId, + }, + } as unknown as BetterAuthSession; +} + /** * Build deterministic config values for broker route tests. */ @@ -195,6 +231,70 @@ describe("auth broker github flow routes", () => { expect(linearProvider?.webhookSignatureVerification).toBe("hmac-sha256"); }); + test("providers route reports session-owned Linear access when Better Auth is enabled", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + const response = await app.handle( + new Request("http://localhost/v1/auth/providers") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly providers: ReadonlyArray<{ + readonly id: string; + readonly accessControlMode?: string; + }>; + }; + const linearProvider = payload.providers.find((p) => p.id === "linear"); + expect(linearProvider?.accessControlMode).toBe("better_auth_session_owned"); + }); + + test("providers route reports organization-owned Linear access when Better Auth has an active organization", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveOrganization( + { + session: { + id: "sess-org", + userId: "user-org", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-org", + }, + user: { + id: "user-org", + email: "org@example.com", + emailVerified: true, + name: "Org User", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + "better-auth-org" + ) + ), + }); + const response = await app.handle( + new Request("http://localhost/v1/auth/providers") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly providers: ReadonlyArray<{ + readonly id: string; + readonly accessControlMode?: string; + }>; + }; + const linearProvider = payload.providers.find((p) => p.id === "linear"); + expect(linearProvider?.accessControlMode).toBe( + "better_auth_organization_owned" + ); + }); + test("linear start flow requests app actor mode by default", async () => { const app = createAuthBrokerApp({ config: createTestConfig(), @@ -556,6 +656,215 @@ describe("auth broker github flow routes", () => { expect(payload.deliveries[0]?.ownerTeamId).toBe("team-a"); }); + test("linear pending deliveries route requires Better Auth session when enabled", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/deliveries") + ); + expect(response.status).toBe(401); + const payload = (await response.json()) as { + readonly ok: false; + readonly error: string; + }; + expect(payload.ok).toBe(false); + expect(payload.error).toBe("better_auth_session_required"); + }); + + test("linear pending deliveries route only returns the current user's deliveries when Better Auth is enabled", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession({ + session: { + id: "sess-user-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-user-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }), + }); + const ownedDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { + type: "Issue", + }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-a", + projectId: "project-a", + issueId: "issue-a", + issueIdentifier: "HACK-1", + betterAuthUserId: "user-a", + organizationId: "org-a", + teamId: "team-a", + }); + await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { + type: "Issue", + }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-b", + projectId: "project-b", + issueId: "issue-b", + issueIdentifier: "HACK-2", + betterAuthUserId: "user-b", + organizationId: "org-b", + teamId: "team-b", + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/deliveries") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly deliveries: ReadonlyArray<{ + readonly id: string; + readonly betterAuthUserId: string | null; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.accessControlMode).toBe("better_auth_session_owned"); + expect(payload.deliveries).toHaveLength(1); + expect(payload.deliveries[0]?.id).toBe(ownedDelivery.id); + expect(payload.deliveries[0]?.betterAuthUserId).toBe("user-a"); + }); + + test("linear pending deliveries route returns the current organization's deliveries when Better Auth has an active organization", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveOrganization( + { + session: { + id: "sess-user-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-user-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + "shared-org" + ) + ), + }); + const firstDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-a", + projectId: "project-a", + issueId: "issue-a", + issueIdentifier: "HACK-1", + betterAuthUserId: "user-a", + betterAuthOrganizationId: "shared-org", + organizationId: "org-a", + teamId: "team-a", + }); + const secondDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-b", + projectId: "project-b", + issueId: "issue-b", + issueIdentifier: "HACK-2", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + organizationId: "org-b", + teamId: "team-b", + }); + await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-c", + projectId: "project-c", + issueId: "issue-c", + issueIdentifier: "HACK-3", + betterAuthUserId: "user-c", + betterAuthOrganizationId: "other-org", + organizationId: "org-c", + teamId: "team-c", + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/deliveries") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly deliveries: ReadonlyArray<{ + readonly id: string; + readonly betterAuthOrganizationId: string | null; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.accessControlMode).toBe("better_auth_organization_owned"); + expect(payload.deliveries).toHaveLength(2); + expect( + [...payload.deliveries.map((delivery) => delivery.id)].sort() + ).toEqual([...([firstDelivery.id, secondDelivery.id] as const)].sort()); + expect( + payload.deliveries.every( + (delivery) => delivery.betterAuthOrganizationId === "shared-org" + ) + ).toBe(true); + }); + test("linear pending deliveries route can mark a delivery applied", async () => { const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); const app = createAuthBrokerApp({ @@ -632,6 +941,135 @@ describe("auth broker github flow routes", () => { expect(payload.error).toBe("linear_delivery_not_found"); }); + test("linear pending deliveries apply route only allows the owner when Better Auth is enabled", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession({ + session: { + id: "sess-user-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-user-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }), + }); + const otherUsersDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { + type: "Issue", + }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-b", + projectId: "project-b", + issueId: "issue-b", + issueIdentifier: "HACK-2", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "org-b", + organizationId: "org-b", + teamId: "team-b", + }); + + const response = await app.handle( + new Request( + `http://localhost/v1/auth/linear/deliveries/${otherUsersDelivery.id}/apply`, + { + method: "POST", + } + ) + ); + expect(response.status).toBe(404); + const payload = (await response.json()) as { + readonly ok: false; + readonly error: string; + }; + expect(payload.ok).toBe(false); + expect(payload.error).toBe("linear_delivery_not_found"); + }); + + test("linear pending deliveries apply route allows the active Better Auth organization", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveOrganization( + { + session: { + id: "sess-user-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-user-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + "shared-org" + ) + ), + }); + const sharedDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-b", + projectId: "project-b", + issueId: "issue-b", + issueIdentifier: "HACK-2", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + organizationId: "org-b", + teamId: "team-b", + }); + + const response = await app.handle( + new Request( + `http://localhost/v1/auth/linear/deliveries/${sharedDelivery.id}/apply`, + { method: "POST" } + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly delivery: { + readonly id: string; + readonly status: string; + }; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery.id).toBe(sharedDelivery.id); + expect(payload.delivery.status).toBe("applied"); + }); + test("linear start route issues flow payload and polling reads pending state", async () => { const flowStore = new FlowStore(); const app = createAuthBrokerApp({ @@ -685,10 +1123,30 @@ describe("auth broker github flow routes", () => { config: createTestConfig(), flowStore, linearConnectionStore: connectionStore, - betterAuthRuntime: { - enabled: true, - db: createBetterAuthDb([{ id: "better-auth-linear-user" }]), - }, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveOrganization( + { + session: { + id: "sess-linear", + userId: "better-auth-linear-user", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-linear", + }, + user: { + id: "better-auth-linear-user", + email: "linear@example.com", + emailVerified: true, + name: "Linear User", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + "better-auth-org" + ), + createBetterAuthDb([{ id: "better-auth-linear-user" }]) + ), }); const startResponse = await app.handle( @@ -808,11 +1266,14 @@ describe("auth broker github flow routes", () => { readonly accountEmail: string | null; readonly organizationId: string | null; readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId: string | null; readonly metadata: Record; }>; }; expect(connectionsPayload.ok).toBe(true); - expect(connectionsPayload.accessControlMode).toBe("manual_unenforced"); + expect(connectionsPayload.accessControlMode).toBe( + "better_auth_organization_owned" + ); expect(connectionsPayload.connections).toHaveLength(1); expect(connectionsPayload.connections[0]?.profileId).toBe("work"); expect(connectionsPayload.connections[0]?.accountEmail).toBe( @@ -824,6 +1285,9 @@ describe("auth broker github flow routes", () => { expect(connectionsPayload.connections[0]?.betterAuthUserId).toBe( "better-auth-linear-user" ); + expect(connectionsPayload.connections[0]?.betterAuthOrganizationId).toBe( + "better-auth-org" + ); expect(connectionsPayload.connections[0]?.metadata).toMatchObject({ organizationName: "Hack Dance", teamIds: ["team-linear"], @@ -833,6 +1297,169 @@ describe("auth broker github flow routes", () => { } }); + test("linear connections route requires Better Auth session when enabled", async () => { + const connectionStore = new InMemoryLinearConnectionStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearConnectionStore: connectionStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + await connectionStore.upsertConnection({ + profileId: "work", + accountEmail: "linear@example.com", + betterAuthUserId: "user-a", + organizationId: "org-a", + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/connections") + ); + expect(response.status).toBe(401); + const payload = (await response.json()) as { + readonly ok: false; + readonly error: string; + }; + expect(payload.ok).toBe(false); + expect(payload.error).toBe("better_auth_session_required"); + }); + + test("linear connections route only returns the current user's connections when Better Auth is enabled", async () => { + const connectionStore = new InMemoryLinearConnectionStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearConnectionStore: connectionStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession({ + session: { + id: "sess-user-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-user-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }), + }); + await connectionStore.upsertConnection({ + profileId: "work", + accountEmail: "linear-a@example.com", + betterAuthUserId: "user-a", + organizationId: "org-a", + }); + await connectionStore.upsertConnection({ + profileId: "other", + accountEmail: "linear-b@example.com", + betterAuthUserId: "user-b", + organizationId: "org-b", + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/connections") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly connections: ReadonlyArray<{ + readonly profileId: string | null; + readonly accountEmail: string | null; + readonly betterAuthUserId: string | null; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.accessControlMode).toBe("better_auth_session_owned"); + expect(payload.connections).toHaveLength(1); + expect(payload.connections[0]?.profileId).toBe("work"); + expect(payload.connections[0]?.accountEmail).toBe("linear-a@example.com"); + expect(payload.connections[0]?.betterAuthUserId).toBe("user-a"); + }); + + test("linear connections route returns the current organization's connections when Better Auth has an active organization", async () => { + const connectionStore = new InMemoryLinearConnectionStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearConnectionStore: connectionStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveOrganization( + { + session: { + id: "sess-user-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-user-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + "shared-org" + ) + ), + }); + await connectionStore.upsertConnection({ + profileId: "work", + accountEmail: "linear-a@example.com", + betterAuthUserId: "user-a", + betterAuthOrganizationId: "shared-org", + organizationId: "org-a", + }); + await connectionStore.upsertConnection({ + profileId: "shared", + accountEmail: "linear-b@example.com", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + organizationId: "org-b", + }); + await connectionStore.upsertConnection({ + profileId: "other", + accountEmail: "linear-c@example.com", + betterAuthUserId: "user-c", + betterAuthOrganizationId: "other-org", + organizationId: "org-c", + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/connections") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly connections: ReadonlyArray<{ + readonly profileId: string | null; + readonly betterAuthOrganizationId: string | null; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.accessControlMode).toBe("better_auth_organization_owned"); + expect(payload.connections).toHaveLength(2); + expect( + [...payload.connections.map((connection) => connection.profileId)].sort() + ).toEqual(["shared", "work"]); + expect( + payload.connections.every( + (connection) => connection.betterAuthOrganizationId === "shared-org" + ) + ).toBe(true); + }); + test("linear refresh route exchanges refresh token", async () => { const app = createAuthBrokerApp({ config: createTestConfig(), diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index 473d65bed..332524daa 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -451,6 +451,196 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 0; }, }, + { + name: "assignee-mappings", + summary: "List explicit local assignee to Linear user mappings", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseAssigneeMappingsArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const profileId = resolveSelectedLinearProfileId({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + }); + const mappings = listLinearAssigneeMappings({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + teamId: parsed.value.teamId, + }); + + const payload = { + profileId, + ...(parsed.value.teamId ? { teamId: parsed.value.teamId } : {}), + mappings, + }; + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear assignee mappings", + entries: [ + ["profile", profileId], + ["team_id", parsed.value.teamId ?? ""], + ["count", String(mappings.length)], + ], + }); + await display.table({ + columns: [ + "Local Assignee", + "Team ID", + "Linear User ID", + "Linear User", + "Linear Email", + ], + rows: mappings.map((mapping) => [ + mapping.localAssignee, + mapping.teamId ?? "", + mapping.linearUserId ?? "", + mapping.linearUserName ?? "", + mapping.linearUserEmail ?? "", + ]), + }); + return 0; + }, + }, + { + name: "set-assignee-mapping", + summary: + "Create or replace an explicit local assignee to Linear user mapping", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseUpsertAssigneeMappingArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const profileId = resolveSelectedLinearProfileId({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + }); + const nextMapping = createLinearAssigneeMapping({ + profileId, + teamId: parsed.value.teamId, + localAssignee: parsed.value.localAssignee, + linearUserId: parsed.value.linearUserId, + linearUserName: parsed.value.linearUserName, + linearUserEmail: parsed.value.linearUserEmail, + }); + if (!nextMapping) { + ctx.logger.error({ + message: + "Missing --local-assignee or Linear user details. Pass --linear-user-id, --linear-user-name, or --linear-user-email.", + }); + return 1; + } + + const currentMappings = listLinearAssigneeMappings({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); + const nextMappings = upsertLinearAssigneeMapping({ + mappings: currentMappings, + mapping: nextMapping, + }); + await updateGlobalConfig({ + path: `controlPlane.extensions["${EXTENSION_ID}"].config.assigneeMappings`, + value: nextMappings, + }); + + const payload = { + upserted: true, + replacedExisting: hasLinearAssigneeMapping({ + mappings: currentMappings, + mapping: nextMapping, + }), + mapping: nextMapping, + }; + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear assignee mapping saved", + entries: [ + ["profile", nextMapping.profileId], + ["team_id", nextMapping.teamId ?? ""], + ["local_assignee", nextMapping.localAssignee], + ["linear_user_id", nextMapping.linearUserId ?? ""], + ["linear_user_name", nextMapping.linearUserName ?? ""], + ["linear_user_email", nextMapping.linearUserEmail ?? ""], + ], + }); + return 0; + }, + }, + { + name: "remove-assignee-mapping", + summary: "Remove an explicit local assignee to Linear user mapping", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseRemoveAssigneeMappingArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const profileId = resolveSelectedLinearProfileId({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + }); + const localAssignee = readOptionalString(parsed.value.localAssignee); + if (!localAssignee) { + ctx.logger.error({ message: "Missing --local-assignee ." }); + return 1; + } + + const currentMappings = listLinearAssigneeMappings({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); + const removed = removeLinearAssigneeMapping({ + mappings: currentMappings, + profileId, + teamId: parsed.value.teamId, + localAssignee, + }); + await updateGlobalConfig({ + path: `controlPlane.extensions["${EXTENSION_ID}"].config.assigneeMappings`, + value: removed.mappings, + }); + + const payload = { + removed: removed.removed, + profileId, + ...(parsed.value.teamId ? { teamId: parsed.value.teamId } : {}), + localAssignee, + }; + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return removed.removed ? 0 : 1; + } + + await display.kv({ + title: "Linear assignee mapping removed", + entries: [ + ["profile", profileId], + ["team_id", parsed.value.teamId ?? ""], + ["local_assignee", localAssignee], + ["removed", removed.removed ? "yes" : "no"], + ], + }); + return removed.removed ? 0 : 1; + }, + }, { name: "project-bind", summary: "Bind current hack project to a default Linear profile/project", @@ -1430,6 +1620,7 @@ type SyncRuntime = { readonly profileId: string; readonly apiUrl: string; readonly projectBinding: ReturnType; + readonly assigneeMappings: readonly LinearAssigneeMapping[]; }; type SyncToggles = { @@ -1452,6 +1643,29 @@ type ApplyDeliveryArgs = { json: boolean; }; +type AssigneeMappingsArgs = { + profileId?: string; + teamId?: string; + json: boolean; +}; + +type UpsertAssigneeMappingArgs = { + profileId?: string; + teamId?: string; + localAssignee?: string; + linearUserId?: string; + linearUserName?: string; + linearUserEmail?: string; + json: boolean; +}; + +type RemoveAssigneeMappingArgs = { + profileId?: string; + teamId?: string; + localAssignee?: string; + json: boolean; +}; + type SyncAssigneeResolution = { readonly requestedAssignee?: string; readonly matchedUserId?: string; @@ -1459,6 +1673,15 @@ type SyncAssigneeResolution = { readonly applied: boolean; }; +type LinearAssigneeMapping = { + readonly profileId: string; + readonly localAssignee: string; + readonly teamId?: string; + readonly linearUserId?: string; + readonly linearUserName?: string; + readonly linearUserEmail?: string; +}; + type LinearToHackRuntime = { readonly tickets: Pick< TicketSyncStore, @@ -1501,7 +1724,9 @@ type HackToLinearRuntime = { | "listTeamUsers" | "updateIssue" >; + readonly assigneeMappings: readonly LinearAssigneeMapping[]; readonly profileId: string; + readonly apiUrl: string; readonly projectBinding: ReturnType; }; @@ -1550,10 +1775,203 @@ async function createSyncRuntime(input: { projectBinding: resolveProjectLinearBinding({ controlPlaneConfig: input.ctx.controlPlaneConfig, }), + assigneeMappings: listLinearAssigneeMappings({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + profileId: token.profileId, + }), }, }; } +function resolveSelectedLinearProfileId(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; +}): string { + return resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: false, + }).profileId; +} + +function listLinearAssigneeMappings(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; + readonly teamId?: string; +}): readonly LinearAssigneeMapping[] { + const extension = input.controlPlaneConfig.extensions?.[EXTENSION_ID]; + const config = + isRecord(extension) && isRecord(extension.config) ? extension.config : null; + const rawMappings = config?.assigneeMappings; + const parsedMappings = parseStoredLinearAssigneeMappings({ + value: rawMappings, + }); + + return parsedMappings.filter((mapping) => { + if (input.profileId && mapping.profileId !== input.profileId) { + return false; + } + if (input.teamId && mapping.teamId !== input.teamId) { + return false; + } + return true; + }); +} + +function parseStoredLinearAssigneeMappings(input: { + readonly value: unknown; +}): readonly LinearAssigneeMapping[] { + let rawItems: readonly unknown[] = []; + if (Array.isArray(input.value)) { + rawItems = input.value; + } else if (isRecord(input.value) && Array.isArray(input.value.items)) { + rawItems = input.value.items; + } + const deduped = new Map(); + for (const item of rawItems) { + const mapping = parseLinearAssigneeMapping({ value: item }); + if (!mapping) { + continue; + } + deduped.set(buildLinearAssigneeMappingKey({ mapping }), mapping); + } + return [...deduped.values()].sort((left, right) => { + const leftKey = buildLinearAssigneeMappingKey({ mapping: left }); + const rightKey = buildLinearAssigneeMappingKey({ mapping: right }); + return leftKey.localeCompare(rightKey); + }); +} + +function parseLinearAssigneeMapping(input: { + readonly value: unknown; +}): LinearAssigneeMapping | null { + if (!isRecord(input.value)) { + return null; + } + return createLinearAssigneeMapping({ + profileId: input.value.profileId, + teamId: input.value.teamId, + localAssignee: input.value.localAssignee, + linearUserId: input.value.linearUserId, + linearUserName: input.value.linearUserName, + linearUserEmail: input.value.linearUserEmail, + }); +} + +function createLinearAssigneeMapping(input: { + readonly profileId: unknown; + readonly teamId?: unknown; + readonly localAssignee?: unknown; + readonly linearUserId?: unknown; + readonly linearUserName?: unknown; + readonly linearUserEmail?: unknown; +}): LinearAssigneeMapping | null { + const profileId = readOptionalString(input.profileId); + const localAssignee = readOptionalString(input.localAssignee); + const linearUserId = readOptionalString(input.linearUserId); + const linearUserName = readOptionalString(input.linearUserName); + const linearUserEmail = readOptionalString(input.linearUserEmail); + if (!(profileId && localAssignee)) { + return null; + } + if (!(linearUserId || linearUserName || linearUserEmail)) { + return null; + } + const teamId = readOptionalString(input.teamId); + return { + profileId, + localAssignee, + ...(teamId ? { teamId } : {}), + ...(linearUserId ? { linearUserId } : {}), + ...(linearUserName ? { linearUserName } : {}), + ...(linearUserEmail ? { linearUserEmail } : {}), + }; +} + +function buildLinearAssigneeMappingKey(input: { + readonly mapping: Pick< + LinearAssigneeMapping, + "profileId" | "teamId" | "localAssignee" + >; +}): string { + return [ + input.mapping.profileId.trim().toLowerCase(), + readOptionalString(input.mapping.teamId)?.toLowerCase() ?? "*", + input.mapping.localAssignee.trim().toLowerCase(), + ].join("|"); +} + +function hasLinearAssigneeMapping(input: { + readonly mappings: readonly LinearAssigneeMapping[]; + readonly mapping: LinearAssigneeMapping; +}): boolean { + const targetKey = buildLinearAssigneeMappingKey({ + mapping: input.mapping, + }); + return input.mappings.some( + (mapping) => + buildLinearAssigneeMappingKey({ + mapping, + }) === targetKey + ); +} + +function upsertLinearAssigneeMapping(input: { + readonly mappings: readonly LinearAssigneeMapping[]; + readonly mapping: LinearAssigneeMapping; +}): readonly LinearAssigneeMapping[] { + const targetKey = buildLinearAssigneeMappingKey({ + mapping: input.mapping, + }); + const next: LinearAssigneeMapping[] = []; + for (const mapping of input.mappings) { + if ( + buildLinearAssigneeMappingKey({ + mapping, + }) === targetKey + ) { + continue; + } + next.push(mapping); + } + next.push(input.mapping); + return next.sort((left, right) => { + const leftKey = buildLinearAssigneeMappingKey({ mapping: left }); + const rightKey = buildLinearAssigneeMappingKey({ mapping: right }); + return leftKey.localeCompare(rightKey); + }); +} + +function removeLinearAssigneeMapping(input: { + readonly mappings: readonly LinearAssigneeMapping[]; + readonly profileId: string; + readonly teamId?: string; + readonly localAssignee: string; +}): { + readonly removed: boolean; + readonly mappings: readonly LinearAssigneeMapping[]; +} { + const targetKey = buildLinearAssigneeMappingKey({ + mapping: { + profileId: input.profileId, + ...(input.teamId ? { teamId: input.teamId } : {}), + localAssignee: input.localAssignee, + }, + }); + let removed = false; + const next = input.mappings.filter((mapping) => { + const isTarget = + buildLinearAssigneeMappingKey({ + mapping, + }) === targetKey; + if (isTarget) { + removed = true; + } + return !isTarget; + }); + return { removed, mappings: next }; +} + type RecordedSyncConflict = { readonly field: string; readonly authority: "hack" | "linear"; @@ -1794,6 +2212,75 @@ function resolveLinearAssigneeMatch(input: { return match ?? null; } +function resolveExplicitLinearAssigneeMapping(input: { + readonly assigneeMappings: readonly LinearAssigneeMapping[]; + readonly profileId: string; + readonly teamId: string; + readonly localAssignee: string; +}): LinearAssigneeMapping | null { + const targetAssignee = input.localAssignee.trim().toLowerCase(); + const exactMatch = + input.assigneeMappings.find( + (mapping) => + mapping.profileId === input.profileId && + mapping.teamId === input.teamId && + mapping.localAssignee.trim().toLowerCase() === targetAssignee + ) ?? null; + if (exactMatch) { + return exactMatch; + } + return ( + input.assigneeMappings.find( + (mapping) => + mapping.profileId === input.profileId && + !readOptionalString(mapping.teamId) && + mapping.localAssignee.trim().toLowerCase() === targetAssignee + ) ?? null + ); +} + +function resolveLinearAssigneeFromMapping(input: { + readonly mapping: LinearAssigneeMapping; + readonly users: readonly LinearUser[]; +}): LinearUser | null { + if (input.mapping.linearUserId) { + const byId = + input.users.find((user) => user.id === input.mapping.linearUserId) ?? + null; + if (byId) { + return byId; + } + } + const email = readOptionalString( + input.mapping.linearUserEmail + )?.toLowerCase(); + if (email) { + const byEmail = + input.users.find( + (user) => readOptionalString(user.email)?.toLowerCase() === email + ) ?? null; + if (byEmail) { + return byEmail; + } + } + const displayName = readOptionalString( + input.mapping.linearUserName + )?.toLowerCase(); + if (displayName) { + const byName = + input.users.find((user) => { + const userDisplayName = readOptionalString( + user.displayName ?? user.name + )?.toLowerCase(); + return userDisplayName === displayName; + }) ?? null; + if (byName) { + return byName; + } + } + return null; +} + function buildConflictDedupKey(input: { readonly field: string; readonly localValue?: TicketMetadataValue; @@ -1977,7 +2464,11 @@ async function recordLinearSyncCheckpoint(input: { } async function resolveTicketAssigneeForLinear(input: { - readonly runtime: Pick; + readonly runtime: { + readonly assigneeMappings: readonly LinearAssigneeMapping[]; + readonly linear: Pick; + readonly profileId: string; + }; readonly ticket: TicketSummary; readonly teamId: string; }): Promise { @@ -1986,6 +2477,31 @@ async function resolveTicketAssigneeForLinear(input: { return { applied: false }; } + const explicitMapping = resolveExplicitLinearAssigneeMapping({ + assigneeMappings: input.runtime.assigneeMappings, + profileId: input.runtime.profileId, + teamId: input.teamId, + localAssignee: requestedAssignee, + }); + if (explicitMapping?.linearUserId) { + return { + requestedAssignee, + matchedUserId: explicitMapping.linearUserId, + ...(readOptionalString( + explicitMapping.linearUserName ?? explicitMapping.linearUserEmail + ) + ? { + matchedUserDisplayName: + readOptionalString( + explicitMapping.linearUserName ?? + explicitMapping.linearUserEmail + ) ?? undefined, + } + : {}), + applied: true, + }; + } + const users = await input.runtime.linear.listTeamUsers({ teamId: input.teamId, }); @@ -1995,10 +2511,17 @@ async function resolveTicketAssigneeForLinear(input: { applied: false, }; } - const match = resolveLinearAssigneeMatch({ - assignee: requestedAssignee, - users: users.data, - }); + const match = + (explicitMapping + ? resolveLinearAssigneeFromMapping({ + mapping: explicitMapping, + users: users.data, + }) + : null) ?? + resolveLinearAssigneeMatch({ + assignee: requestedAssignee, + users: users.data, + }); return { requestedAssignee, ...(match?.id ? { matchedUserId: match.id } : {}), @@ -2218,7 +2741,10 @@ async function syncTicketToLinearIssue(input: { } async function resolveLinearTargetForTicketSync(input: { - readonly runtime: SyncRuntime; + readonly runtime: { + readonly linear: Pick; + readonly projectBinding: ReturnType; + }; readonly ticket: TicketSummary; readonly existingIssue: LinearIssue | null; readonly explicitProjectId?: string; @@ -2277,7 +2803,15 @@ type LinearTicketMutationFields = { }; async function resolveLinearMutationFieldsForTicketSync(input: { - readonly runtime: SyncRuntime; + readonly runtime: { + readonly linear: Pick< + LinearSyncClient, + "listTeamLabels" | "listTeamStates" | "listTeamUsers" + >; + readonly tickets: Pick; + readonly profileId: string; + readonly assigneeMappings: readonly LinearAssigneeMapping[]; + }; readonly ticket: TicketSummary; readonly teamId: string; readonly syncToggles: SyncToggles; @@ -2342,7 +2876,9 @@ async function resolveLinearMutationFieldsForTicketSync(input: { } async function upsertLinearIssueForTicketSync(input: { - readonly runtime: SyncRuntime; + readonly runtime: { + readonly linear: Pick; + }; readonly ticket: TicketSummary; readonly existingIssue: LinearIssue | null; readonly fields: LinearTicketMutationFields; @@ -2944,7 +3480,12 @@ async function buildLinearDependencyIndex(input: { } async function resolveLinkedLinearIssue(input: { - readonly runtime: SyncRuntime; + readonly runtime: { + readonly linear: Pick< + LinearSyncClient, + "getIssueById" | "getIssueByIdentifier" + >; + }; readonly ticket: TicketSummary; }): Promise< | { readonly ok: true; readonly issue: LinearIssue | null } @@ -2977,7 +3518,9 @@ async function resolveLinkedLinearIssue(input: { } async function resolveLinearStateIdForTicketStatus(input: { - readonly runtime: SyncRuntime; + readonly runtime: { + readonly linear: Pick; + }; readonly teamId: string; readonly status: TicketStatus; readonly enabled: boolean; @@ -3005,7 +3548,9 @@ async function resolveLinearStateIdForTicketStatus(input: { } async function resolveLinearLabelIds(input: { - readonly runtime: SyncRuntime; + readonly runtime: { + readonly linear: Pick; + }; readonly teamId: string; readonly tags: readonly string[]; readonly enabled: boolean; @@ -3045,7 +3590,9 @@ async function resolveLinearLabelIds(input: { } async function resolveLinearParentIssueId(input: { - readonly runtime: SyncRuntime; + readonly runtime: { + readonly tickets: Pick; + }; readonly ticket: TicketSummary; readonly enabled: boolean; }): Promise< @@ -4450,6 +4997,130 @@ function parseApplyDeliveryArgs(input: { }; } +function parseAssigneeMappingsArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: AssigneeMappingsArgs } + | { readonly ok: false; readonly error: string } { + const value: AssigneeMappingsArgs = { json: false }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "team-id": "teamId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + +function parseUpsertAssigneeMappingArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: UpsertAssigneeMappingArgs } + | { readonly ok: false; readonly error: string } { + const value: UpsertAssigneeMappingArgs = { json: false }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "team-id": "teamId", + "local-assignee": "localAssignee", + "linear-user-id": "linearUserId", + "linear-user-name": "linearUserName", + "linear-user-email": "linearUserEmail", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + + if (!readOptionalString(value.localAssignee)) { + return { + ok: false, + error: "Missing --local-assignee .", + }; + } + if ( + !( + readOptionalString(value.linearUserId) || + readOptionalString(value.linearUserName) || + readOptionalString(value.linearUserEmail) + ) + ) { + return { + ok: false, + error: + "Missing Linear user target. Pass --linear-user-id, --linear-user-name, or --linear-user-email.", + }; + } + + return { ok: true, value }; +} + +function parseRemoveAssigneeMappingArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: RemoveAssigneeMappingArgs } + | { readonly ok: false; readonly error: string } { + const value: RemoveAssigneeMappingArgs = { json: false }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "team-id": "teamId", + "local-assignee": "localAssignee", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + + if (!readOptionalString(value.localAssignee)) { + return { + ok: false, + error: "Missing --local-assignee .", + }; + } + + return { ok: true, value }; +} + function parseSetupArgs(input: { readonly args: readonly string[]; }): @@ -4949,15 +5620,19 @@ export const __testOnly = { buildOAuthArgsFromConnectArgs, detectAuthoritativeFieldConflicts, parseApplyDeliveryArgs, + parseAssigneeMappingsArgs, parseConnectArgs, parseDeliveriesArgs, parseProjectBindArgs, parseProjectsArgs, + parseRemoveAssigneeMappingArgs, resolveOAuthBrokerRuntimeConfig, + resolveTicketAssigneeForLinear, parseSetupArgs, parseStatusArgs, parseSyncIssueArgs, parseSyncProjectArgs, + parseUpsertAssigneeMappingArgs, selectLinearCommentsToAppend, selectTicketCommentsToPush, shouldFallbackConnectToOAuth, diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts index 2b5dbf925..cfec75171 100644 --- a/src/control-plane/extensions/tickets/commands.ts +++ b/src/control-plane/extensions/tickets/commands.ts @@ -1520,16 +1520,17 @@ function parseResolveConflictArgs(opts: { continue; } if (token.startsWith("--resolution=")) { - resolution = parseConflictResolutionValue({ + const parsedResolution = parseConflictResolutionValue({ value: token.slice("--resolution=".length), }); - if (!resolution) { + if (!parsedResolution) { return { ok: false, error: "Invalid --resolution value. Expected accept_local|accept_remote|merged|ignore.", }; } + resolution = parsedResolution; continue; } if (token === "--resolution") { @@ -1537,14 +1538,15 @@ function parseResolveConflictArgs(opts: { if (!value) { return { ok: false, error: "--resolution requires a value." }; } - resolution = parseConflictResolutionValue({ value }); - if (!resolution) { + const parsedResolution = parseConflictResolutionValue({ value }); + if (!parsedResolution) { return { ok: false, error: "Invalid --resolution value. Expected accept_local|accept_remote|merged|ignore.", }; } + resolution = parsedResolution; i += 1; continue; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 01b88d824..ab946dc21 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -998,6 +998,130 @@ function registerTools(opts: { readonly server: McpServer }): void { }); } ); + + opts.server.registerTool( + "hack.linear.assignee-mappings.list", + { + title: "List Linear assignee mappings", + description: + "List explicit local-assignee to Linear-user mappings for the selected profile/team.", + inputSchema: { + ...linearCommonInput, + teamId: z.string().describe("Optional Linear team filter").optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const args = [ + "linear", + "assignee-mappings", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.assignee-mappings.list", + args, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.assignee-mappings.upsert", + { + title: "Upsert Linear assignee mapping", + description: + "Create or replace an explicit local-assignee to Linear-user mapping.", + inputSchema: { + ...linearCommonInput, + teamId: z.string().describe("Optional Linear team scope").optional(), + localAssignee: z + .string() + .describe("Local assignee label or handle to match against tickets"), + linearUserId: z.string().describe("Linear user id").optional(), + linearUserName: z + .string() + .describe("Linear user display name fallback") + .optional(), + linearUserEmail: z + .string() + .describe("Linear user email fallback") + .optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const args = [ + "linear", + "set-assignee-mapping", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + "--local-assignee", + input.localAssignee, + ...(input.linearUserId ? ["--linear-user-id", input.linearUserId] : []), + ...(input.linearUserName + ? ["--linear-user-name", input.linearUserName] + : []), + ...(input.linearUserEmail + ? ["--linear-user-email", input.linearUserEmail] + : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.assignee-mappings.upsert", + args, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.assignee-mappings.remove", + { + title: "Remove Linear assignee mapping", + description: "Remove an explicit local-assignee to Linear-user mapping.", + inputSchema: { + ...linearCommonInput, + teamId: z.string().describe("Optional Linear team scope").optional(), + localAssignee: z + .string() + .describe("Local assignee label or handle to remove"), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const args = [ + "linear", + "remove-assignee-mapping", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + "--local-assignee", + input.localAssignee, + ]; + + const result = await runHackCommand({ + tool: "hack.linear.assignee-mappings.remove", + args, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); } function buildToolResult(opts: { diff --git a/tests/linear-client.test.ts b/tests/linear-client.test.ts index 19833f56f..3cc606bb6 100644 --- a/tests/linear-client.test.ts +++ b/tests/linear-client.test.ts @@ -9,9 +9,15 @@ afterEach(() => { }); test("getIssueByIdentifier includes assignee data when present", async () => { - let requestBody: Record | null = null; - globalThis.fetch = async (_input, init) => { - requestBody = JSON.parse(String(init?.body)) as Record; + let requestBody: { + readonly query?: unknown; + readonly variables?: unknown; + } | null = null; + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as { + readonly query?: unknown; + readonly variables?: unknown; + }; return new Response( JSON.stringify({ data: { @@ -51,7 +57,7 @@ test("getIssueByIdentifier includes assignee data when present", async () => { }), { status: 200 } ); - }; + }) as typeof fetch; const client = createLinearClient({ token: "linear-token" }); const result = await client.getIssueByIdentifier({ identifier: "ENG-123" }); @@ -61,7 +67,9 @@ test("getIssueByIdentifier includes assignee data when present", async () => { return; } - expect(String(requestBody?.query)).toContain("assignee {"); + expect( + String((requestBody as { readonly query?: unknown } | null)?.query ?? "") + ).toContain("assignee {"); expect(result.data?.assigneeId).toBe("user_123"); expect(result.data?.assigneeName).toBe("Alice Example"); expect(result.data?.assigneeDisplayName).toBe("Alice"); @@ -70,9 +78,15 @@ test("getIssueByIdentifier includes assignee data when present", async () => { }); test("listTeamUsers returns membership users for assignee mapping", async () => { - let requestBody: Record | null = null; - globalThis.fetch = async (_input, init) => { - requestBody = JSON.parse(String(init?.body)) as Record; + let requestBody: { + readonly query?: unknown; + readonly variables?: unknown; + } | null = null; + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as { + readonly query?: unknown; + readonly variables?: unknown; + }; return new Response( JSON.stringify({ data: { @@ -103,7 +117,7 @@ test("listTeamUsers returns membership users for assignee mapping", async () => }), { status: 200 } ); - }; + }) as typeof fetch; const client = createLinearClient({ token: "linear-token" }); const result = await client.listTeamUsers({ teamId: "team_123" }); @@ -113,7 +127,9 @@ test("listTeamUsers returns membership users for assignee mapping", async () => return; } - expect(String(requestBody?.query)).toContain("memberships"); + expect( + String((requestBody as { readonly query?: unknown } | null)?.query ?? "") + ).toContain("memberships"); expect(result.data).toEqual([ { id: "user_123", @@ -132,9 +148,15 @@ test("listTeamUsers returns membership users for assignee mapping", async () => }); test("listIssueComments parses issue comments", async () => { - let requestBody: Record | null = null; - globalThis.fetch = async (_input, init) => { - requestBody = JSON.parse(String(init?.body)) as Record; + let requestBody: { + readonly query?: unknown; + readonly variables?: unknown; + } | null = null; + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as { + readonly query?: unknown; + readonly variables?: unknown; + }; return new Response( JSON.stringify({ data: { @@ -160,7 +182,7 @@ test("listIssueComments parses issue comments", async () => { }), { status: 200 } ); - }; + }) as typeof fetch; const client = createLinearClient({ token: "linear-token" }); const result = await client.listIssueComments({ issueId: "issue_123" }); @@ -170,7 +192,9 @@ test("listIssueComments parses issue comments", async () => { return; } - expect(String(requestBody?.query)).toContain("comments("); + expect( + String((requestBody as { readonly query?: unknown } | null)?.query ?? "") + ).toContain("comments("); expect(result.data).toEqual([ { id: "comment_123", @@ -186,9 +210,15 @@ test("listIssueComments parses issue comments", async () => { }); test("createComment returns created comment payload", async () => { - let requestBody: Record | null = null; - globalThis.fetch = async (_input, init) => { - requestBody = JSON.parse(String(init?.body)) as Record; + let requestBody: { + readonly query?: unknown; + readonly variables?: unknown; + } | null = null; + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as { + readonly query?: unknown; + readonly variables?: unknown; + }; return new Response( JSON.stringify({ data: { @@ -209,7 +239,7 @@ test("createComment returns created comment payload", async () => { }), { status: 200 } ); - }; + }) as typeof fetch; const client = createLinearClient({ token: "linear-token" }); const result = await client.createComment({ @@ -222,8 +252,12 @@ test("createComment returns created comment payload", async () => { return; } - expect(String(requestBody?.query)).toContain("commentCreate"); - expect(requestBody?.variables).toEqual({ + expect( + String((requestBody as { readonly query?: unknown } | null)?.query ?? "") + ).toContain("commentCreate"); + expect( + (requestBody as { readonly variables?: unknown } | null)?.variables + ).toEqual({ input: { issueId: "issue_123", body: "Ship it", @@ -240,9 +274,15 @@ test("createComment returns created comment payload", async () => { }); test("updateIssue forwards assigneeId when provided", async () => { - let requestBody: Record | null = null; - globalThis.fetch = async (_input, init) => { - requestBody = JSON.parse(String(init?.body)) as Record; + let requestBody: { + readonly query?: unknown; + readonly variables?: unknown; + } | null = null; + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as { + readonly query?: unknown; + readonly variables?: unknown; + }; return new Response( JSON.stringify({ data: { @@ -276,7 +316,7 @@ test("updateIssue forwards assigneeId when provided", async () => { }), { status: 200 } ); - }; + }) as typeof fetch; const client = createLinearClient({ token: "linear-token" }); const result = await client.updateIssue({ @@ -290,7 +330,9 @@ test("updateIssue forwards assigneeId when provided", async () => { return; } - expect(requestBody?.variables).toEqual({ + expect( + (requestBody as { readonly variables?: unknown } | null)?.variables + ).toEqual({ id: "issue_123", input: { title: "Ship assignee sync", diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index e82b91b19..7cf1a91a5 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -251,6 +251,84 @@ test("parseApplyDeliveryArgs requires a delivery id", () => { expect(parsed.error).toContain("--delivery-id"); }); +test("parseAssigneeMappingsArgs parses profile and team filters", () => { + const parsed = __testOnly.parseAssigneeMappingsArgs({ + args: ["--profile", "work", "--team-id", "team-1", "--json"], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + teamId: "team-1", + json: true, + }); +}); + +test("parseUpsertAssigneeMappingArgs requires a local assignee and remote target", () => { + const parsed = __testOnly.parseUpsertAssigneeMappingArgs({ + args: ["--profile", "work", "--local-assignee", "alice@hack"], + }); + + expect(parsed.ok).toBe(false); + if (parsed.ok) { + return; + } + + expect(parsed.error).toContain("--linear-user-id"); +}); + +test("parseUpsertAssigneeMappingArgs parses explicit mapping fields", () => { + const parsed = __testOnly.parseUpsertAssigneeMappingArgs({ + args: [ + "--profile", + "work", + "--team-id", + "team-1", + "--local-assignee", + "alice@hack", + "--linear-user-id", + "user-1", + "--linear-user-name", + "Alice Example", + "--linear-user-email", + "alice@example.com", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + teamId: "team-1", + localAssignee: "alice@hack", + linearUserId: "user-1", + linearUserName: "Alice Example", + linearUserEmail: "alice@example.com", + json: true, + }); +}); + +test("parseRemoveAssigneeMappingArgs requires a local assignee", () => { + const parsed = __testOnly.parseRemoveAssigneeMappingArgs({ + args: ["--profile", "work"], + }); + + expect(parsed.ok).toBe(false); + if (parsed.ok) { + return; + } + + expect(parsed.error).toContain("--local-assignee"); +}); + test("detectAuthoritativeFieldConflicts reports divergence for hack-owned tickets", () => { const conflicts = __testOnly.detectAuthoritativeFieldConflicts({ authority: "hack", @@ -373,6 +451,57 @@ test("selectTicketCommentsToPush keeps unsynced local comments even when bodies expect(selected.map((comment) => comment.commentId)).toEqual(["comment-2"]); }); +test("resolveTicketAssigneeForLinear prefers explicit mappings before fuzzy user matching", async () => { + const resolution = await __testOnly.resolveTicketAssigneeForLinear({ + runtime: { + profileId: "work", + assigneeMappings: [ + { + profileId: "work", + teamId: "team-1", + localAssignee: "backend-owner", + linearUserId: "user-2", + linearUserName: "Backend Owner", + }, + ], + linear: { + listTeamUsers: async () => ({ + ok: true as const, + data: [ + { + id: "user-1", + email: "backend-owner@example.com", + displayName: "Wrong Match", + }, + ], + }), + }, + }, + ticket: { + ticketId: "T-00001", + title: "Ship explicit assignee mappings", + body: "", + status: "open", + createdAt: "2026-03-05T10:00:00.000Z", + updatedAt: "2026-03-05T10:00:00.000Z", + dependsOn: [], + blocks: [], + owner: "hack", + source: "hack", + assignee: "backend-owner", + tags: [], + }, + teamId: "team-1", + }); + + expect(resolution).toEqual({ + requestedAssignee: "backend-owner", + matchedUserId: "user-2", + matchedUserDisplayName: "Backend Owner", + applied: true, + }); +}); + test("syncIssueFromLinearToTicket preserves hack authority, appends comments, and records checkpoints", async () => { const conflicts: string[] = []; const appendedBodies: string[] = []; @@ -467,7 +596,7 @@ test("syncIssueFromLinearToTicket preserves hack authority, appends comments, an ticketId: "T-00001", provider: "linear", field: input.field, - status: "open", + status: "open" as const, createdAt: "2026-03-05T10:10:00.000Z", updatedAt: "2026-03-05T10:10:00.000Z", }, @@ -556,6 +685,7 @@ test("syncTicketToLinearIssue pushes missing local comments and records a checkp projectBinding: { teamId: "team-1", }, + assigneeMappings: [], tickets: { getTicket: async () => ({ ticketId: "T-00001", diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index cb2b0604d..b39a81bfc 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -43,6 +43,9 @@ testIntegration( expect(names).toContain("hack.linear.sync.project"); expect(names).toContain("hack.linear.deliveries.list"); expect(names).toContain("hack.linear.deliveries.apply"); + expect(names).toContain("hack.linear.assignee-mappings.list"); + expect(names).toContain("hack.linear.assignee-mappings.upsert"); + expect(names).toContain("hack.linear.assignee-mappings.remove"); } ); @@ -146,6 +149,110 @@ testIntegration( } ); +testIntegration( + "hack.linear.assignee-mappings.list forwards profile and team filters", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.assignee-mappings.list", + arguments: { + profile: "work", + teamId: "team-1", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + profileId: "work", + teamId: "team-1", + mappings: [ + { + profileId: "work", + teamId: "team-1", + localAssignee: "alice@hack", + linearUserId: "user-1", + }, + ], + }); + } +); + +testIntegration( + "hack.linear.assignee-mappings.upsert forwards explicit mapping fields", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.assignee-mappings.upsert", + arguments: { + profile: "work", + teamId: "team-1", + localAssignee: "alice@hack", + linearUserId: "user-1", + linearUserName: "Alice Example", + linearUserEmail: "alice@example.com", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + upserted: true, + replacedExisting: false, + mapping: { + profileId: "work", + teamId: "team-1", + localAssignee: "alice@hack", + linearUserId: "user-1", + linearUserName: "Alice Example", + linearUserEmail: "alice@example.com", + }, + }); + } +); + +testIntegration( + "hack.linear.assignee-mappings.remove forwards mapping identity", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.assignee-mappings.remove", + arguments: { + profile: "work", + teamId: "team-1", + localAssignee: "alice@hack", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + removed: true, + profileId: "work", + teamId: "team-1", + localAssignee: "alice@hack", + }); + } +); + async function startMcpClient(): Promise { tempDir = await mkdtemp(join(tmpdir(), "hack-mcp-")); const homeDir = join(tempDir, "home"); @@ -205,6 +312,40 @@ function buildHackStubScript(): string { ' console.log(JSON.stringify({ deliveryId, status: "applied" }))', " process.exit(0)", " }", + ' if (sub === "assignee-mappings") {', + ' const profileIndex = args.indexOf("--profile")', + ' const teamIndex = args.indexOf("--team-id")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', + " const teamId = teamIndex === -1 ? null : args[teamIndex + 1]", + ' console.log(JSON.stringify({ profileId, teamId, mappings: [{ profileId, teamId, localAssignee: "alice@hack", linearUserId: "user-1" }] }))', + " process.exit(0)", + " }", + ' if (sub === "set-assignee-mapping") {', + ' const profileIndex = args.indexOf("--profile")', + ' const teamIndex = args.indexOf("--team-id")', + ' const localIndex = args.indexOf("--local-assignee")', + ' const userIdIndex = args.indexOf("--linear-user-id")', + ' const userNameIndex = args.indexOf("--linear-user-name")', + ' const userEmailIndex = args.indexOf("--linear-user-email")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', + " const teamId = teamIndex === -1 ? null : args[teamIndex + 1]", + " const localAssignee = localIndex === -1 ? null : args[localIndex + 1]", + " const linearUserId = userIdIndex === -1 ? null : args[userIdIndex + 1]", + " const linearUserName = userNameIndex === -1 ? null : args[userNameIndex + 1]", + " const linearUserEmail = userEmailIndex === -1 ? null : args[userEmailIndex + 1]", + " console.log(JSON.stringify({ upserted: true, replacedExisting: false, mapping: { profileId, teamId, localAssignee, linearUserId, linearUserName, linearUserEmail } }))", + " process.exit(0)", + " }", + ' if (sub === "remove-assignee-mapping") {', + ' const profileIndex = args.indexOf("--profile")', + ' const teamIndex = args.indexOf("--team-id")', + ' const localIndex = args.indexOf("--local-assignee")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', + " const teamId = teamIndex === -1 ? null : args[teamIndex + 1]", + " const localAssignee = localIndex === -1 ? null : args[localIndex + 1]", + " console.log(JSON.stringify({ removed: true, profileId, teamId, localAssignee }))", + " process.exit(0)", + " }", "}", "console.error(`unknown command: ${cmd}`)", "process.exit(1)", diff --git a/tests/tickets-store.test.ts b/tests/tickets-store.test.ts index 452d9a7e4..e7606f0a3 100644 --- a/tests/tickets-store.test.ts +++ b/tests/tickets-store.test.ts @@ -47,162 +47,158 @@ afterEach(async () => { } }); -test( - "tickets store materializes assignee, comments, checkpoints, and conflicts", - { timeout: 20_000 }, - async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-store-", - }); - const store = await createStore({ projectRoot }); - - const created = await store.createTicket({ - title: "Sync metadata ticket", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } +test("tickets store materializes assignee, comments, checkpoints, and conflicts", async () => { + const projectRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-store-", + }); + const store = await createStore({ projectRoot }); - const ticketId = created.ticket.ticketId; - - const updated = await store.updateTicket({ - ticketId, - assignee: "alice@hack", - actor: "router@hack", - }); - expect(updated.ok).toBe(true); - - const firstComment = await store.appendComment({ - ticketId, - body: "Imported from Linear.", - source: "linear", - externalId: "comment-1", - actor: "linear@app", - }); - expect(firstComment.ok).toBe(true); - if (!firstComment.ok) { - throw new Error(firstComment.error); - } + const created = await store.createTicket({ + title: "Sync metadata ticket", + owner: "hack", + source: "hack", + actor: "creator@hack", + }); + expect(created.ok).toBe(true); + if (!created.ok) { + throw new Error(created.error); + } - const secondComment = await store.appendComment({ - ticketId, - body: "Local follow-up.", - source: "hack", - actor: "alice@hack", - }); - expect(secondComment.ok).toBe(true); - if (!secondComment.ok) { - throw new Error(secondComment.error); - } + const ticketId = created.ticket.ticketId; - const linkedComment = await store.linkCommentExternalId({ - ticketId, - commentId: secondComment.comment.commentId, - externalId: "linear-comment-2", - externalUrl: "https://linear.app/issue/HACK-1#comment-2", - actor: "sync@app", - }); - expect(linkedComment.ok).toBe(true); - - const checkpoint = await store.recordSyncCheckpoint({ - ticketId, - provider: "linear", - profileId: "default", - direction: "pull", - remoteCursor: "issue/LIN-123#v2", - remoteUpdatedAt: "2026-03-05T18:15:00.000Z", - actor: "sync@app", - }); - expect(checkpoint.ok).toBe(true); - if (!checkpoint.ok) { - throw new Error(checkpoint.error); - } + const updated = await store.updateTicket({ + ticketId, + assignee: "alice@hack", + actor: "router@hack", + }); + expect(updated.ok).toBe(true); - const conflict = await store.recordSyncConflict({ - ticketId, - provider: "linear", - field: "assignee", - localValue: "alice@hack", - remoteValue: "bob@linear", - authority: "origin", - summary: "Assignee diverged during pull.", - actor: "sync@app", - }); - expect(conflict.ok).toBe(true); - if (!conflict.ok) { - throw new Error(conflict.error); - } + const firstComment = await store.appendComment({ + ticketId, + body: "Imported from Linear.", + source: "linear", + externalId: "comment-1", + actor: "linear@app", + }); + expect(firstComment.ok).toBe(true); + if (!firstComment.ok) { + throw new Error(firstComment.error); + } + + const secondComment = await store.appendComment({ + ticketId, + body: "Local follow-up.", + source: "hack", + actor: "alice@hack", + }); + expect(secondComment.ok).toBe(true); + if (!secondComment.ok) { + throw new Error(secondComment.error); + } + + const linkedComment = await store.linkCommentExternalId({ + ticketId, + commentId: secondComment.comment.commentId, + externalId: "linear-comment-2", + externalUrl: "https://linear.app/issue/HACK-1#comment-2", + actor: "sync@app", + }); + expect(linkedComment.ok).toBe(true); - const resolved = await store.resolveSyncConflict({ - ticketId, - conflictId: conflict.conflict.conflictId, - resolution: "accept_remote", - summary: "Linear remains source of truth for this field.", - actor: "alice@hack", - }); - expect(resolved.ok).toBe(true); - - const snapshot = await store.readSnapshot(); - const ticket = snapshot.tickets.find((item) => item.ticketId === ticketId); - expect(ticket?.assignee).toBe("alice@hack"); - - const comments = snapshot.commentsByTicket.get(ticketId) ?? []; - expect(comments).toHaveLength(2); - expect(comments.map((item) => item.body)).toEqual([ - "Imported from Linear.", - "Local follow-up.", - ]); - expect(comments[0]).toMatchObject({ - source: "linear", - externalId: "comment-1", - actor: "linear@app", - }); - expect(comments[1]).toMatchObject({ - source: "hack", - externalId: "linear-comment-2", - externalUrl: "https://linear.app/issue/HACK-1#comment-2", - }); - - const checkpoints = snapshot.syncCheckpointsByTicket.get(ticketId) ?? []; - expect(checkpoints).toHaveLength(1); - expect(checkpoints[0]).toMatchObject({ - provider: "linear", - profileId: "default", - direction: "pull", - remoteCursor: "issue/LIN-123#v2", - remoteUpdatedAt: "2026-03-05T18:15:00.000Z", - }); - - const conflicts = snapshot.conflictsByTicket.get(ticketId) ?? []; - expect(conflicts).toHaveLength(1); - expect(conflicts[0]).toMatchObject({ - provider: "linear", - field: "assignee", - status: "resolved", - authority: "origin", - localValue: "alice@hack", - remoteValue: "bob@linear", - resolution: "accept_remote", - resolutionSummary: "Linear remains source of truth for this field.", - }); - - const events = await store.listEvents({ ticketId }); - expect(events.map((item) => item.type)).toEqual([ - "ticket.created", - "ticket.updated", - "ticket.comment_appended", - "ticket.comment_appended", - "ticket.comment_linked", - "ticket.sync_checkpoint_recorded", - "ticket.sync_conflict_recorded", - "ticket.sync_conflict_resolved", - ]); + const checkpoint = await store.recordSyncCheckpoint({ + ticketId, + provider: "linear", + profileId: "default", + direction: "pull", + remoteCursor: "issue/LIN-123#v2", + remoteUpdatedAt: "2026-03-05T18:15:00.000Z", + actor: "sync@app", + }); + expect(checkpoint.ok).toBe(true); + if (!checkpoint.ok) { + throw new Error(checkpoint.error); } -); + + const conflict = await store.recordSyncConflict({ + ticketId, + provider: "linear", + field: "assignee", + localValue: "alice@hack", + remoteValue: "bob@linear", + authority: "origin", + summary: "Assignee diverged during pull.", + actor: "sync@app", + }); + expect(conflict.ok).toBe(true); + if (!conflict.ok) { + throw new Error(conflict.error); + } + + const resolved = await store.resolveSyncConflict({ + ticketId, + conflictId: conflict.conflict.conflictId, + resolution: "accept_remote", + summary: "Linear remains source of truth for this field.", + actor: "alice@hack", + }); + expect(resolved.ok).toBe(true); + + const snapshot = await store.readSnapshot(); + const ticket = snapshot.tickets.find((item) => item.ticketId === ticketId); + expect(ticket?.assignee).toBe("alice@hack"); + + const comments = snapshot.commentsByTicket.get(ticketId) ?? []; + expect(comments).toHaveLength(2); + expect(comments.map((item) => item.body)).toEqual([ + "Imported from Linear.", + "Local follow-up.", + ]); + expect(comments[0]).toMatchObject({ + source: "linear", + externalId: "comment-1", + actor: "linear@app", + }); + expect(comments[1]).toMatchObject({ + source: "hack", + externalId: "linear-comment-2", + externalUrl: "https://linear.app/issue/HACK-1#comment-2", + }); + + const checkpoints = snapshot.syncCheckpointsByTicket.get(ticketId) ?? []; + expect(checkpoints).toHaveLength(1); + expect(checkpoints[0]).toMatchObject({ + provider: "linear", + profileId: "default", + direction: "pull", + remoteCursor: "issue/LIN-123#v2", + remoteUpdatedAt: "2026-03-05T18:15:00.000Z", + }); + + const conflicts = snapshot.conflictsByTicket.get(ticketId) ?? []; + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ + provider: "linear", + field: "assignee", + status: "resolved", + authority: "origin", + localValue: "alice@hack", + remoteValue: "bob@linear", + resolution: "accept_remote", + resolutionSummary: "Linear remains source of truth for this field.", + }); + + const events = await store.listEvents({ ticketId }); + expect(events.map((item) => item.type)).toEqual([ + "ticket.created", + "ticket.updated", + "ticket.comment_appended", + "ticket.comment_appended", + "ticket.comment_linked", + "ticket.sync_checkpoint_recorded", + "ticket.sync_conflict_recorded", + "ticket.sync_conflict_resolved", + ]); +}); test("tickets show json includes materialized sync metadata", async () => { const projectRoot = await createTempGitProject({ From 7a5637f848dfd14587e5e419d7ad64295fb9198e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 5 Mar 2026 20:47:59 -0500 Subject: [PATCH 04/54] Start Linear phase 3 ownership schema work --- ...-03-06-linear-phase-3-completion-design.md | 108 +++++++++++ ...26-03-06-linear-phase-3-completion-plan.md | 168 ++++++++++++++++++ packages/db/src/schema/core.ts | 4 + packages/db/tests/smoke.test.ts | 8 + services/auth-broker/src/db/schema.ts | 4 + .../src/modules/linear-agent/plugin.ts | 2 + .../src/modules/linear-connections/service.ts | 111 ++++++++++-- .../src/modules/linear-sync-store/service.ts | 88 +++++++-- services/auth-broker/tests/index.test.ts | 136 +++++++++++++- .../tests/linear-ownership-compat.test.ts | 161 +++++++++++++++++ 10 files changed, 767 insertions(+), 23 deletions(-) create mode 100644 docs/plans/2026-03-06-linear-phase-3-completion-design.md create mode 100644 docs/plans/2026-03-06-linear-phase-3-completion-plan.md create mode 100644 services/auth-broker/tests/linear-ownership-compat.test.ts diff --git a/docs/plans/2026-03-06-linear-phase-3-completion-design.md b/docs/plans/2026-03-06-linear-phase-3-completion-design.md new file mode 100644 index 000000000..f288c1e60 --- /dev/null +++ b/docs/plans/2026-03-06-linear-phase-3-completion-design.md @@ -0,0 +1,108 @@ +# Linear Phase 3 Completion Design + +## Goal +Finish the remaining production gaps in the Linear integration: team-scoped broker access control, first-class ownership persistence, shared repo-backed review notes, and opt-in project autosync built on the existing manual-first workflow. + +## Existing State +- Linear OAuth, token refresh, connection persistence, webhook verification, and pending-delivery persistence are live in `services/auth-broker`. +- Broker ownership is currently user-scoped or active-organization-scoped, but team-level enforcement is not implemented. +- Organization ownership is stored pragmatically inside connection metadata and webhook payload envelopes rather than dedicated DB columns. +- The macOS app has a real review queue and local-only review notes, but those notes do not travel with git-backed ticket history. +- Manual sync remains the source of truth for applying Linear changes. Webhooks produce pending deliveries but are not auto-applied. + +## Approved Direction +Review notes should be repo-shared ticket events, not broker-side records. + +That yields a clean boundary: +- Repo-shared ticket state: + - conflict records + - conflict resolutions + - review notes + - manual apply/audit notes +- Broker-scoped integration state: + - OAuth connections + - webhook deliveries + - assignee mappings + - autosync subscriptions + - user/org/team ownership metadata + +Review notes must stay distinct from synced ticket comments. They should be append-only Hack review events and must not post to Linear unless we explicitly add that later. + +## Design Decisions + +### 1. Team-scoped broker authz +Broker Linear routes should move from: +- unauthenticated/public metadata for provider discovery +- authenticated user/org ownership for connection and delivery records + +to: +- user ownership fallback only when no org/team scope exists +- org ownership when the active Better Auth organization is present +- team ownership enforcement for routes that list or mutate connection/delivery state when a record is team-scoped + +This should apply to: +- `/v1/auth/linear/connections` +- `/v1/auth/linear/deliveries` +- `/v1/auth/linear/deliveries/:id/apply` +- future autosync subscription routes + +### 2. First-class ownership schema +Move ownership fields out of JSON blobs/envelopes and into dedicated persisted columns. + +Needed broker-side fields: +- `betterAuthOrganizationId` +- `betterAuthTeamId` + +Tables affected: +- `linear_connections` +- `linear_webhook_events` +- `linear_sync_subscriptions` +- `linear_assignee_mappings` should also support team scoping if not already first-class + +The auth-broker service still needs to remain deployable in Railway’s isolated Docker image, so the service-local DB schema/client must stay self-contained or copy-safe. + +### 3. Shared repo-backed review notes +Add a new append-only ticket event such as: +- `ticket.review_note_appended` + +Properties: +- repo-shared in the git-backed ticket history +- separate from synced comments +- includes author/source metadata and timestamp +- visible in ticket detail and review queue +- can optionally carry an `origin` or `context` like `manual_apply`, `conflict_review`, `assignee_resolution` + +This is the right place for human reconciliation notes and audit context around manual sync decisions. + +### 4. Project autosync +Autosync should remain opt-in and project-scoped. + +Model: +- webhook deliveries continue to land in the broker as pending events +- autosync subscription config enables automatic application for matching project/profile/team routes +- autosync uses the same sync engine as manual apply +- unresolved authority conflicts, ambiguous assignee mapping, or dependency translation failures should not silently overwrite; they should record review/conflict state and stop short of destructive mutation + +Autosync should preserve the manual-first contract: +- default off +- explicit per-project enablement +- observability into what was auto-applied vs queued for review + +## Execution Order +1. Promote ownership to first-class schema and data access +2. Enforce team-level authz using those first-class fields +3. Add shared repo-backed review notes and wire them into CLI/macOS review flow +4. Add project-scoped autosync subscriptions and worker/apply flow + +## Risks +- Schema changes in auth-broker must not break Railway deployment packaging. +- Team membership resolution depends on Better Auth session shape and may require careful compatibility handling. +- Repo-shared review notes must avoid being mistaken for synced ticket comments. +- Autosync can create noisy review state if the conflict boundary is too aggressive; keep it conservative. + +## Testing Expectations +- auth-broker route tests for user/org/team visibility and mutation rules +- auth-broker store tests for first-class ownership columns +- ticket store tests for review-note event materialization +- CLI tests for review-note append/list and autosync subscription/apply commands +- macOS model/UI tests for shared review-note rendering and autosync status surfaces diff --git a/docs/plans/2026-03-06-linear-phase-3-completion-plan.md b/docs/plans/2026-03-06-linear-phase-3-completion-plan.md new file mode 100644 index 000000000..354e26e53 --- /dev/null +++ b/docs/plans/2026-03-06-linear-phase-3-completion-plan.md @@ -0,0 +1,168 @@ +# Linear Phase 3 Completion Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Finish the remaining Linear production work by adding first-class ownership persistence, team-scoped broker authz, shared repo-backed review notes, and opt-in project autosync. + +**Architecture:** Keep git-backed tickets as the project source of truth and keep broker-only integration state in `services/auth-broker`. Promote ownership fields into first-class broker persistence, then enforce access from Better Auth session/org/team state. Add repo-shared review notes as new ticket events, and finally layer autosync on top of the existing pending-delivery/manual-apply engine. + +**Tech Stack:** Bun, TypeScript, Elysia, Better Auth, Drizzle/Neon, SwiftUI, git-backed ticket event log. + +--- + +### Task 1: Promote Linear ownership fields to first-class broker schema + +**Files:** +- Modify: `services/auth-broker/src/db/schema.ts` +- Modify: `services/auth-broker/src/db.ts` if needed +- Modify: `services/auth-broker/src/modules/linear-connections/service.ts` +- Modify: `services/auth-broker/src/modules/linear-sync-store/service.ts` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write the failing broker tests** +- Assert connection rows round-trip `betterAuthOrganizationId` and `betterAuthTeamId` as first-class fields. +- Assert webhook deliveries round-trip `betterAuthOrganizationId` and `betterAuthTeamId` as first-class fields. + +**Step 2: Run the focused broker tests to verify failure** +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: FAIL because the schema/service layer still pulls ownership from metadata/payload envelopes. + +**Step 3: Implement the minimal schema/service change** +- Add dedicated ownership columns to the local auth-broker schema. +- Read/write them directly in connection and webhook delivery services. +- Keep compatibility fallback for existing metadata/payload rows during rollout. + +**Step 4: Re-run the focused tests** +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: PASS + +**Step 5: Commit** +```bash +git add services/auth-broker/src/db.ts services/auth-broker/src/db/schema.ts services/auth-broker/src/modules/linear-connections/service.ts services/auth-broker/src/modules/linear-sync-store/service.ts services/auth-broker/tests/index.test.ts +git commit -m "feat(auth-broker): promote linear ownership fields" +``` + +### Task 2: Enforce team-scoped Better Auth access on Linear broker routes + +**Files:** +- Modify: `services/auth-broker/src/modules/better-auth/session.ts` +- Modify: `services/auth-broker/src/modules/linear-connections/plugin.ts` +- Modify: `services/auth-broker/src/modules/linear-sync-store/plugin.ts` +- Modify: `services/auth-broker/src/modules/providers/plugin.ts` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write the failing broker route tests** +- Active team membership can list/apply team-owned deliveries. +- User/session fallback still works for legacy user-owned records. +- Org-only sessions cannot mutate team-owned records outside membership. + +**Step 2: Run the focused broker tests to verify failure** +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: FAIL because only user/org scoping exists. + +**Step 3: Implement minimal team authz** +- Extend resolved Better Auth session shape with team membership identifiers. +- Filter connection/delivery lists by user, org, and team ownership in that order. +- Update provider metadata access mode to report the strongest active scope. + +**Step 4: Re-run the focused tests** +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: PASS + +**Step 5: Commit** +```bash +git add services/auth-broker/src/modules/better-auth/session.ts services/auth-broker/src/modules/linear-connections/plugin.ts services/auth-broker/src/modules/linear-sync-store/plugin.ts services/auth-broker/src/modules/providers/plugin.ts services/auth-broker/tests/index.test.ts +git commit -m "feat(auth-broker): enforce linear team access" +``` + +### Task 3: Add shared repo-backed review note events + +**Files:** +- Modify: `src/control-plane/extensions/tickets/store.ts` +- Modify: `src/control-plane/extensions/tickets/commands.ts` +- Modify: `src/control-plane/extensions/linear/commands.ts` +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift` +- Test: `tests/tickets-store.test.ts` +- Test: `apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift` + +**Step 1: Write the failing local ticket tests** +- `ticket.review_note_appended` materializes in ticket detail. +- Review notes remain distinct from synced ticket comments. + +**Step 2: Run the focused tests to verify failure** +Run: `bun test tests/tickets-store.test.ts && swift test --package-path apps/macos --filter TicketDetailReviewModelTests` +Expected: FAIL because review notes are still desktop-local state. + +**Step 3: Implement the minimal shared review-note path** +- Add a new append-only ticket event type. +- Add CLI command/helper to append review notes. +- Replace desktop-local review note storage with ticket-backed data. +- Keep review notes separate from Linear-synced comments. + +**Step 4: Re-run the focused tests** +Run: `bun test tests/tickets-store.test.ts && swift test --package-path apps/macos --filter TicketDetailReviewModelTests` +Expected: PASS + +**Step 5: Commit** +```bash +git add src/control-plane/extensions/tickets/store.ts src/control-plane/extensions/tickets/commands.ts src/control-plane/extensions/linear/commands.ts tests/tickets-store.test.ts apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift +git commit -m "feat(tickets): share linear review notes" +``` + +### Task 4: Add opt-in project autosync subscriptions and apply flow + +**Files:** +- Modify: `services/auth-broker/src/db/schema.ts` +- Modify: `services/auth-broker/src/modules/linear-sync-store/service.ts` +- Modify: `services/auth-broker/src/modules/linear-agent/plugin.ts` +- Modify: `src/control-plane/extensions/linear/commands.ts` +- Modify: `src/mcp/server.ts` +- Modify: `apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift` +- Test: `services/auth-broker/tests/index.test.ts` +- Test: `tests/linear-commands.test.ts` +- Test: `tests/mcp.test.ts` + +**Step 1: Write the failing autosync tests** +- Project subscription rows can be created/listed for a profile/project/team. +- Matching webhook deliveries can auto-apply when autosync is enabled. +- Conflict-producing deliveries remain pending/review-needed instead of silently overwriting. + +**Step 2: Run the focused tests to verify failure** +Run: `bun test --cwd services/auth-broker tests/index.test.ts && bun test tests/linear-commands.test.ts tests/mcp.test.ts` +Expected: FAIL because autosync subscriptions and auto-apply behavior do not exist. + +**Step 3: Implement the minimal autosync path** +- Add subscription persistence. +- Match pending deliveries to project/profile/team routes. +- Reuse the existing sync engine for auto-apply. +- Stop on conflict and record review state instead of forcing overwrite. + +**Step 4: Re-run the focused tests** +Run: `bun test --cwd services/auth-broker tests/index.test.ts && bun test tests/linear-commands.test.ts tests/mcp.test.ts` +Expected: PASS + +**Step 5: Commit** +```bash +git add services/auth-broker/src/db/schema.ts services/auth-broker/src/modules/linear-sync-store/service.ts services/auth-broker/src/modules/linear-agent/plugin.ts src/control-plane/extensions/linear/commands.ts src/mcp/server.ts apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift services/auth-broker/tests/index.test.ts tests/linear-commands.test.ts tests/mcp.test.ts +git commit -m "feat(linear): add project autosync" +``` + +### Task 5: Final verification + +**Files:** +- Verify only + +**Step 1: Run focused broker/macOS tests** +Run: `bun test --cwd services/auth-broker tests/index.test.ts && swift test --package-path apps/macos --filter TicketDetailReviewModelTests` +Expected: PASS + +**Step 2: Run full repo verification gates** +Run: `bun run build && bun run typecheck && bun run test && bun run check && hack ps` +Expected: PASS + +**Step 3: Verify auth-broker deployability** +Run: `railway up --service auth-broker -c` +Expected: successful deployment with `/health` returning `ok: true` diff --git a/packages/db/src/schema/core.ts b/packages/db/src/schema/core.ts index b2d9b59db..84f6f2f84 100644 --- a/packages/db/src/schema/core.ts +++ b/packages/db/src/schema/core.ts @@ -66,6 +66,8 @@ export const linearConnections = pgTable("linear_connections", { accountEmail: text("account_email"), authRef: text("auth_ref"), betterAuthUserId: text("better_auth_user_id"), + betterAuthOrganizationId: text("better_auth_organization_id"), + betterAuthTeamId: text("better_auth_team_id"), organizationId: text("organization_id"), teamId: text("team_id"), metadataJson: text("metadata_json").notNull().default("{}"), @@ -119,6 +121,8 @@ export const linearWebhookEvents = pgTable("linear_webhook_events", { applyError: text("apply_error"), claimedBy: text("claimed_by"), betterAuthUserId: text("better_auth_user_id"), + betterAuthOrganizationId: text("better_auth_organization_id"), + betterAuthTeamId: text("better_auth_team_id"), organizationId: text("organization_id"), ownerTeamId: text("owner_team_id"), createdAt: timestamp("created_at", { withTimezone: true }) diff --git a/packages/db/tests/smoke.test.ts b/packages/db/tests/smoke.test.ts index 7ebcc36b0..10b6aa4de 100644 --- a/packages/db/tests/smoke.test.ts +++ b/packages/db/tests/smoke.test.ts @@ -17,12 +17,20 @@ test("db package exports linear sync schema tables", () => { expect(linearConnections[Symbol.for("drizzle:Name")]).toBe( "linear_connections" ); + expect(linearConnections.betterAuthOrganizationId.name).toBe( + "better_auth_organization_id" + ); + expect(linearConnections.betterAuthTeamId.name).toBe("better_auth_team_id"); expect(linearAssigneeMappings[Symbol.for("drizzle:Name")]).toBe( "linear_assignee_mappings" ); expect(linearWebhookEvents[Symbol.for("drizzle:Name")]).toBe( "linear_webhook_events" ); + expect(linearWebhookEvents.betterAuthOrganizationId.name).toBe( + "better_auth_organization_id" + ); + expect(linearWebhookEvents.betterAuthTeamId.name).toBe("better_auth_team_id"); expect(linearSyncSubscriptions[Symbol.for("drizzle:Name")]).toBe( "linear_sync_subscriptions" ); diff --git a/services/auth-broker/src/db/schema.ts b/services/auth-broker/src/db/schema.ts index 38d1ec222..fb49aee2c 100644 --- a/services/auth-broker/src/db/schema.ts +++ b/services/auth-broker/src/db/schema.ts @@ -9,6 +9,8 @@ export const linearConnections = pgTable("linear_connections", { accountEmail: text("account_email"), authRef: text("auth_ref"), betterAuthUserId: text("better_auth_user_id"), + betterAuthOrganizationId: text("better_auth_organization_id"), + betterAuthTeamId: text("better_auth_team_id"), organizationId: text("organization_id"), teamId: text("team_id"), metadataJson: text("metadata_json").notNull().default("{}"), @@ -35,6 +37,8 @@ export const linearWebhookEvents = pgTable("linear_webhook_events", { applyError: text("apply_error"), claimedBy: text("claimed_by"), betterAuthUserId: text("better_auth_user_id"), + betterAuthOrganizationId: text("better_auth_organization_id"), + betterAuthTeamId: text("better_auth_team_id"), organizationId: text("organization_id"), ownerTeamId: text("owner_team_id"), createdAt: timestamp("created_at", { withTimezone: true }) diff --git a/services/auth-broker/src/modules/linear-agent/plugin.ts b/services/auth-broker/src/modules/linear-agent/plugin.ts index 360d7fb38..11a1303f1 100644 --- a/services/auth-broker/src/modules/linear-agent/plugin.ts +++ b/services/auth-broker/src/modules/linear-agent/plugin.ts @@ -190,6 +190,8 @@ async function persistWebhookDelivery(input: { ownership.status === "matched" ? ownership.betterAuthOrganizationId : null, + betterAuthTeamId: + ownership.status === "matched" ? ownership.betterAuthTeamId : null, organizationId: input.prepared.event.organizationId ?? ownership.organizationId ?? null, teamId: input.prepared.event.teamId ?? ownership.teamId ?? null, diff --git a/services/auth-broker/src/modules/linear-connections/service.ts b/services/auth-broker/src/modules/linear-connections/service.ts index 0309fde9f..12993db25 100644 --- a/services/auth-broker/src/modules/linear-connections/service.ts +++ b/services/auth-broker/src/modules/linear-connections/service.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { and, desc, eq, type SQL } from "drizzle-orm"; +import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { linearConnections } from "../../db/schema.ts"; import { createDbClient } from "../../db.ts"; @@ -17,6 +17,7 @@ export type LinearConnectionRecord = { readonly authRef: string | null; readonly betterAuthUserId: string | null; readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; readonly organizationId: string | null; readonly teamId: string | null; readonly metadata: LinearConnectionMetadata; @@ -32,6 +33,7 @@ export type UpsertLinearConnectionInput = { readonly authRef?: string | null; readonly betterAuthUserId?: string | null; readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; readonly organizationId?: string | null; readonly teamId?: string | null; readonly metadata?: LinearConnectionMetadata; @@ -42,6 +44,7 @@ export type ListLinearConnectionsInput = { readonly organizationId?: string | null; readonly betterAuthUserId?: string | null; readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; }; export type LinearWebhookOwnership = { @@ -49,6 +52,7 @@ export type LinearWebhookOwnership = { readonly profileId: string | null; readonly betterAuthUserId: string | null; readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; readonly organizationId: string | null; readonly teamId: string | null; readonly connectionId?: string; @@ -79,6 +83,7 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { const metadata = composeConnectionMetadata({ metadata: input.metadata, betterAuthOrganizationId: input.betterAuthOrganizationId, + betterAuthTeamId: input.betterAuthTeamId, }); const record: LinearConnectionRecord = { id: existing?.id ?? randomUUID(), @@ -90,6 +95,7 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { authRef: normalizeText(input.authRef), betterAuthUserId: normalizeText(input.betterAuthUserId), betterAuthOrganizationId: normalizeText(input.betterAuthOrganizationId), + betterAuthTeamId: normalizeText(input.betterAuthTeamId), organizationId: normalizeText(input.organizationId), teamId: normalizeText(input.teamId), metadata, @@ -133,6 +139,7 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { profileId: null, betterAuthUserId: null, betterAuthOrganizationId: null, + betterAuthTeamId: null, organizationId: null, teamId: null, }; @@ -150,14 +157,20 @@ export function createLinearConnectionStoreFromDb(input: { readonly databaseUrl: string; }): LinearConnectionStore { const db = createDbClient({ databaseUrl: input.databaseUrl }); + const ensureOwnershipColumns = createOwnershipColumnsEnsurer({ + db, + tableName: "linear_connections", + }); return { upsertConnection: async (connection) => { + await ensureOwnershipColumns(); const connectionKey = buildConnectionKey(connection); const now = new Date(); const metadataJson = JSON.stringify( composeConnectionMetadata({ metadata: connection.metadata, betterAuthOrganizationId: connection.betterAuthOrganizationId, + betterAuthTeamId: connection.betterAuthTeamId, }) ); const inserted = await db @@ -170,6 +183,10 @@ export function createLinearConnectionStoreFromDb(input: { accountEmail: normalizeText(connection.accountEmail), authRef: normalizeText(connection.authRef), betterAuthUserId: normalizeText(connection.betterAuthUserId), + betterAuthOrganizationId: normalizeText( + connection.betterAuthOrganizationId + ), + betterAuthTeamId: normalizeText(connection.betterAuthTeamId), organizationId: normalizeText(connection.organizationId), teamId: normalizeText(connection.teamId), metadataJson, @@ -184,6 +201,10 @@ export function createLinearConnectionStoreFromDb(input: { accountEmail: normalizeText(connection.accountEmail), authRef: normalizeText(connection.authRef), betterAuthUserId: normalizeText(connection.betterAuthUserId), + betterAuthOrganizationId: normalizeText( + connection.betterAuthOrganizationId + ), + betterAuthTeamId: normalizeText(connection.betterAuthTeamId), organizationId: normalizeText(connection.organizationId), teamId: normalizeText(connection.teamId), metadataJson, @@ -199,6 +220,7 @@ export function createLinearConnectionStoreFromDb(input: { }, listConnections: async (input = {}) => { + await ensureOwnershipColumns(); const filters = buildListFilters({ input }); const rows = filters.length === 0 @@ -217,6 +239,7 @@ export function createLinearConnectionStoreFromDb(input: { }, resolveWebhookOwnership: async ({ profileId, organizationId }) => { + await ensureOwnershipColumns(); const normalizedProfileId = normalizeText(profileId); if (normalizedProfileId) { const rows = await db @@ -240,6 +263,7 @@ export function createLinearConnectionStoreFromDb(input: { profileId: null, betterAuthUserId: null, betterAuthOrganizationId: null, + betterAuthTeamId: null, organizationId: null, teamId: null, }; @@ -331,6 +355,10 @@ function matchesConnectionFilter(input: { } else if (betterAuthOrganizationId) { return true; } + const betterAuthTeamId = normalizeText(input.filter.betterAuthTeamId); + if (betterAuthTeamId && input.record.betterAuthTeamId !== betterAuthTeamId) { + return false; + } const betterAuthUserId = normalizeText(input.filter.betterAuthUserId); if (betterAuthUserId && input.record.betterAuthUserId !== betterAuthUserId) { return false; @@ -338,9 +366,12 @@ function matchesConnectionFilter(input: { return true; } -function toConnectionRecord(input: { +export function toConnectionRecord(input: { readonly row: typeof linearConnections.$inferSelect; }): LinearConnectionRecord { + const storedOwnership = readStoredConnectionOwnership({ + raw: input.row.metadataJson, + }); return { id: input.row.id, connectionKey: input.row.connectionKey, @@ -350,9 +381,11 @@ function toConnectionRecord(input: { accountEmail: input.row.accountEmail ?? null, authRef: input.row.authRef ?? null, betterAuthUserId: input.row.betterAuthUserId ?? null, - betterAuthOrganizationId: parseStoredBetterAuthOrganizationId({ - raw: input.row.metadataJson, - }), + betterAuthOrganizationId: + input.row.betterAuthOrganizationId ?? + storedOwnership.betterAuthOrganizationId, + betterAuthTeamId: + input.row.betterAuthTeamId ?? storedOwnership.betterAuthTeamId, organizationId: input.row.organizationId ?? null, teamId: input.row.teamId ?? null, metadata: parseMetadata({ raw: input.row.metadataJson }), @@ -361,6 +394,8 @@ function toConnectionRecord(input: { }; } +export const materializeLinearConnectionRecord = toConnectionRecord; + function parseMetadata(input: { readonly raw: string; }): LinearConnectionMetadata { @@ -373,6 +408,7 @@ function parseMetadata(input: { ...(parsed as LinearConnectionMetadata), }; delete metadata[BETTER_AUTH_ORGANIZATION_METADATA_KEY]; + delete metadata[BETTER_AUTH_TEAM_METADATA_KEY]; return metadata; } catch { return {}; @@ -391,6 +427,7 @@ function toWebhookOwnership(input: { profileId: preferred.profileId, betterAuthUserId: preferred.betterAuthUserId, betterAuthOrganizationId: preferred.betterAuthOrganizationId, + betterAuthTeamId: preferred.betterAuthTeamId, organizationId: preferred.organizationId ?? input.fallbackOrganizationId, teamId: preferred.teamId, connectionId: preferred.id, @@ -402,6 +439,7 @@ function toWebhookOwnership(input: { profileId: null, betterAuthUserId: null, betterAuthOrganizationId: null, + betterAuthTeamId: null, organizationId: input.fallbackOrganizationId, teamId: null, }; @@ -411,16 +449,19 @@ function toWebhookOwnership(input: { profileId: null, betterAuthUserId: null, betterAuthOrganizationId: null, + betterAuthTeamId: null, organizationId: input.fallbackOrganizationId, teamId: null, }; } const BETTER_AUTH_ORGANIZATION_METADATA_KEY = "_betterAuthOrganizationId"; +const BETTER_AUTH_TEAM_METADATA_KEY = "_betterAuthTeamId"; function composeConnectionMetadata(input: { readonly metadata?: LinearConnectionMetadata; readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; }): LinearConnectionMetadata { const metadata = { ...(input.metadata ?? {}) }; const betterAuthOrganizationId = normalizeText( @@ -431,25 +472,71 @@ function composeConnectionMetadata(input: { } else { delete metadata[BETTER_AUTH_ORGANIZATION_METADATA_KEY]; } + const betterAuthTeamId = normalizeText(input.betterAuthTeamId); + if (betterAuthTeamId) { + metadata[BETTER_AUTH_TEAM_METADATA_KEY] = betterAuthTeamId; + } else { + delete metadata[BETTER_AUTH_TEAM_METADATA_KEY]; + } return metadata; } -function parseStoredBetterAuthOrganizationId(input: { +export function readStoredConnectionOwnership(input: { readonly raw: string; -}): string | null { +}): { + readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; +} { try { const parsed = JSON.parse(input.raw) as unknown; if (typeof parsed !== "object" || parsed === null) { - return null; + return { + betterAuthOrganizationId: null, + betterAuthTeamId: null, + }; } - return normalizeText( - (parsed as Record)[BETTER_AUTH_ORGANIZATION_METADATA_KEY] - ); + const record = parsed as Record; + return { + betterAuthOrganizationId: normalizeText( + record[BETTER_AUTH_ORGANIZATION_METADATA_KEY] + ), + betterAuthTeamId: normalizeText(record[BETTER_AUTH_TEAM_METADATA_KEY]), + }; } catch { - return null; + return { + betterAuthOrganizationId: null, + betterAuthTeamId: null, + }; } } +function createOwnershipColumnsEnsurer(input: { + readonly db: ReturnType; + readonly tableName: "linear_connections"; +}) { + let promise: Promise | null = null; + return async () => { + promise ??= ensureOwnershipColumns(input); + await promise; + }; +} + +async function ensureOwnershipColumns(input: { + readonly db: ReturnType; + readonly tableName: "linear_connections"; +}) { + await input.db.execute( + sql.raw( + `ALTER TABLE "${input.tableName}" ADD COLUMN IF NOT EXISTS "better_auth_organization_id" text` + ) + ); + await input.db.execute( + sql.raw( + `ALTER TABLE "${input.tableName}" ADD COLUMN IF NOT EXISTS "better_auth_team_id" text` + ) + ); +} + function normalizeText(value: unknown): string | null { if (typeof value !== "string") { return null; diff --git a/services/auth-broker/src/modules/linear-sync-store/service.ts b/services/auth-broker/src/modules/linear-sync-store/service.ts index f79eeabf9..197de04b8 100644 --- a/services/auth-broker/src/modules/linear-sync-store/service.ts +++ b/services/auth-broker/src/modules/linear-sync-store/service.ts @@ -22,6 +22,7 @@ export type RecordLinearWebhookDeliveryInput = { readonly issueIdentifier?: string | null; readonly betterAuthUserId: string | null; readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; readonly organizationId: string | null; readonly teamId: string | null; }; @@ -33,6 +34,7 @@ export type ListLinearWebhookDeliveriesInput = { readonly teamId?: string | null; readonly betterAuthUserId?: string | null; readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; }; export type LinearWebhookDelivery = { @@ -51,6 +53,7 @@ export type LinearWebhookDelivery = { readonly issueIdentifier: string | null; readonly betterAuthUserId: string | null; readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; readonly organizationId: string | null; readonly ownerTeamId: string | null; readonly status: LinearWebhookDeliveryStatus; @@ -103,6 +106,7 @@ export class InMemoryLinearSyncStore implements LinearSyncStore { issueIdentifier: normalizeText(input.issueIdentifier), betterAuthUserId: input.betterAuthUserId, betterAuthOrganizationId: input.betterAuthOrganizationId ?? null, + betterAuthTeamId: input.betterAuthTeamId ?? null, organizationId: input.organizationId, ownerTeamId: normalizeText(input.teamId), status: "pending", @@ -157,6 +161,10 @@ export function createLinearSyncStoreFromDb(input: { const db = createDbClient({ databaseUrl: input.databaseUrl, }); + const ensureOwnershipColumns = createOwnershipColumnsEnsurer({ + db, + tableName: "linear_webhook_events", + }); return { recordWebhookDelivery: async ({ path, @@ -173,9 +181,11 @@ export function createLinearSyncStoreFromDb(input: { issueIdentifier, betterAuthUserId, betterAuthOrganizationId, + betterAuthTeamId, organizationId, teamId, }) => { + await ensureOwnershipColumns(); const resolvedDeliveryKey = normalizeText(deliveryKey) ?? buildDeliveryKey({ @@ -204,10 +214,13 @@ export function createLinearSyncStoreFromDb(input: { signatureVerified, webhookTimestamp, [BETTER_AUTH_ORGANIZATION_PAYLOAD_KEY]: betterAuthOrganizationId, + [BETTER_AUTH_TEAM_PAYLOAD_KEY]: betterAuthTeamId, }), applyError: null, claimedBy: null, betterAuthUserId, + betterAuthOrganizationId: normalizeText(betterAuthOrganizationId), + betterAuthTeamId: normalizeText(betterAuthTeamId), organizationId, ownerTeamId: teamId, }) @@ -232,6 +245,7 @@ export function createLinearSyncStoreFromDb(input: { }, listWebhookDeliveries: async (input = {}) => { + await ensureOwnershipColumns(); const filters = buildListFilters({ input }); const rows = filters.length === 0 @@ -252,6 +266,7 @@ export function createLinearSyncStoreFromDb(input: { }, getWebhookDelivery: async ({ deliveryId }) => { + await ensureOwnershipColumns(); const rows = await db .select() .from(linearWebhookEvents) @@ -261,6 +276,7 @@ export function createLinearSyncStoreFromDb(input: { }, markWebhookDeliveryApplied: async ({ deliveryId }) => { + await ensureOwnershipColumns(); const now = new Date(); const updated = await db .update(linearWebhookEvents) @@ -278,9 +294,12 @@ export function createLinearSyncStoreFromDb(input: { }; } -function toWebhookDelivery(input: { +export function toWebhookDelivery(input: { readonly row: typeof linearWebhookEvents.$inferSelect; }): LinearWebhookDelivery { + const storedOwnership = readStoredDeliveryOwnership({ + payloadJson: input.row.payloadJson, + }); return { id: input.row.id, path: readStoredPath({ payloadJson: input.row.payloadJson }), @@ -300,9 +319,11 @@ function toWebhookDelivery(input: { issueId: input.row.issueId ?? null, issueIdentifier: input.row.issueIdentifier ?? null, betterAuthUserId: input.row.betterAuthUserId ?? null, - betterAuthOrganizationId: readStoredBetterAuthOrganizationId({ - payloadJson: input.row.payloadJson, - }), + betterAuthOrganizationId: + input.row.betterAuthOrganizationId ?? + storedOwnership.betterAuthOrganizationId, + betterAuthTeamId: + input.row.betterAuthTeamId ?? storedOwnership.betterAuthTeamId, organizationId: input.row.organizationId ?? null, ownerTeamId: input.row.ownerTeamId ?? null, status: normalizeDeliveryStatus({ value: input.row.status }), @@ -312,6 +333,8 @@ function toWebhookDelivery(input: { }; } +export const materializeLinearWebhookDelivery = toWebhookDelivery; + function buildListFilters(input: { readonly input: ListLinearWebhookDeliveriesInput; }) { @@ -380,6 +403,13 @@ function matchesDeliveryFilter(input: { } else if (betterAuthOrganizationId) { return true; } + const betterAuthTeamId = normalizeText(input.filter.betterAuthTeamId); + if ( + betterAuthTeamId && + input.delivery.betterAuthTeamId !== betterAuthTeamId + ) { + return false; + } const betterAuthUserId = normalizeText(input.filter.betterAuthUserId); if ( betterAuthUserId && @@ -412,6 +442,7 @@ function buildDeliveryKey(input: { } const BETTER_AUTH_ORGANIZATION_PAYLOAD_KEY = "_betterAuthOrganizationId"; +const BETTER_AUTH_TEAM_PAYLOAD_KEY = "_betterAuthTeamId"; function readStoredEnvelope(input: { readonly payloadJson: string; @@ -449,13 +480,23 @@ function readStoredWebhookTimestamp(input: { }); } -function readStoredBetterAuthOrganizationId(input: { +export function readStoredDeliveryOwnership(input: { readonly payloadJson: string; -}): string | null { - return readStringField({ - record: readStoredEnvelope(input), - key: BETTER_AUTH_ORGANIZATION_PAYLOAD_KEY, - }); +}): { + readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; +} { + const record = readStoredEnvelope(input); + return { + betterAuthOrganizationId: readStringField({ + record, + key: BETTER_AUTH_ORGANIZATION_PAYLOAD_KEY, + }), + betterAuthTeamId: readStringField({ + record, + key: BETTER_AUTH_TEAM_PAYLOAD_KEY, + }), + }; } function readStoredSignatureVerified(input: { @@ -481,6 +522,33 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } +function createOwnershipColumnsEnsurer(input: { + readonly db: ReturnType; + readonly tableName: "linear_webhook_events"; +}) { + let promise: Promise | null = null; + return async () => { + promise ??= ensureOwnershipColumns(input); + await promise; + }; +} + +async function ensureOwnershipColumns(input: { + readonly db: ReturnType; + readonly tableName: "linear_webhook_events"; +}) { + await input.db.execute( + sql.raw( + `ALTER TABLE "${input.tableName}" ADD COLUMN IF NOT EXISTS "better_auth_organization_id" text` + ) + ); + await input.db.execute( + sql.raw( + `ALTER TABLE "${input.tableName}" ADD COLUMN IF NOT EXISTS "better_auth_team_id" text` + ) + ); +} + function normalizeText(value: unknown): string | null { if (typeof value !== "string") { return null; diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 24cd41e57..10ac6ca15 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -4,10 +4,14 @@ import { createHmac } from "node:crypto"; import type { BetterAuthRuntime } from "../src/better-auth.ts"; import { FlowStore } from "../src/flow-store.ts"; import { createAuthBrokerApp } from "../src/index.ts"; -import { InMemoryLinearConnectionStore } from "../src/modules/linear-connections/service.ts"; +import { + InMemoryLinearConnectionStore, + materializeLinearConnectionRecord, +} from "../src/modules/linear-connections/service.ts"; import { InMemoryLinearSyncStore, type LinearSyncStore, + materializeLinearWebhookDelivery, } from "../src/modules/linear-sync-store/service.ts"; type BetterAuthDb = NonNullable; @@ -110,6 +114,136 @@ function resolveFetchUrl(input: string | Request | URL): string { } describe("auth broker github flow routes", () => { + test("linear connection materialization prefers first-class ownership columns and falls back to legacy metadata", () => { + const fromColumns = materializeLinearConnectionRecord({ + row: { + id: "conn-columns", + connectionKey: "profile:work", + profileId: "work", + accountId: null, + accountName: "Work", + accountEmail: "linear@example.com", + authRef: null, + betterAuthUserId: "user-a", + betterAuthOrganizationId: "column-org", + betterAuthTeamId: "column-team", + organizationId: "linear-org", + teamId: "linear-team", + metadataJson: JSON.stringify({ + organizationName: "Hack", + _betterAuthOrganizationId: "legacy-org", + _betterAuthTeamId: "legacy-team", + }), + createdAt: new Date("2026-03-06T00:00:00.000Z"), + updatedAt: new Date("2026-03-06T00:00:00.000Z"), + }, + }); + expect(fromColumns.betterAuthOrganizationId).toBe("column-org"); + expect(fromColumns.betterAuthTeamId).toBe("column-team"); + expect(fromColumns.metadata).toEqual({ + organizationName: "Hack", + }); + + const fromLegacyMetadata = materializeLinearConnectionRecord({ + row: { + id: "conn-legacy", + connectionKey: "profile:legacy", + profileId: "legacy", + accountId: null, + accountName: null, + accountEmail: "legacy@example.com", + authRef: null, + betterAuthUserId: "user-b", + betterAuthOrganizationId: null, + betterAuthTeamId: null, + organizationId: "legacy-linear-org", + teamId: "legacy-linear-team", + metadataJson: JSON.stringify({ + _betterAuthOrganizationId: "legacy-org", + _betterAuthTeamId: "legacy-team", + }), + createdAt: new Date("2026-03-06T00:00:00.000Z"), + updatedAt: new Date("2026-03-06T00:00:00.000Z"), + }, + }); + expect(fromLegacyMetadata.betterAuthOrganizationId).toBe("legacy-org"); + expect(fromLegacyMetadata.betterAuthTeamId).toBe("legacy-team"); + }); + + test("linear webhook delivery materialization prefers first-class ownership columns and falls back to legacy payload envelope", () => { + const fromColumns = materializeLinearWebhookDelivery({ + row: { + id: "delivery-columns", + deliveryKey: "delivery-columns", + profileId: "work", + projectId: "project-a", + teamId: "linear-team-a", + issueId: "issue-a", + issueIdentifier: "HACK-1", + eventType: "Issue", + action: "create", + status: "pending", + payloadJson: JSON.stringify({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payload: { type: "Issue" }, + signatureVerified: true, + webhookTimestamp: "2026-03-06T00:00:00.000Z", + _betterAuthOrganizationId: "legacy-org", + _betterAuthTeamId: "legacy-team", + }), + applyError: null, + claimedBy: null, + betterAuthUserId: "user-a", + betterAuthOrganizationId: "column-org", + betterAuthTeamId: "column-team", + organizationId: "linear-org", + ownerTeamId: "linear-owner-team", + createdAt: new Date("2026-03-06T00:00:00.000Z"), + updatedAt: new Date("2026-03-06T00:00:00.000Z"), + appliedAt: null, + }, + }); + expect(fromColumns.betterAuthOrganizationId).toBe("column-org"); + expect(fromColumns.betterAuthTeamId).toBe("column-team"); + + const fromLegacyPayload = materializeLinearWebhookDelivery({ + row: { + id: "delivery-legacy", + deliveryKey: "delivery-legacy", + profileId: "legacy", + projectId: "project-b", + teamId: "linear-team-b", + issueId: "issue-b", + issueIdentifier: "HACK-2", + eventType: "Issue", + action: "update", + status: "pending", + payloadJson: JSON.stringify({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payload: { type: "Issue" }, + signatureVerified: true, + webhookTimestamp: "2026-03-06T00:00:00.000Z", + _betterAuthOrganizationId: "legacy-org", + _betterAuthTeamId: "legacy-team", + }), + applyError: null, + claimedBy: null, + betterAuthUserId: "user-b", + betterAuthOrganizationId: null, + betterAuthTeamId: null, + organizationId: "linear-org-b", + ownerTeamId: "linear-owner-team-b", + createdAt: new Date("2026-03-06T00:00:00.000Z"), + updatedAt: new Date("2026-03-06T00:00:00.000Z"), + appliedAt: null, + }, + }); + expect(fromLegacyPayload.betterAuthOrganizationId).toBe("legacy-org"); + expect(fromLegacyPayload.betterAuthTeamId).toBe("legacy-team"); + }); + test("better auth status endpoint is exposed", async () => { const app = createAuthBrokerApp({ config: createTestConfig(), diff --git a/services/auth-broker/tests/linear-ownership-compat.test.ts b/services/auth-broker/tests/linear-ownership-compat.test.ts new file mode 100644 index 000000000..e24f90b1f --- /dev/null +++ b/services/auth-broker/tests/linear-ownership-compat.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test"; + +import { + readStoredConnectionOwnership, + toConnectionRecord, +} from "../src/modules/linear-connections/service.ts"; +import { + readStoredDeliveryOwnership, + toWebhookDelivery, +} from "../src/modules/linear-sync-store/service.ts"; + +type LinearConnectionRow = + typeof import("../src/db/schema.ts").linearConnections.$inferSelect; +type LinearWebhookDeliveryRow = + typeof import("../src/db/schema.ts").linearWebhookEvents.$inferSelect; + +test("toConnectionRecord prefers first-class ownership columns and strips legacy metadata keys", () => { + const row: LinearConnectionRow = { + id: "conn-1", + connectionKey: "profile:work", + profileId: "work", + accountId: null, + accountName: null, + accountEmail: "linear@example.com", + authRef: null, + betterAuthUserId: "user-1", + betterAuthOrganizationId: "org-column", + betterAuthTeamId: "team-column", + organizationId: "linear-org", + teamId: "linear-team", + metadataJson: JSON.stringify({ + _betterAuthOrganizationId: "org-legacy", + _betterAuthTeamId: "team-legacy", + organizationName: "Hack Dance", + }), + createdAt: new Date("2026-03-06T00:00:00.000Z"), + updatedAt: new Date("2026-03-06T00:00:00.000Z"), + }; + + const record = toConnectionRecord({ row }); + expect(record.betterAuthOrganizationId).toBe("org-column"); + expect(record.betterAuthTeamId).toBe("team-column"); + expect(record.metadata).toEqual({ organizationName: "Hack Dance" }); +}); + +test("toConnectionRecord falls back to legacy ownership metadata when structured columns are absent", () => { + const row: LinearConnectionRow = { + id: "conn-2", + connectionKey: "profile:legacy", + profileId: "legacy", + accountId: null, + accountName: null, + accountEmail: null, + authRef: null, + betterAuthUserId: "user-2", + betterAuthOrganizationId: null, + betterAuthTeamId: null, + organizationId: null, + teamId: null, + metadataJson: JSON.stringify({ + _betterAuthOrganizationId: "org-legacy", + _betterAuthTeamId: "team-legacy", + }), + createdAt: new Date("2026-03-06T00:00:00.000Z"), + updatedAt: new Date("2026-03-06T00:00:00.000Z"), + }; + + const record = toConnectionRecord({ row }); + expect(record.betterAuthOrganizationId).toBe("org-legacy"); + expect(record.betterAuthTeamId).toBe("team-legacy"); +}); + +test("readStoredConnectionOwnership tolerates invalid JSON", () => { + expect(readStoredConnectionOwnership({ raw: "{" })).toEqual({ + betterAuthOrganizationId: null, + betterAuthTeamId: null, + }); +}); + +test("toWebhookDelivery prefers first-class ownership columns and falls back to legacy payload keys", () => { + const row: LinearWebhookDeliveryRow = { + id: "delivery-1", + deliveryKey: "key-1", + profileId: "work", + projectId: "project-1", + teamId: "linear-team", + issueId: "issue-1", + issueIdentifier: "HACK-1", + eventType: "Issue", + action: "update", + status: "pending", + payloadJson: JSON.stringify({ + path: "/linear/webhooks", + rawBody: "{}", + payload: { type: "Issue" }, + signatureVerified: true, + webhookTimestamp: "2026-03-06T00:00:00.000Z", + _betterAuthOrganizationId: "org-legacy", + _betterAuthTeamId: "team-legacy", + }), + applyError: null, + claimedBy: null, + betterAuthUserId: "user-1", + betterAuthOrganizationId: "org-column", + betterAuthTeamId: "team-column", + organizationId: "linear-org", + ownerTeamId: "linear-team", + createdAt: new Date("2026-03-06T00:00:00.000Z"), + updatedAt: new Date("2026-03-06T00:00:00.000Z"), + appliedAt: null, + }; + + const delivery = toWebhookDelivery({ row }); + expect(delivery.betterAuthOrganizationId).toBe("org-column"); + expect(delivery.betterAuthTeamId).toBe("team-column"); +}); + +test("toWebhookDelivery falls back to legacy payload ownership keys when structured columns are absent", () => { + const row: LinearWebhookDeliveryRow = { + id: "delivery-2", + deliveryKey: "key-2", + profileId: null, + projectId: null, + teamId: null, + issueId: null, + issueIdentifier: null, + eventType: "Issue", + action: "create", + status: "pending", + payloadJson: JSON.stringify({ + path: "/linear/webhooks", + rawBody: "{}", + payload: { type: "Issue" }, + signatureVerified: true, + webhookTimestamp: "2026-03-06T00:00:00.000Z", + _betterAuthOrganizationId: "org-legacy", + _betterAuthTeamId: "team-legacy", + }), + applyError: null, + claimedBy: null, + betterAuthUserId: "user-2", + betterAuthOrganizationId: null, + betterAuthTeamId: null, + organizationId: null, + ownerTeamId: null, + createdAt: new Date("2026-03-06T00:00:00.000Z"), + updatedAt: new Date("2026-03-06T00:00:00.000Z"), + appliedAt: null, + }; + + const delivery = toWebhookDelivery({ row }); + expect(delivery.betterAuthOrganizationId).toBe("org-legacy"); + expect(delivery.betterAuthTeamId).toBe("team-legacy"); +}); + +test("readStoredDeliveryOwnership tolerates invalid JSON", () => { + expect(readStoredDeliveryOwnership({ payloadJson: "{" })).toEqual({ + betterAuthOrganizationId: null, + betterAuthTeamId: null, + }); +}); From 781d638fc9ac21cce9b55d9484bf9aa609e9cf09 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 5 Mar 2026 22:08:46 -0500 Subject: [PATCH 05/54] Finish Linear authz, routing, and review flow --- .../DashboardFeature/DashboardModel.swift | 123 ++ .../DashboardFeature/ProjectDetailView.swift | 495 ++++- .../DashboardFeature/TicketsView.swift | 296 ++- .../HackCLIService/HackCLIClient.swift | 128 ++ .../Sources/HackDesktopModels/Models.swift | 186 +- .../TicketDetailReviewModelTests.swift | 37 +- packages/db/src/schema/core.ts | 4 +- services/auth-broker/src/app.ts | 36 + services/auth-broker/src/db/schema.ts | 20 + services/auth-broker/src/flow-store.ts | 22 + .../modules/better-auth/management-token.ts | 230 +++ .../src/modules/better-auth/session.ts | 182 +- .../src/modules/linear-agent/plugin.ts | 27 +- .../src/modules/linear-autosync/plugin.ts | 249 +++ .../src/modules/linear-autosync/service.ts | 450 +++++ .../src/modules/linear-connections/plugin.ts | 39 +- .../src/modules/linear-oauth/service.ts | 10 + .../src/modules/linear-sync-store/plugin.ts | 51 +- .../src/modules/linear-sync-store/service.ts | 12 +- .../src/modules/providers/plugin.ts | 1 + services/auth-broker/src/types.ts | 4 + services/auth-broker/tests/index.test.ts | 1615 +++++++++++++++-- src/control-plane/extensions/linear/auth.ts | 101 ++ .../extensions/linear/commands.ts | 1514 +++++++++++++-- .../extensions/tickets/commands.ts | 87 + src/control-plane/extensions/tickets/store.ts | 126 +- src/mcp/server.ts | 239 ++- tests/linear-commands.test.ts | 295 ++- tests/mcp.test.ts | 263 ++- tests/tickets-extension.test.ts | 22 +- tests/tickets-store.test.ts | 36 +- 31 files changed, 6341 insertions(+), 559 deletions(-) create mode 100644 services/auth-broker/src/modules/better-auth/management-token.ts create mode 100644 services/auth-broker/src/modules/linear-autosync/plugin.ts create mode 100644 services/auth-broker/src/modules/linear-autosync/service.ts diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 84fc7173c..86f5a0b05 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -342,6 +342,26 @@ public final class DashboardModel { } } + public func appendTicketReviewNote( + for project: ProjectSummary, + ticketId: String, + body: String, + actor: String? = nil + ) async -> TicketReviewNoteAppendResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + return await runActionResult(message: "Appending review note…") { + try await self.ticketsClient.appendTicketReviewNote( + path: path, + ticketId: ticketId, + body: body, + actor: actor + ) + } + } + public func syncTickets(for project: ProjectSummary) async -> TicketsSyncResult? { guard let path = resolveProjectPath(project) else { errorMessage = "Missing project path for \(project.name)" @@ -757,6 +777,55 @@ public final class DashboardModel { } } + public func listLinearAutosyncSubscriptions( + profileId: String? = nil, + projectId: String? = nil, + teamId: String? = nil + ) async -> LinearAutosyncSubscriptionsResponse? { + do { + return try await client.listLinearAutosyncSubscriptions( + profileId: profileId, + projectId: projectId, + teamId: teamId + ) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + public func setLinearAutosyncSubscription( + profileId: String? = nil, + projectId: String? = nil, + teamId: String? = nil, + mode: String = "auto_apply", + status: String = "active" + ) async -> LinearAutosyncSubscriptionMutationResponse? { + return await runActionResult(message: "Saving Linear autosync…") { + try await self.client.setLinearAutosyncSubscription( + profileId: profileId, + projectId: projectId, + teamId: teamId, + mode: mode, + status: status + ) + } + } + + public func removeLinearAutosyncSubscription( + profileId: String? = nil, + projectId: String? = nil, + teamId: String? = nil + ) async -> LinearAutosyncSubscriptionMutationResponse? { + return await runActionResult(message: "Removing Linear autosync…") { + try await self.client.removeLinearAutosyncSubscription( + profileId: profileId, + projectId: projectId, + teamId: teamId + ) + } + } + public func bindLinearProject( for project: ProjectSummary, profileId: String?, @@ -781,9 +850,60 @@ public final class DashboardModel { } } + public func inspectLinearProjectBinding( + for project: ProjectSummary + ) async -> LinearProjectBindingResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + do { + return try await client.inspectLinearProjectBinding(path: path) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + public func linkLinearProject( + for project: ProjectSummary, + profileId: String?, + projectId: String, + projectName: String?, + teamId: String? + ) async -> LinearProjectBindingResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + return await runActionResult(message: "Adding Linear project to sync scope…") { + try await self.client.linkLinearProject( + path: path, + profileId: profileId, + projectId: projectId, + projectName: projectName, + teamId: teamId + ) + } + } + + public func unlinkLinearProject( + for project: ProjectSummary, + projectId: String + ) async -> LinearProjectBindingResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + return await runActionResult(message: "Removing Linear project from sync scope…") { + try await self.client.unlinkLinearProject(path: path, projectId: projectId) + } + } + public func syncLinearProject( for project: ProjectSummary, from direction: String, + profileId: String? = nil, ownerMode: String? = nil, projectId: String? = nil, teamId: String? = nil, @@ -801,6 +921,7 @@ public final class DashboardModel { try await self.client.syncLinearProject( path: path, from: direction, + profileId: profileId, ownerMode: ownerMode, projectId: projectId, teamId: teamId, @@ -813,6 +934,7 @@ public final class DashboardModel { public func syncLinearIssue( for project: ProjectSummary, from direction: String, + profileId: String? = nil, issueIdentifier: String? = nil, ticketId: String? = nil, projectId: String? = nil, @@ -830,6 +952,7 @@ public final class DashboardModel { try await self.client.syncLinearIssue( path: path, from: direction, + profileId: profileId, issueIdentifier: issueIdentifier, ticketId: ticketId, projectId: projectId, diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 1f535c3c6..27af573d5 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -175,8 +175,12 @@ struct ProjectDetailView: View { @State private var linearBoundProjectId = "" @State private var linearBoundProjectName = "" @State private var linearBoundTeamId = "" + @State private var linearAdditionalProjects: [LinearProjectBindingTarget] = [] + @State private var linearAutosyncRouteKeys: Set = [] + @State private var togglingLinearAutosyncRouteKeys: Set = [] @State private var linearProjectOptions: [LinearProjectSummary] = [] @State private var selectedLinearProjectId = "" + @State private var selectedAdditionalLinearProjectId = "" @State private var linearProjectMessage = "" @State private var pendingLinearSyncAction: ProjectLinearSyncAction? = nil @State private var projectSystemGitIdentity: GitSystemIdentity? = nil @@ -911,6 +915,56 @@ struct ProjectDetailView: View { .foregroundStyle(.tertiary) } + HStack(alignment: .top, spacing: 12) { + Text("Linear scope") + .font(.mono(.subheadline, weight: .semibold)) + .frame(width: 120, alignment: .leading) + + VStack(alignment: .leading, spacing: 8) { + if allLinearRouteTargets.isEmpty { + Text("No Linear route bound yet. Pick a default project first, then optionally add extra projects for pull review and autosync.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(allLinearRouteTargets) { target in + linearScopeRouteRow( + target: target, + isDefault: target.projectId == linearBoundProjectId + ) + } + } + } + + HStack(spacing: 8) { + Picker("Add linked project", selection: $selectedAdditionalLinearProjectId) { + Text("Default only").tag("") + ForEach(availableAdditionalLinearProjects) { linearProject in + Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 360, alignment: .leading) + .disabled(availableAdditionalLinearProjects.isEmpty) + + Button("Add") { + Task { await persistAdditionalLinearProjectSelection() } + } + .adaptiveToolbarButton() + .disabled( + selectedAdditionalLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + ) + } + } + + Spacer() + Text(additionalLinearProjectsSummary) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + HStack(alignment: .center, spacing: 12) { Text("Ticket sync") .font(.mono(.subheadline, weight: .semibold)) @@ -950,7 +1004,10 @@ struct ProjectDetailView: View { if let accountName = linearResolvedAccountName, !accountName.isEmpty { let teamSuffix = linearBoundTeamId.isEmpty ? "" : " • team \(linearBoundTeamId)" - Text("Linear route: \(accountName)\(linearBoundProjectName.isEmpty ? "" : " • \(linearBoundProjectName)")\(teamSuffix)") + let additionalSuffix = linearAdditionalProjects.isEmpty + ? "" + : " • +\(linearAdditionalProjects.count) linked" + Text("Linear route: \(accountName)\(linearBoundProjectName.isEmpty ? "" : " • \(linearBoundProjectName)")\(teamSuffix)\(additionalSuffix)") .font(.mono(.caption2)) .foregroundStyle(.secondary) } @@ -1464,6 +1521,95 @@ struct ProjectDetailView: View { return githubProfileLabel(profileId: selectedProfileId) } + @ViewBuilder + private func linearScopeRouteRow( + target: LinearProjectBindingTarget, + isDefault: Bool + ) -> some View { + let normalizedTarget = normalizedLinearRouteTarget(target) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 8) { + Text(linearProjectBindingTargetLabel(normalizedTarget)) + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.primary) + if isDefault { + StatusPill(text: "Default", tone: .good) + } else { + StatusPill(text: "Linked", tone: .neutral) + } + if isLinearAutosyncEnabled(for: normalizedTarget) { + StatusPill(text: "Autosync", tone: .good) + } + Spacer() + if isLinearAutosyncBusy(for: normalizedTarget) { + ProgressView() + .controlSize(.small) + } + Toggle( + isOn: Binding( + get: { isLinearAutosyncEnabled(for: normalizedTarget) }, + set: { enabled in + toggleLinearAutosync(for: normalizedTarget, enabled: enabled) + } + ) + ) { + Text("Auto") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + .labelsHidden() + .disabled(isLinearAutosyncBusy(for: normalizedTarget)) + + Menu { + Button("Pull now") { + Task { await syncSpecificLinearProject(normalizedTarget, from: "linear") } + } + .disabled(linearResolvedStatus?.tokenResolved != true) + Button("Push now") { + Task { await syncSpecificLinearProject(normalizedTarget, from: "hack") } + } + .disabled(linearResolvedStatus?.tokenResolved != true) + if !isDefault { + Button("Make default") { + Task { await makeAdditionalLinearProjectDefault(normalizedTarget) } + } + Button(role: .destructive) { + Task { await removeAdditionalLinearProject(normalizedTarget) } + } label: { + Text("Remove") + } + } + } label: { + Label("Actions", systemImage: "ellipsis.circle") + } + .buttonStyle(PressableIconButtonStyle()) + } + + HStack(spacing: 8) { + if let profileId = normalizedTarget.profileId, !profileId.isEmpty { + Text("Profile \(linearProfileLabel(profileId: profileId))") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + if let teamId = normalizedTarget.teamId, !teamId.isEmpty { + Text("Team \(teamId)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(colorScheme == .dark ? 0.05 : 0.035)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + private var selectedLinearProfileSummary: String { let selectedProfileId = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) guard !selectedProfileId.isEmpty else { @@ -1489,6 +1635,71 @@ struct ProjectDetailView: View { return selectedProjectId } + private var availableAdditionalLinearProjects: [LinearProjectSummary] { + let linkedProjectIds = Set(linearAdditionalProjects.map(\.projectId)) + return linearProjectOptions.filter { linearProject in + linearProject.id != linearBoundProjectId && !linkedProjectIds.contains(linearProject.id) + } + } + + private var effectiveDefaultLinearTarget: LinearProjectBindingTarget? { + guard !linearBoundProjectId.isEmpty else { + return nil + } + return LinearProjectBindingTarget( + profileId: linearBoundProfileId, + projectId: linearBoundProjectId, + projectName: linearBoundProjectName.isEmpty ? nil : linearBoundProjectName, + teamId: linearBoundTeamId.isEmpty ? nil : linearBoundTeamId + ) + } + + private var linearBoundProfileId: String? { + let projectProfile = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) + if !projectProfile.isEmpty { + return projectProfile + } + let resolvedProfile = linearResolvedProfile.trimmingCharacters(in: .whitespacesAndNewlines) + return resolvedProfile.isEmpty ? nil : resolvedProfile + } + + private var allLinearRouteTargets: [LinearProjectBindingTarget] { + var targets: [LinearProjectBindingTarget] = [] + if let effectiveDefaultLinearTarget { + targets.append(effectiveDefaultLinearTarget) + } + targets.append(contentsOf: linearAdditionalProjects.map(normalizedLinearRouteTarget(_:))) + return targets + } + + private func normalizedLinearRouteTarget(_ target: LinearProjectBindingTarget) -> LinearProjectBindingTarget { + if let profileId = target.profileId?.trimmingCharacters(in: .whitespacesAndNewlines), + !profileId.isEmpty { + return target + } + return LinearProjectBindingTarget( + profileId: linearBoundProfileId, + projectId: target.projectId, + projectName: target.projectName, + teamId: target.teamId + ) + } + + private func linearRouteKey(for target: LinearProjectBindingTarget) -> String { + let normalized = normalizedLinearRouteTarget(target) + let profileId = normalized.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "*" + let teamId = normalized.teamId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "*" + return [profileId, normalized.projectId, teamId].joined(separator: "::") + } + + private func isLinearAutosyncEnabled(for target: LinearProjectBindingTarget) -> Bool { + linearAutosyncRouteKeys.contains(linearRouteKey(for: target)) + } + + private func isLinearAutosyncBusy(for target: LinearProjectBindingTarget) -> Bool { + togglingLinearAutosyncRouteKeys.contains(linearRouteKey(for: target)) + } + private var linearResolvedAccountName: String? { let trimmed = linearResolvedProfile.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -1498,7 +1709,7 @@ struct ProjectDetailView: View { } private var canSyncFromLinear: Bool { - !resolvedLinearProjectId.isEmpty && linearResolvedStatus?.tokenResolved == true + hasAnyLinearProjectRouting && linearResolvedStatus?.tokenResolved == true } private var canSyncToLinear: Bool { @@ -1513,18 +1724,35 @@ struct ProjectDetailView: View { return linearBoundProjectId.trimmingCharacters(in: .whitespacesAndNewlines) } + private var hasAnyLinearProjectRouting: Bool { + if !resolvedLinearProjectId.isEmpty { + return true + } + return !linearAdditionalProjects.isEmpty + } + + private var additionalLinearProjectsSummary: String { + if linearAdditionalProjects.isEmpty { + return "Default only" + } + return "\(linearAdditionalProjects.count) linked" + } + private var ticketSyncSummary: String { if linearResolvedStatus?.tokenResolved != true { return "Connect Linear first" } - let projectId = resolvedLinearProjectId - if projectId.isEmpty { + if !hasAnyLinearProjectRouting { return "Pick a Linear project" } - if linearBoundProjectName.isEmpty { - return "\(projectId) • origin decides authority" + if linearAdditionalProjects.isEmpty { + if linearBoundProjectName.isEmpty { + return "\(resolvedLinearProjectId) • origin decides authority" + } + return "\(linearBoundProjectName) • origin decides authority" } - return "\(linearBoundProjectName) • origin decides authority" + let defaultLabel = linearBoundProjectName.isEmpty ? resolvedLinearProjectId : linearBoundProjectName + return "\(defaultLabel) + \(linearAdditionalProjects.count) linked • origin decides authority" } private var remoteConfigMessage: String { @@ -1578,7 +1806,7 @@ struct ProjectDetailView: View { } private var linearRoutingContractTone: StatusTone { - if linearResolvedStatus?.tokenResolved != true || resolvedLinearProjectId.isEmpty { + if linearResolvedStatus?.tokenResolved != true || !hasAnyLinearProjectRouting { return .warn } return .neutral @@ -1587,10 +1815,14 @@ struct ProjectDetailView: View { private var linearRoutingContractMessage: String { let routeSummary: String if let accountName = linearResolvedAccountName, !accountName.isEmpty { - if linearBoundProjectName.isEmpty { + if linearBoundProjectName.isEmpty && linearAdditionalProjects.isEmpty { routeSummary = "Current route uses \(accountName)." - } else { + } else if linearAdditionalProjects.isEmpty { routeSummary = "Current route uses \(accountName) and \(linearBoundProjectName)." + } else { + let defaultProjectLabel = linearBoundProjectName.isEmpty ? "no default project" : linearBoundProjectName + routeSummary = + "Current route uses \(accountName), default project \(defaultProjectLabel), and \(linearAdditionalProjects.count) linked project\(linearAdditionalProjects.count == 1 ? "" : "s")." } } else { routeSummary = "Linear is not fully routed for this project yet." @@ -1734,6 +1966,30 @@ struct ProjectDetailView: View { return project.name } + private func linearProjectBindingTargetLabel(_ target: LinearProjectBindingTarget) -> String { + if let matchingProject = linearProjectOptions.first(where: { $0.id == target.projectId }) { + let projectLabel = linearProjectMenuLabel(matchingProject) + if let profileId = target.profileId, + !profileId.isEmpty, + profileId != linearResolvedProfile, + profileId != linearProjectProfile { + return "\(linearProfileLabel(profileId: profileId)) • \(projectLabel)" + } + return projectLabel + } + + let projectLabel: String + if let projectName = target.projectName, !projectName.isEmpty { + projectLabel = projectName + } else { + projectLabel = target.projectId + } + if let profileId = target.profileId, !profileId.isEmpty { + return "\(linearProfileLabel(profileId: profileId)) • \(projectLabel)" + } + return projectLabel + } + /** Reload project-level remote execution defaults and Git credential routing. */ @@ -1762,22 +2018,7 @@ struct ProjectDetailView: View { for: project, key: "controlPlane.routing.overrides.github.profile" ) - async let projectLinearProfile = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.profile" - ) - async let projectLinearProjectId = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.projectId" - ) - async let projectLinearProjectName = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.projectName" - ) - async let projectLinearTeamId = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.teamId" - ) + async let linearBinding = model.inspectLinearProjectBinding(for: project) async let defaultGitHubProfile = model.getGlobalConfig( key: "controlPlane.extensions[\"dance.hack.github\"].config.defaultProfile" ) @@ -1795,10 +2036,7 @@ struct ProjectDetailView: View { let resolvedExecutionNodeId = await executionNodeId let resolvedProjectNodeId = await projectNodeId let resolvedProjectGitHubProfile = await projectGitHubProfile - let resolvedProjectLinearProfile = await projectLinearProfile - let resolvedProjectLinearProjectId = await projectLinearProjectId - let resolvedProjectLinearProjectName = await projectLinearProjectName - let resolvedProjectLinearTeamId = await projectLinearTeamId + let resolvedLinearBinding = await linearBinding let resolvedDefaultGitHubProfile = await defaultGitHubProfile let resolvedDefaultLinearProfile = await defaultLinearProfile let resolvedGitHubProfiles = await githubProfiles @@ -1850,14 +2088,15 @@ struct ProjectDetailView: View { ) } - linearProjectProfile = (resolvedProjectLinearProfile ?? "") + linearProjectProfile = (resolvedLinearBinding?.profileId ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) - linearBoundProjectId = (resolvedProjectLinearProjectId ?? "") + linearBoundProjectId = (resolvedLinearBinding?.projectId ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) - linearBoundProjectName = (resolvedProjectLinearProjectName ?? "") + linearBoundProjectName = (resolvedLinearBinding?.projectName ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) - linearBoundTeamId = (resolvedProjectLinearTeamId ?? "") + linearBoundTeamId = (resolvedLinearBinding?.teamId ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) + linearAdditionalProjects = resolvedLinearBinding?.additionalProjects ?? [] let fallbackDefaultLinearProfile = (resolvedDefaultLinearProfile ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) linearDefaultProfile = resolvedLinearProfiles?.defaultProfile ?? fallbackDefaultLinearProfile @@ -1896,6 +2135,10 @@ struct ProjectDetailView: View { linearProjectOptions = [] } selectedLinearProjectId = linearBoundProjectId + if linearAdditionalProjects.contains(where: { $0.projectId == selectedAdditionalLinearProjectId }) { + selectedAdditionalLinearProjectId = "" + } + linearAutosyncRouteKeys = await loadLinearAutosyncRouteKeys(targets: allLinearRouteTargets) executionMode = RemoteExecutionMode.fromConfig(resolvedExecutionModeRaw) ?? (executionTargetNodeId.isEmpty ? .local : .localEditRemoteRun) executionTargetMessage = "" @@ -2114,6 +2357,148 @@ struct ProjectDetailView: View { queueExecutionTargetReload() } + private func persistAdditionalLinearProjectSelection() async { + let selectedProjectId = selectedAdditionalLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !selectedProjectId.isEmpty else { + linearProjectMessage = "Choose a Linear project to add." + return + } + guard let linearProject = linearProjectOptions.first(where: { $0.id == selectedProjectId }) else { + linearProjectMessage = "Choose a valid Linear project before adding it." + return + } + + let profileOverride = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) + let response = await model.linkLinearProject( + for: project, + profileId: profileOverride.isEmpty ? nil : profileOverride, + projectId: linearProject.id, + projectName: linearProject.name, + teamId: linearProject.teamId + ) + guard response?.ok == true else { + linearProjectMessage = model.errorMessage ?? "Failed to add Linear project to sync scope." + return + } + + selectedAdditionalLinearProjectId = "" + linearProjectMessage = "Added \(linearProjectMenuLabel(linearProject)) to the sync scope." + executionTargetMessage = "" + githubProfileMessage = "" + await model.refresh() + queueExecutionTargetReload() + } + + private func loadLinearAutosyncRouteKeys( + targets: [LinearProjectBindingTarget] + ) async -> Set { + var nextKeys: Set = [] + for target in targets { + let normalizedTarget = normalizedLinearRouteTarget(target) + guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { + continue + } + guard let subscriptions = await model.listLinearAutosyncSubscriptions( + profileId: profileId, + projectId: normalizedTarget.projectId, + teamId: normalizedTarget.teamId + ) else { + continue + } + if subscriptions.subscriptions.contains(where: { + $0.projectId == normalizedTarget.projectId && + $0.teamId == normalizedTarget.teamId && + $0.mode == "auto_apply" && + $0.status == "active" + }) { + nextKeys.insert(linearRouteKey(for: normalizedTarget)) + } + } + return nextKeys + } + + private func removeAdditionalLinearProject(_ target: LinearProjectBindingTarget) async { + let response = await model.unlinkLinearProject( + for: project, + projectId: target.projectId + ) + guard response?.ok == true else { + linearProjectMessage = model.errorMessage ?? "Failed to remove linked Linear project." + return + } + + linearProjectMessage = "Removed \(linearProjectBindingTargetLabel(target)) from the sync scope." + executionTargetMessage = "" + githubProfileMessage = "" + await model.refresh() + queueExecutionTargetReload() + } + + private func toggleLinearAutosync( + for target: LinearProjectBindingTarget, + enabled: Bool + ) { + let routeKey = linearRouteKey(for: target) + togglingLinearAutosyncRouteKeys.insert(routeKey) + Task { + defer { togglingLinearAutosyncRouteKeys.remove(routeKey) } + let normalizedTarget = normalizedLinearRouteTarget(target) + guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { + linearProjectMessage = "Choose a Linear account before changing autosync." + return + } + if enabled { + let response = await model.setLinearAutosyncSubscription( + profileId: profileId, + projectId: normalizedTarget.projectId, + teamId: normalizedTarget.teamId, + mode: "auto_apply", + status: "active" + ) + guard response != nil else { + linearProjectMessage = model.errorMessage ?? "Failed to enable Linear autosync." + return + } + linearAutosyncRouteKeys.insert(routeKey) + linearProjectMessage = "Autosync enabled for \(linearProjectBindingTargetLabel(normalizedTarget))." + return + } + + let response = await model.removeLinearAutosyncSubscription( + profileId: profileId, + projectId: normalizedTarget.projectId, + teamId: normalizedTarget.teamId + ) + guard response != nil else { + linearProjectMessage = model.errorMessage ?? "Failed to disable Linear autosync." + return + } + linearAutosyncRouteKeys.remove(routeKey) + linearProjectMessage = "Autosync disabled for \(linearProjectBindingTargetLabel(normalizedTarget))." + } + } + + private func makeAdditionalLinearProjectDefault(_ target: LinearProjectBindingTarget) async { + let normalizedTarget = normalizedLinearRouteTarget(target) + let response = await model.bindLinearProject( + for: project, + profileId: normalizedTarget.profileId, + projectId: normalizedTarget.projectId, + projectName: normalizedTarget.projectName, + teamId: normalizedTarget.teamId, + clear: false + ) + guard response?.ok == true else { + linearProjectMessage = model.errorMessage ?? "Failed to make the linked Linear project the default route." + return + } + + linearProjectMessage = "Default Linear route set to \(linearProjectBindingTargetLabel(normalizedTarget))." + await model.refresh() + queueExecutionTargetReload() + } + private func clearLinearProjectBinding() async { let profileOverride = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) let response = await model.bindLinearProject( @@ -2138,17 +2523,13 @@ struct ProjectDetailView: View { } private func syncBoundLinearProject(from direction: String) async { - let explicitProjectId = resolvedLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - let explicitTeamId = linearBoundTeamId - .trimmingCharacters(in: .whitespacesAndNewlines) let ownerMode = direction == "hack" ? "hack" : nil let result = await model.syncLinearProject( for: project, from: direction, ownerMode: ownerMode, - projectId: explicitProjectId.isEmpty ? nil : explicitProjectId, - teamId: explicitTeamId.isEmpty ? nil : explicitTeamId, + projectId: nil, + teamId: nil, limit: nil, syncLabels: nil ) @@ -2158,8 +2539,9 @@ struct ProjectDetailView: View { } if direction == "linear" { + let routedProjects = result.projectIds?.count ?? (linearAdditionalProjects.isEmpty ? 1 : linearAdditionalProjects.count + 1) linearProjectMessage = - "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") • created \(result.created) • updated \(result.updated). Linear stays authoritative for Linear-origin title, body, status, and project binding." + "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from \(routedProjects) routed project\(routedProjects == 1 ? "" : "s") • created \(result.created) • updated \(result.updated). Linear stays authoritative for Linear-origin title, body, status, and project binding." } else { linearProjectMessage = "Pushed \(result.processed) Hack ticket\(result.processed == 1 ? "" : "s") • created \(result.created) • updated \(result.updated). Hack stays authoritative for Hack-origin title, body, status, and project binding." @@ -2168,6 +2550,37 @@ struct ProjectDetailView: View { githubProfileMessage = "" } + private func syncSpecificLinearProject( + _ target: LinearProjectBindingTarget, + from direction: String + ) async { + let normalizedTarget = normalizedLinearRouteTarget(target) + let ownerMode = direction == "hack" ? "hack" : nil + let result = await model.syncLinearProject( + for: project, + from: direction, + profileId: normalizedTarget.profileId, + ownerMode: ownerMode, + projectId: normalizedTarget.projectId, + teamId: normalizedTarget.teamId, + limit: nil, + syncLabels: nil + ) + guard let result, result.ok else { + linearProjectMessage = model.errorMessage ?? "Ticket sync failed." + return + } + + let routeLabel = linearProjectBindingTargetLabel(normalizedTarget) + if direction == "linear" { + linearProjectMessage = + "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from \(routeLabel) • created \(result.created) • updated \(result.updated)." + } else { + linearProjectMessage = + "Pushed \(result.processed) Hack ticket\(result.processed == 1 ? "" : "s") to \(routeLabel) • created \(result.created) • updated \(result.updated)." + } + } + private struct LifecycleSummaryCounts { let startupHookCount: Int let shutdownHookCount: Int diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index c828c92fc..95fb53ea9 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -5,7 +5,6 @@ import SwiftUI import HackDesktopModels struct TicketsView: View { - @AppStorage("hackDesktop.linearReviewNotes.v1") private var reviewNotesStorage = "{}" @Environment(DashboardModel.self) private var model @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL @@ -32,15 +31,15 @@ struct TicketsView: View { @State private var linearRouteProjectId = "" @State private var linearRouteProjectName = "" @State private var linearRouteTeamId = "" + @State private var linearAdditionalProjects: [LinearProjectBindingTarget] = [] @State private var linearRouteStatus: LinearStatusResponse? = nil @State private var pendingBulkSyncAction: TicketBulkSyncAction? = nil @State private var activeSyncAction: TicketSyncAction? = nil @State private var resolvingConflictIds: Set = [] @State private var postingCommentTicketIds: Set = [] + @State private var postingReviewNoteTicketIds: Set = [] @State private var ticketDetailCache: [String: TicketDetailResponse] = [:] - @State private var reviewNotesByTicketKey: [String: [TicketLocalReviewNote]] = [:] @State private var reviewComposerDrafts: [String: String] = [:] - @State private var isReviewNotesExpanded = true @FocusState private var ticketsListFocused: Bool var body: some View { @@ -53,13 +52,11 @@ struct TicketsView: View { content } } - .fontDesign(.monospaced) .padding(.horizontal, 24) .padding(.top, 12) .padding(.bottom, 24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { - loadPersistedReviewNotes() await refreshLinearRouting() loadCachedTickets() await refreshTickets() @@ -77,9 +74,6 @@ struct TicketsView: View { .onChange(of: selectedOriginFilter) { _, _ in updateSelectionAfterRefresh() } - .onChange(of: reviewNotesStorage) { _, _ in - loadPersistedReviewNotes() - } .onReceive( NotificationCenter.default.publisher(for: .hackTicketReviewQueueRequested) ) { notification in @@ -129,33 +123,10 @@ struct TicketsView: View { originFilterMenu searchField if !linearRouteStatusLabel.isEmpty { - ticketHeaderBadge( - title: linearRouteStatusLabel, - systemImage: linearRouteStatus?.tokenResolved == true ? "point.3.connected.trianglepath.dotted" : "exclamationmark.triangle", - tone: linearRouteStatus?.tokenResolved == true ? .secondary : .orange - ) + linearRouteStatusButton } - Button { - openProjectRouting() - } label: { - ticketHeaderBadge( - title: "Project routing", - systemImage: "slider.horizontal.3", - tone: .secondary - ) - } - .buttonStyle(.plain) if reviewQueueCount > 0 { - Button { - activateReviewQueue() - } label: { - ticketHeaderBadge( - title: reviewQueueLabel, - systemImage: "bubble.left.and.exclamationmark.bubble.right", - tone: selectedFilter == .reviewQueue ? .orange : .secondary - ) - } - .buttonStyle(.plain) + reviewQueueButton } Spacer(minLength: 12) if let activeSyncAction { @@ -167,21 +138,6 @@ struct TicketsView: View { .foregroundStyle(.secondary) } } - Button { - requestBulkSyncConfirmation(.pullLinear) - } label: { - Label("Pull Linear", systemImage: "arrow.down.left") - } - .adaptiveToolbarButton() - .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) - Button { - requestBulkSyncConfirmation(.pushHack) - } label: { - Label("Push Hack", systemImage: "arrow.up.right") - } - .adaptiveToolbarButton() - .disabled(!canSyncProjectToLinear || isAnySyncInFlight) - newTicketButton Menu { Button("Refresh") { Task { await refreshTickets() } @@ -227,11 +183,14 @@ struct TicketsView: View { openProjectRouting() } } label: { - Image(systemName: "ellipsis.circle") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.primary.opacity(0.7)) + ticketHeaderBadge( + title: "Sync", + systemImage: "arrow.triangle.branch", + tone: .secondary + ) } - .buttonStyle(PressableIconButtonStyle()) + .buttonStyle(.plain) + newTicketButton } linearSyncPolicyCallout @@ -255,7 +214,7 @@ struct TicketsView: View { TicketReviewQueueEntry( ticket: ticket, detail: ticketDetailCache[ticket.ticketId], - localNoteCount: reviewNotes(for: ticket).count + reviewNoteCount: reviewNotes(for: ticket).count ) } .sorted { lhs, rhs in @@ -290,9 +249,12 @@ struct TicketsView: View { if linearRouteStatus?.tokenResolved != true { return "Linear not connected" } - if !linearRouteProjectName.isEmpty { + if !linearRouteProjectName.isEmpty && linearAdditionalProjects.isEmpty { return linearRouteProjectName } + if !linearRouteProjectName.isEmpty { + return "\(linearRouteProjectName) + \(linearAdditionalProjects.count)" + } if !linearRouteProjectId.isEmpty { return linearRouteProjectId } @@ -303,7 +265,7 @@ struct TicketsView: View { } private var canSyncProjectFromLinear: Bool { - linearRouteStatus?.tokenResolved == true && !linearRouteProjectId.isEmpty + linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting } private var canSyncProjectToLinear: Bool { @@ -325,13 +287,16 @@ struct TicketsView: View { ) } + @ViewBuilder private var linearSyncPolicyCallout: some View { - InlineCallout( - tone: linearSyncPolicyTone, - title: "Linear sync policy", - message: linearSyncPolicyMessage, - actions: linearSyncPolicyActions - ) + if linearSyncPolicyTone == .warn { + InlineCallout( + tone: linearSyncPolicyTone, + title: "Linear sync policy", + message: linearSyncPolicyMessage, + actions: linearSyncPolicyActions + ) + } } @ViewBuilder @@ -347,22 +312,23 @@ struct TicketsView: View { } private var linearSyncPolicyTone: StatusTone { - if linearRouteStatus?.tokenResolved != true || linearRouteProjectId.isEmpty { + if linearRouteStatus?.tokenResolved != true || !hasAnyLinearProjectRouting { return .warn } return .neutral } private var linearSyncPolicyMessage: String { - let baseMessage = - "Authority is origin-derived from the ticket owner. Comments stay append-only in arrival order. Assignee, labels, and dependencies are the mergeable fields and may need review." if linearRouteStatus?.tokenResolved != true { - return "\(baseMessage) Connect a Linear account to enable project pull and per-ticket refresh." + return "Connect a Linear account to pull issues and refresh linked tickets. Hack or Linear ownership decides which side wins when fields diverge." } - if linearRouteProjectId.isEmpty { - return "\(baseMessage) This project still needs a Linear route before bulk pull can target the right project." + if !hasAnyLinearProjectRouting { + return "This project needs a Linear route before bulk pull can target the right project. Comments stay append-only, and mergeable fields may still need review." } - return "\(baseMessage) Pull Linear when Linear owns the ticket. Push Hack when Hack owns the ticket and you want to publish it outward." + if !linearAdditionalProjects.isEmpty { + return "Bulk pull includes the default route plus \(linearAdditionalProjects.count) linked Linear project\(linearAdditionalProjects.count == 1 ? "" : "s"). Comments stay append-only, and mergeable fields may still need review." + } + return "Pull Linear when Linear owns the ticket. Push Hack when Hack owns the ticket and you want to publish it outward." } private var linearSyncPolicyActions: [InlineCalloutAction] { @@ -376,7 +342,7 @@ struct TicketsView: View { }, ] } - if linearRouteProjectId.isEmpty { + if !hasAnyLinearProjectRouting { return [ InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { openProjectRouting() @@ -386,6 +352,13 @@ struct TicketsView: View { return [] } + private var hasAnyLinearProjectRouting: Bool { + if !linearRouteProjectId.isEmpty { + return true + } + return !linearAdditionalProjects.isEmpty + } + private var loadNoticeTone: StatusTone { guard let loadNotice else { return .neutral } let normalized = loadNotice.lowercased() @@ -443,7 +416,7 @@ struct TicketsView: View { } ] } - if linearRouteProjectId.isEmpty { + if !hasAnyLinearProjectRouting { return [ InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { openProjectRouting() @@ -462,6 +435,34 @@ struct TicketsView: View { return [] } + private var linearRouteStatusButton: some View { + Button { + openProjectRouting() + } label: { + ticketHeaderBadge( + title: linearRouteStatusLabel, + systemImage: linearRouteStatus?.tokenResolved == true + ? "point.3.connected.trianglepath.dotted" + : "exclamationmark.triangle", + tone: linearRouteStatus?.tokenResolved == true ? .secondary : .orange + ) + } + .buttonStyle(.plain) + } + + private var reviewQueueButton: some View { + Button { + activateReviewQueue() + } label: { + ticketHeaderBadge( + title: reviewQueueLabel, + systemImage: "bubble.left.and.exclamationmark.bubble.right", + tone: selectedFilter == .reviewQueue ? .orange : .secondary + ) + } + .buttonStyle(.plain) + } + private var statusFilterMenu: some View { Menu { ForEach(TicketFilter.allCases, id: \.self) { filter in @@ -655,7 +656,7 @@ struct TicketsView: View { tone: .secondary ) ticketMetaPill( - "\(filteredReviewQueueEntries.reduce(0) { $0 + $1.localNoteCount }) local note\(filteredReviewQueueEntries.reduce(0) { $0 + $1.localNoteCount } == 1 ? "" : "s")", + "\(filteredReviewQueueEntries.reduce(0) { $0 + $1.reviewNoteCount }) review note\(filteredReviewQueueEntries.reduce(0) { $0 + $1.reviewNoteCount } == 1 ? "" : "s")", tone: .secondary ) } @@ -897,9 +898,9 @@ struct TicketsView: View { "\(entry.commentCount) comment\(entry.commentCount == 1 ? "" : "s")", tone: .secondary ) - if entry.localNoteCount > 0 { + if entry.reviewNoteCount > 0 { ticketMetaPill( - "\(entry.localNoteCount) local note\(entry.localNoteCount == 1 ? "" : "s")", + "\(entry.reviewNoteCount) review note\(entry.reviewNoteCount == 1 ? "" : "s")", tone: .secondary ) } @@ -1045,7 +1046,7 @@ struct TicketsView: View { ticketMetaPill("\(review.resolvedConflictCount) resolved", tone: review.resolvedConflictCount > 0 ? .green : .secondary) if reviewNotes(for: detail.ticket).count > 0 { let noteCount = reviewNotes(for: detail.ticket).count - ticketMetaPill("\(noteCount) local note\(noteCount == 1 ? "" : "s")", tone: .secondary) + ticketMetaPill("\(noteCount) review note\(noteCount == 1 ? "" : "s")", tone: .secondary) } if let checkpointSummary = review.checkpointSummary { ticketMetaPill(checkpointSummary, tone: .secondary) @@ -1120,7 +1121,7 @@ struct TicketsView: View { Text("Review notes") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) - ticketMetaPill("Local to Hack Desktop", tone: .secondary) + ticketMetaPill("Shared in ticket history", tone: .secondary) if !savedNotes.isEmpty { ticketMetaPill("\(savedNotes.count) saved", tone: .secondary) } @@ -1129,7 +1130,7 @@ struct TicketsView: View { InlineCallout( tone: detail.linearSyncReviewState.needsReview ? .warn : .neutral, title: "Compose a review comment", - message: "Draft follow-up from conflicts and comment history here. You can keep it local as a review note or append it to the ticket as an immutable Hack comment that sync can push to Linear later.", + message: "Draft follow-up from conflicts and comment history here. Save it as a repo-shared review note or append it to the ticket as an immutable Hack comment that sync can push to Linear later.", actions: [ InlineCalloutAction(label: "Use review summary", systemImage: "sparkles") { applyReviewDraftTemplate(for: detail) @@ -1145,7 +1146,11 @@ struct TicketsView: View { ForEach(savedNotes.sorted(by: { $0.createdAt > $1.createdAt })) { note in VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { - ticketMetaPill("Saved note", tone: .secondary) + ticketMetaPill("Review note", tone: .secondary) + ticketMetaPill(note.actor, tone: .secondary) + if let context = note.context, !context.isEmpty { + ticketMetaPill(context, tone: .secondary) + } Text(humanReadableDetailDate(note.createdAt)) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) @@ -1188,7 +1193,7 @@ struct TicketsView: View { .padding(.vertical, 6) .frame(minHeight: 128) if reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text("Draft a local review comment, quote an imported comment, or stage a conflict summary.") + Text("Draft a shared review note, quote an imported comment, or stage a conflict summary.") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) .padding(.horizontal, 14) @@ -1219,12 +1224,15 @@ struct TicketsView: View { } Button { - saveReviewDraft(for: detail.ticket) + Task { await postReviewDraftAsReviewNote(for: detail.ticket) } } label: { - Label("Save local note", systemImage: "tray.and.arrow.down") + Label("Save review note", systemImage: "tray.and.arrow.down") } .adaptiveToolbarButtonProminent() - .disabled(reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled( + reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + postingReviewNoteTicketIds.contains(detail.ticket.ticketId) + ) Button { Task { await postReviewDraftAsTicketComment(for: detail.ticket) } @@ -1243,7 +1251,9 @@ struct TicketsView: View { .adaptiveToolbarButton() .disabled(reviewDraft(for: detail.ticket).isEmpty) - if postingCommentTicketIds.contains(detail.ticket.ticketId) { + if postingReviewNoteTicketIds.contains(detail.ticket.ticketId) || + postingCommentTicketIds.contains(detail.ticket.ticketId) + { ProgressView() .controlSize(.small) } @@ -1925,34 +1935,21 @@ struct TicketsView: View { } private func refreshLinearRouting() async { - async let projectProfile = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.profile" - ) - async let projectId = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.projectId" - ) - async let projectName = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.projectName" - ) - async let teamId = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.teamId" - ) + async let binding = model.inspectLinearProjectBinding(for: project) async let defaultProfile = model.getGlobalConfig( key: "controlPlane.extensions[\"dance.hack.linear\"].config.defaultProfile" ) - let resolvedProjectProfile = (await projectProfile ?? "") + let resolvedBinding = await binding + let resolvedProjectProfile = (resolvedBinding?.profileId ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) let resolvedDefaultProfile = (await defaultProfile ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) linearRouteProfile = resolvedProjectProfile.isEmpty ? resolvedDefaultProfile : resolvedProjectProfile - linearRouteProjectId = (await projectId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - linearRouteProjectName = (await projectName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - linearRouteTeamId = (await teamId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + linearRouteProjectId = (resolvedBinding?.projectId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + linearRouteProjectName = (resolvedBinding?.projectName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + linearRouteTeamId = (resolvedBinding?.teamId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + linearAdditionalProjects = resolvedBinding?.additionalProjects ?? [] linearRouteStatus = await model.inspectLinearStatus( profileId: linearRouteProfile.isEmpty ? nil : linearRouteProfile ) @@ -1965,7 +1962,7 @@ struct TicketsView: View { for: project, from: "linear", ownerMode: nil, - projectId: linearRouteProjectId.isEmpty ? nil : linearRouteProjectId, + projectId: nil, teamId: nil, limit: nil, syncLabels: nil @@ -1974,7 +1971,8 @@ struct TicketsView: View { loadNotice = model.errorMessage ?? "Linear sync failed." return } - loadNotice = "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from Linear." + let routedProjects = result.projectIds?.count ?? (linearAdditionalProjects.isEmpty ? 1 : linearAdditionalProjects.count + 1) + loadNotice = "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from \(routedProjects) routed Linear project\(routedProjects == 1 ? "" : "s")." await refreshTickets() } } @@ -1986,8 +1984,8 @@ struct TicketsView: View { for: project, from: "hack", ownerMode: "hack", - projectId: linearRouteProjectId.isEmpty ? nil : linearRouteProjectId, - teamId: linearRouteTeamId.isEmpty ? nil : linearRouteTeamId, + projectId: nil, + teamId: nil, limit: nil, syncLabels: nil ) @@ -2008,8 +2006,8 @@ struct TicketsView: View { from: "hack", issueIdentifier: nil, ticketId: ticket.ticketId, - projectId: linearRouteProjectId.isEmpty ? nil : linearRouteProjectId, - teamId: linearRouteTeamId.isEmpty ? nil : linearRouteTeamId, + projectId: nil, + teamId: nil, syncLabels: nil ) guard let result, result.ok else { @@ -2097,20 +2095,19 @@ struct TicketsView: View { ticketsListFocused = true } - private func ticketReviewStorageKey(for ticket: TicketSummary) -> String { - "\(project.id):\(ticket.ticketId)" - } - - private func reviewNotes(for ticket: TicketSummary) -> [TicketLocalReviewNote] { - reviewNotesByTicketKey[ticketReviewStorageKey(for: ticket)] ?? [] + private func reviewNotes(for ticket: TicketSummary) -> [TicketReviewNote] { + if let detail = ticketDetail, detail.ticket.ticketId == ticket.ticketId { + return detail.reviewNotes + } + return ticketDetailCache[ticket.ticketId]?.reviewNotes ?? [] } private func reviewDraft(for ticket: TicketSummary) -> String { - reviewComposerDrafts[ticketReviewStorageKey(for: ticket)] ?? "" + reviewComposerDrafts[ticket.ticketId] ?? "" } private func reviewDraftBinding(for ticket: TicketSummary) -> Binding { - let key = ticketReviewStorageKey(for: ticket) + let key = ticket.ticketId return Binding( get: { reviewComposerDrafts[key] ?? "" }, set: { newValue in @@ -2119,30 +2116,8 @@ struct TicketsView: View { ) } - private func loadPersistedReviewNotes() { - guard let data = reviewNotesStorage.data(using: .utf8) else { - reviewNotesByTicketKey = [:] - return - } - do { - reviewNotesByTicketKey = try JSONDecoder().decode([String: [TicketLocalReviewNote]].self, from: data) - } catch { - reviewNotesByTicketKey = [:] - } - } - - private func persistReviewNotes() { - do { - let data = try JSONEncoder().encode(reviewNotesByTicketKey) - reviewNotesStorage = String(decoding: data, as: UTF8.self) - } catch { - loadNotice = "Failed to save local review notes." - } - } - private func applyReviewDraftTemplate(for detail: TicketDetailResponse) { - reviewComposerDrafts[ticketReviewStorageKey(for: detail.ticket)] = TicketReviewComposer.draft(for: detail) - isReviewNotesExpanded = true + reviewComposerDrafts[detail.ticket.ticketId] = TicketReviewComposer.draft(for: detail) } private func appendQuotedCommentToReviewDraft(_ comment: TicketComment, ticket: TicketSummary) { @@ -2155,12 +2130,11 @@ struct TicketsView: View { return } let draft = TicketReviewComposer.draft(for: detail, highlightedConflict: conflict) - reviewComposerDrafts[ticketReviewStorageKey(for: detail.ticket)] = draft - isReviewNotesExpanded = true + reviewComposerDrafts[detail.ticket.ticketId] = draft } private func appendToReviewDraft(_ text: String, ticket: TicketSummary) { - let key = ticketReviewStorageKey(for: ticket) + let key = ticket.ticketId let existing = reviewComposerDrafts[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -2171,30 +2145,40 @@ struct TicketsView: View { } else { reviewComposerDrafts[key] = "\(existing)\n\n\(trimmed)" } - isReviewNotesExpanded = true } - private func saveReviewDraft(for ticket: TicketSummary) { - let key = ticketReviewStorageKey(for: ticket) + private func postReviewDraftAsReviewNote(for ticket: TicketSummary) async { + let key = ticket.ticketId let trimmed = (reviewComposerDrafts[key] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { + loadNotice = "Draft a review note before saving it." + return + } + guard !postingReviewNoteTicketIds.contains(ticket.ticketId) else { return } - let note = TicketLocalReviewNote( - noteId: UUID().uuidString, - createdAt: TicketDateFormatter.isoBasic.string(from: Date()), - markdown: trimmed + + postingReviewNoteTicketIds.insert(ticket.ticketId) + defer { postingReviewNoteTicketIds.remove(ticket.ticketId) } + + let response = await model.appendTicketReviewNote( + for: project, + ticketId: ticket.ticketId, + body: trimmed ) - var existing = reviewNotesByTicketKey[key] ?? [] - existing.append(note) - reviewNotesByTicketKey[key] = existing + guard response != nil else { + loadNotice = model.errorMessage ?? "Failed to save review note." + return + } + reviewComposerDrafts[key] = "" - persistReviewNotes() - loadNotice = "Saved a local review note." + loadNotice = "Saved a shared review note to \(ticket.ticketId)." + await refreshTickets() + await loadTicketDetail() } private func postReviewDraftAsTicketComment(for ticket: TicketSummary) async { - let key = ticketReviewStorageKey(for: ticket) + let key = ticket.ticketId let trimmed = (reviewComposerDrafts[key] ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -2226,7 +2210,7 @@ struct TicketsView: View { } private func clearReviewDraft(for ticket: TicketSummary) { - reviewComposerDrafts[ticketReviewStorageKey(for: ticket)] = "" + reviewComposerDrafts[ticket.ticketId] = "" } private func copyReviewDraft(for ticket: TicketSummary) { diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 0506ce7f3..af833c767 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -550,6 +550,66 @@ public actor HackCLIClient { return try decodeJsonOrThrow(LinearAssigneeMappingRemovalResponse.self, result: result) } + public func listLinearAutosyncSubscriptions( + profileId: String? = nil, + projectId: String? = nil, + teamId: String? = nil + ) async throws -> LinearAutosyncSubscriptionsResponse { + var args = ["x", "linear", "subscriptions", "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-id", projectId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearAutosyncSubscriptionsResponse.self, result: result) + } + + public func setLinearAutosyncSubscription( + profileId: String? = nil, + projectId: String? = nil, + teamId: String? = nil, + mode: String = "auto_apply", + status: String = "active" + ) async throws -> LinearAutosyncSubscriptionMutationResponse { + var args = ["x", "linear", "set-subscription", "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-id", projectId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + args.append(contentsOf: ["--mode", mode, "--status", status]) + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearAutosyncSubscriptionMutationResponse.self, result: result) + } + + public func removeLinearAutosyncSubscription( + profileId: String? = nil, + projectId: String? = nil, + teamId: String? = nil + ) async throws -> LinearAutosyncSubscriptionMutationResponse { + var args = ["x", "linear", "remove-subscription", "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-id", projectId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearAutosyncSubscriptionMutationResponse.self, result: result) + } + public func bindLinearProject( path: String, profileId: String?, @@ -578,9 +638,48 @@ public actor HackCLIClient { return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) } + public func inspectLinearProjectBinding(path: String) async throws -> LinearProjectBindingResponse { + let result = try await run(["x", "linear", "project-bind", "--json"], allowNonZeroExit: true, cwd: path) + return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) + } + + public func linkLinearProject( + path: String, + profileId: String?, + projectId: String, + projectName: String?, + teamId: String? + ) async throws -> LinearProjectBindingResponse { + var args = ["x", "linear", "project-link", "--json", "--project-id", projectId] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let projectName, !projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-name", projectName]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + let result = try await run(args, allowNonZeroExit: true, cwd: path) + return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) + } + + public func unlinkLinearProject( + path: String, + projectId: String + ) async throws -> LinearProjectBindingResponse { + let result = try await run( + ["x", "linear", "project-unlink", "--json", "--project-id", projectId], + allowNonZeroExit: true, + cwd: path + ) + return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) + } + public func syncLinearProject( path: String, from direction: String, + profileId: String? = nil, ownerMode: String? = nil, projectId: String? = nil, teamId: String? = nil, @@ -588,6 +687,9 @@ public actor HackCLIClient { syncLabels: Bool? = nil ) async throws -> LinearProjectSyncResponse { var args = ["x", "linear", "sync-project", "--from", direction, "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } if let ownerMode, !ownerMode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { args.append(contentsOf: ["--owner", ownerMode]) } @@ -610,6 +712,7 @@ public actor HackCLIClient { public func syncLinearIssue( path: String, from direction: String, + profileId: String? = nil, issueIdentifier: String? = nil, ticketId: String? = nil, projectId: String? = nil, @@ -617,6 +720,9 @@ public actor HackCLIClient { syncLabels: Bool? = nil ) async throws -> LinearIssueSyncResponse { var args = ["x", "linear", "sync-issue", "--from", direction, "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } if let issueIdentifier, !issueIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { args.append(contentsOf: ["--issue", issueIdentifier]) } @@ -1112,6 +1218,28 @@ public actor HackCLIClient { return try decodeLenient(TicketCommentAppendResponse.self, from: result.stdout) } + public func appendTicketReviewNote( + path: String, + ticketId: String, + body: String, + actor: String? = nil + ) async throws -> TicketReviewNoteAppendResponse { + var args = [ + "x", + "tickets", + "review-note", + ticketId, + "--body", + body, + "--json", + ] + if let actor, !actor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--actor", actor]) + } + let result = try await run(args, cwd: path) + return try decodeLenient(TicketReviewNoteAppendResponse.self, from: result.stdout) + } + public func resolveTicketConflict( path: String, ticketId: String, diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 14381cd08..22482524a 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -1482,6 +1482,60 @@ public struct LinearAssigneeMappingRemovalResponse: Decodable, Hashable { } } +public struct LinearAutosyncSubscription: Decodable, Hashable, Identifiable { + public let id: String + public let profileId: String + public let projectId: String? + public let teamId: String? + public let mode: String + public let status: String + public let updatedAt: String? + + public init( + id: String, + profileId: String, + projectId: String?, + teamId: String?, + mode: String, + status: String, + updatedAt: String? + ) { + self.id = id + self.profileId = profileId + self.projectId = projectId + self.teamId = teamId + self.mode = mode + self.status = status + self.updatedAt = updatedAt + } +} + +public struct LinearAutosyncSubscriptionsResponse: Decodable, Hashable { + public let profileId: String + public let subscriptions: [LinearAutosyncSubscription] + + public init( + profileId: String, + subscriptions: [LinearAutosyncSubscription] + ) { + self.profileId = profileId + self.subscriptions = subscriptions + } +} + +public struct LinearAutosyncSubscriptionMutationResponse: Decodable, Hashable { + public let profileId: String + public let subscription: LinearAutosyncSubscription + + public init( + profileId: String, + subscription: LinearAutosyncSubscription + ) { + self.profileId = profileId + self.subscription = subscription + } +} + public struct LinearProjectBindingResponse: Decodable, Hashable { public let ok: Bool public let cleared: Bool? @@ -1489,6 +1543,9 @@ public struct LinearProjectBindingResponse: Decodable, Hashable { public let projectId: String? public let projectName: String? public let teamId: String? + public let additionalProjects: [LinearProjectBindingTarget] + public let additionalProjectChanged: Bool? + public let removedProjectId: String? public init( ok: Bool, @@ -1496,7 +1553,10 @@ public struct LinearProjectBindingResponse: Decodable, Hashable { profileId: String?, projectId: String?, projectName: String?, - teamId: String? + teamId: String?, + additionalProjects: [LinearProjectBindingTarget] = [], + additionalProjectChanged: Bool? = nil, + removedProjectId: String? = nil ) { self.ok = ok self.cleared = cleared @@ -1504,6 +1564,54 @@ public struct LinearProjectBindingResponse: Decodable, Hashable { self.projectId = projectId self.projectName = projectName self.teamId = teamId + self.additionalProjects = additionalProjects + self.additionalProjectChanged = additionalProjectChanged + self.removedProjectId = removedProjectId + } + + public var defaultProject: LinearProjectBindingTarget? { + guard let projectId else { + return nil + } + return LinearProjectBindingTarget( + profileId: profileId, + projectId: projectId, + projectName: projectName, + teamId: teamId + ) + } + + public var allProjects: [LinearProjectBindingTarget] { + var projects: [LinearProjectBindingTarget] = [] + if let defaultProject { + projects.append(defaultProject) + } + projects.append(contentsOf: additionalProjects) + return projects + } +} + +public struct LinearProjectBindingTarget: Decodable, Hashable, Identifiable { + public let profileId: String? + public let projectId: String + public let projectName: String? + public let teamId: String? + + public var id: String { + let profile = profileId ?? "" + return "\(profile)::\(projectId)" + } + + public init( + profileId: String?, + projectId: String, + projectName: String?, + teamId: String? + ) { + self.profileId = profileId + self.projectId = projectId + self.projectName = projectName + self.teamId = teamId } } @@ -1531,12 +1639,20 @@ public struct LinearIssueSyncResponse: Decodable, Hashable { public struct LinearProjectSyncResponse: Decodable, Hashable { public let ok: Bool + public let projectIds: [String]? public let processed: Int public let created: Int public let updated: Int - public init(ok: Bool, processed: Int, created: Int, updated: Int) { + public init( + ok: Bool, + projectIds: [String]? = nil, + processed: Int, + created: Int, + updated: Int + ) { self.ok = ok + self.projectIds = projectIds self.processed = processed self.created = created self.updated = updated @@ -2506,7 +2622,7 @@ public struct TicketReviewQueueEntry: Hashable, Identifiable { public let summary: String public let commentCount: Int public let openConflictCount: Int - public let localNoteCount: Int + public let reviewNoteCount: Int public let updatedAt: String public var id: String { ticketId } @@ -2514,9 +2630,9 @@ public struct TicketReviewQueueEntry: Hashable, Identifiable { public init?( ticket: TicketSummary, detail: TicketDetailResponse? = nil, - localNoteCount: Int = 0 + reviewNoteCount: Int = 0 ) { - let normalizedLocalNoteCount = max(localNoteCount, 0) + let normalizedReviewNoteCount = max(reviewNoteCount, 0) let summary: String let badgeLabel: String let commentCount: Int @@ -2543,22 +2659,57 @@ public struct TicketReviewQueueEntry: Hashable, Identifiable { self.summary = summary self.commentCount = commentCount self.openConflictCount = openConflictCount - self.localNoteCount = normalizedLocalNoteCount + self.reviewNoteCount = normalizedReviewNoteCount updatedAt = detail?.ticket.updatedAt ?? ticket.updatedAt } } -public struct TicketLocalReviewNote: Codable, Hashable, Identifiable { +public struct TicketReviewNote: Decodable, Hashable, Identifiable { public let noteId: String - public let createdAt: String + public let ticketId: String public let markdown: String + public let actor: String + public let createdAt: String + public let context: String? public var id: String { noteId } - public init(noteId: String, createdAt: String, markdown: String) { + public init( + noteId: String, + ticketId: String, + markdown: String, + actor: String, + createdAt: String, + context: String? + ) { self.noteId = noteId - self.createdAt = createdAt + self.ticketId = ticketId self.markdown = markdown + self.actor = actor + self.createdAt = createdAt + self.context = context + } + + private enum CodingKeys: String, CodingKey { + case noteId + case ticketId + case markdown + case body + case actor + case createdAt + case context + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + noteId = try container.decode(String.self, forKey: .noteId) + ticketId = try container.decode(String.self, forKey: .ticketId) + markdown = + try container.decodeIfPresent(String.self, forKey: .markdown) + ?? container.decode(String.self, forKey: .body) + actor = try container.decode(String.self, forKey: .actor) + createdAt = try container.decode(String.self, forKey: .createdAt) + context = try container.decodeIfPresent(String.self, forKey: .context) } } @@ -2668,6 +2819,7 @@ public struct TicketsListResponse: Decodable { public struct TicketDetailResponse: Decodable { public let ticket: TicketSummary public let comments: [TicketComment] + public let reviewNotes: [TicketReviewNote] public let syncCheckpoints: [TicketSyncCheckpoint] public let conflicts: [TicketSyncConflict] public let events: [TicketEvent] @@ -2675,6 +2827,7 @@ public struct TicketDetailResponse: Decodable { public init(ticket: TicketSummary, events: [TicketEvent]) { self.ticket = ticket comments = [] + reviewNotes = [] syncCheckpoints = [] conflicts = [] self.events = events @@ -2683,12 +2836,14 @@ public struct TicketDetailResponse: Decodable { public init( ticket: TicketSummary, comments: [TicketComment], + reviewNotes: [TicketReviewNote], syncCheckpoints: [TicketSyncCheckpoint], conflicts: [TicketSyncConflict], events: [TicketEvent] ) { self.ticket = ticket self.comments = comments + self.reviewNotes = reviewNotes self.syncCheckpoints = syncCheckpoints self.conflicts = conflicts self.events = events @@ -2697,6 +2852,7 @@ public struct TicketDetailResponse: Decodable { private enum CodingKeys: String, CodingKey { case ticket case comments + case reviewNotes case syncCheckpoints case conflicts case events @@ -2706,6 +2862,8 @@ public struct TicketDetailResponse: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) ticket = try container.decode(TicketSummary.self, forKey: .ticket) comments = try container.decodeIfPresent([TicketComment].self, forKey: .comments) ?? [] + reviewNotes = + try container.decodeIfPresent([TicketReviewNote].self, forKey: .reviewNotes) ?? [] syncCheckpoints = try container.decodeIfPresent([TicketSyncCheckpoint].self, forKey: .syncCheckpoints) ?? [] conflicts = try container.decodeIfPresent([TicketSyncConflict].self, forKey: .conflicts) ?? [] events = try container.decodeIfPresent([TicketEvent].self, forKey: .events) ?? [] @@ -2789,6 +2947,14 @@ public struct TicketCommentAppendResponse: Decodable { } } +public struct TicketReviewNoteAppendResponse: Decodable { + public let reviewNote: TicketReviewNote + + public init(reviewNote: TicketReviewNote) { + self.reviewNote = reviewNote + } +} + public struct TicketsSyncResponse: Decodable { public let sync: TicketsSyncResult diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift index 3bcaa0058..451376c1b 100644 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift @@ -50,6 +50,16 @@ final class TicketDetailReviewModelTests: XCTestCase { "createdAt": "2026-03-05T16:20:00Z" } ], + "reviewNotes": [ + { + "noteId": "note-1", + "ticketId": "T-00042", + "markdown": "Follow up after assignee review.", + "actor": "alice@hack", + "createdAt": "2026-03-05T16:25:00Z", + "context": "conflict_review" + } + ], "syncCheckpoints": [ { "checkpointId": "cp-1", @@ -115,6 +125,8 @@ final class TicketDetailReviewModelTests: XCTestCase { XCTAssertEqual(detail.ticket.assignee, "alice@hack") XCTAssertEqual(detail.comments.count, 2) + XCTAssertEqual(detail.reviewNotes.count, 1) + XCTAssertEqual(detail.reviewNotes.first?.markdown, "Follow up after assignee review.") XCTAssertEqual(detail.syncCheckpoints.count, 1) XCTAssertEqual(detail.conflicts.count, 2) XCTAssertEqual(detail.latestSyncCheckpoint?.remoteCursor, "issue/ENG-42#v2") @@ -281,13 +293,13 @@ final class TicketDetailReviewModelTests: XCTestCase { ] ) - let entry = TicketReviewQueueEntry(ticket: detail.ticket, detail: detail, localNoteCount: 2) + let entry = TicketReviewQueueEntry(ticket: detail.ticket, detail: detail, reviewNoteCount: 2) XCTAssertEqual(entry?.ticketId, detail.ticket.ticketId) XCTAssertEqual(entry?.badgeLabel, "1 open conflict") XCTAssertEqual(entry?.commentCount, 1) XCTAssertEqual(entry?.openConflictCount, 1) - XCTAssertEqual(entry?.localNoteCount, 2) + XCTAssertEqual(entry?.reviewNoteCount, 2) } func testReviewComposerDraftIncludesConflictAndLatestComment() { @@ -340,15 +352,36 @@ final class TicketDetailReviewModelTests: XCTestCase { XCTAssertTrue(draft.contains("> Imported from Linear.")) } + func testReviewNoteDecodesLegacyBodyField() throws { + let data = Data( + #""" + { + "noteId": "note-legacy", + "ticketId": "T-00042", + "body": "Legacy review note body.", + "actor": "alice@hack", + "createdAt": "2026-03-05T16:25:00Z" + } + """#.utf8 + ) + + let note = try JSONDecoder().decode(TicketReviewNote.self, from: data) + + XCTAssertEqual(note.noteId, "note-legacy") + XCTAssertEqual(note.markdown, "Legacy review note body.") + } + private func makeDetail( ticket: TicketSummary, comments: [TicketComment], + reviewNotes: [TicketReviewNote] = [], syncCheckpoints: [TicketSyncCheckpoint], conflicts: [TicketSyncConflict] ) -> TicketDetailResponse { TicketDetailResponse( ticket: ticket, comments: comments, + reviewNotes: reviewNotes, syncCheckpoints: syncCheckpoints, conflicts: conflicts, events: [] diff --git a/packages/db/src/schema/core.ts b/packages/db/src/schema/core.ts index 84f6f2f84..bcd6d32af 100644 --- a/packages/db/src/schema/core.ts +++ b/packages/db/src/schema/core.ts @@ -146,8 +146,8 @@ export const linearSyncSubscriptions = pgTable("linear_sync_subscriptions", { mode: text("mode").notNull().default("manual"), status: text("status").notNull().default("active"), betterAuthUserId: text("better_auth_user_id"), - organizationId: text("organization_id"), - ownerTeamId: text("owner_team_id"), + betterAuthOrganizationId: text("better_auth_organization_id"), + betterAuthTeamId: text("better_auth_team_id"), configJson: text("config_json").notNull().default("{}"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() diff --git a/services/auth-broker/src/app.ts b/services/auth-broker/src/app.ts index acc6af843..2976b617c 100644 --- a/services/auth-broker/src/app.ts +++ b/services/auth-broker/src/app.ts @@ -10,6 +10,12 @@ import { createBetterAuthPlugin } from "./modules/better-auth/plugin.ts"; import { createCoreRoutesPlugin } from "./modules/core/plugin.ts"; import { createGitHubOAuthPlugin } from "./modules/github-oauth/plugin.ts"; import { createLinearAgentPlugin } from "./modules/linear-agent/plugin.ts"; +import { createLinearAutosyncPlugin } from "./modules/linear-autosync/plugin.ts"; +import { + createLinearAutosyncStoreFromDb, + InMemoryLinearAutosyncStore, + type LinearAutosyncStore, +} from "./modules/linear-autosync/service.ts"; import { createLinearConnectionsPlugin } from "./modules/linear-connections/plugin.ts"; import { createLinearConnectionStoreFromDb, @@ -32,6 +38,7 @@ export type CreateAuthBrokerAppOptions = { readonly betterAuthRuntime?: BetterAuthRuntime; readonly linearSyncStore?: LinearSyncStore; readonly linearConnectionStore?: LinearConnectionStore; + readonly linearAutosyncStore?: LinearAutosyncStore; }; /** @@ -43,6 +50,7 @@ export function createAuthBrokerApp({ betterAuthRuntime: externalBetterAuthRuntime, linearSyncStore: externalLinearSyncStore, linearConnectionStore: externalLinearConnectionStore, + linearAutosyncStore: externalLinearAutosyncStore, }: CreateAuthBrokerAppOptions) { const flowStore = externalStore ?? new FlowStore({ filePath: config.flowStorePath }); @@ -52,6 +60,8 @@ export function createAuthBrokerApp({ externalLinearSyncStore ?? createDefaultLinearSyncStore(); const linearConnectionStore = externalLinearConnectionStore ?? createDefaultLinearConnectionStore(); + const linearAutosyncStore = + externalLinearAutosyncStore ?? createDefaultLinearAutosyncStore(); let flowSweepTimer: ReturnType | null = null; return new Elysia({ @@ -99,6 +109,13 @@ export function createAuthBrokerApp({ config, syncStore: linearSyncStore, connectionStore: linearConnectionStore, + autosyncStore: linearAutosyncStore, + }) + ) + .use( + createLinearAutosyncPlugin({ + autosyncStore: linearAutosyncStore, + betterAuthRuntime, }) ) .use( @@ -163,6 +180,19 @@ function createDefaultLinearConnectionStore(): LinearConnectionStore { } } +function createDefaultLinearAutosyncStore(): LinearAutosyncStore { + if (!process.env.DATABASE_URL) { + return new InMemoryLinearAutosyncStore(); + } + try { + return createLinearAutosyncStoreFromDb({ + databaseUrl: process.env.DATABASE_URL, + }); + } catch { + return new InMemoryLinearAutosyncStore(); + } +} + /** * Identify route paths that are intentionally GET-only in broker v1. */ @@ -185,6 +215,12 @@ function isReadOnlyRoutePath(input: { readonly path: string }): boolean { if (input.path === "/v1/auth/linear/refresh") { return false; } + if (input.path === "/v1/auth/linear/subscriptions") { + return false; + } + if (input.path === "/v1/auth/linear/subscriptions/remove") { + return false; + } if ( input.path.startsWith("/v1/auth/linear/deliveries/") && input.path.endsWith("/apply") diff --git a/services/auth-broker/src/db/schema.ts b/services/auth-broker/src/db/schema.ts index fb49aee2c..4cc67746b 100644 --- a/services/auth-broker/src/db/schema.ts +++ b/services/auth-broker/src/db/schema.ts @@ -49,3 +49,23 @@ export const linearWebhookEvents = pgTable("linear_webhook_events", { .defaultNow(), appliedAt: timestamp("applied_at", { withTimezone: true }), }); + +export const linearSyncSubscriptions = pgTable("linear_sync_subscriptions", { + id: uuid("id").defaultRandom().primaryKey(), + subscriptionKey: text("subscription_key").notNull().unique(), + profileId: text("profile_id").notNull(), + projectId: text("project_id"), + teamId: text("team_id"), + mode: text("mode").notNull().default("manual"), + status: text("status").notNull().default("active"), + betterAuthUserId: text("better_auth_user_id"), + betterAuthOrganizationId: text("better_auth_organization_id"), + betterAuthTeamId: text("better_auth_team_id"), + configJson: text("config_json").notNull().default("{}"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); diff --git a/services/auth-broker/src/flow-store.ts b/services/auth-broker/src/flow-store.ts index 979206618..05b005ce5 100644 --- a/services/auth-broker/src/flow-store.ts +++ b/services/auth-broker/src/flow-store.ts @@ -81,6 +81,8 @@ export class FlowStore { readonly tokenExpiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; readonly installationId?: string; }): OAuthFlow | null { const flow = this.getById(opts.flowId); @@ -92,6 +94,8 @@ export class FlowStore { flow.tokenExpiresAt = opts.tokenExpiresAt; flow.refreshToken = opts.refreshToken; flow.refreshTokenExpiresAt = opts.refreshTokenExpiresAt; + flow.managementToken = opts.managementToken; + flow.managementTokenExpiresAt = opts.managementTokenExpiresAt; flow.installationId = opts.installationId; flow.status = "complete"; flow.completedAt = new Date().toISOString(); @@ -175,6 +179,12 @@ export class FlowStore { ...(flow.refreshTokenExpiresAt ? { refreshTokenExpiresAt: flow.refreshTokenExpiresAt } : {}), + ...(flow.managementToken + ? { managementToken: flow.managementToken } + : {}), + ...(flow.managementTokenExpiresAt + ? { managementTokenExpiresAt: flow.managementTokenExpiresAt } + : {}), }; flow.status = "claimed"; flow.claimedAt = claimedAt; @@ -182,6 +192,8 @@ export class FlowStore { flow.tokenExpiresAt = undefined; flow.refreshToken = undefined; flow.refreshTokenExpiresAt = undefined; + flow.managementToken = undefined; + flow.managementTokenExpiresAt = undefined; changed = true; if (changed) { this.persist(); @@ -294,6 +306,10 @@ function toPublicStatus(flow: OAuthFlow): OAuthFlowPublicStatus { ...(flow.account?.installationIds ? { installationIds: flow.account.installationIds } : {}), + ...(flow.managementToken ? { managementToken: flow.managementToken } : {}), + ...(flow.managementTokenExpiresAt + ? { managementTokenExpiresAt: flow.managementTokenExpiresAt } + : {}), ...(flow.appId ? { appId: flow.appId } : {}), ...(flow.appSlug ? { appSlug: flow.appSlug } : {}), ...(flow.appInstallUrl ? { appInstallUrl: flow.appInstallUrl } : {}), @@ -388,6 +404,10 @@ function parsePersistedFlow(input: { const refreshTokenExpiresAt = asOptionalString( input.value.refreshTokenExpiresAt ); + const managementToken = asOptionalString(input.value.managementToken); + const managementTokenExpiresAt = asOptionalString( + input.value.managementTokenExpiresAt + ); const error = asOptionalString(input.value.error); const completedAt = asOptionalString(input.value.completedAt); const claimedAt = asOptionalString(input.value.claimedAt); @@ -413,6 +433,8 @@ function parsePersistedFlow(input: { ...(tokenExpiresAt ? { tokenExpiresAt } : {}), ...(refreshToken ? { refreshToken } : {}), ...(refreshTokenExpiresAt ? { refreshTokenExpiresAt } : {}), + ...(managementToken ? { managementToken } : {}), + ...(managementTokenExpiresAt ? { managementTokenExpiresAt } : {}), ...(error ? { error } : {}), ...(completedAt ? { completedAt } : {}), ...(claimedAt ? { claimedAt } : {}), diff --git a/services/auth-broker/src/modules/better-auth/management-token.ts b/services/auth-broker/src/modules/better-auth/management-token.ts new file mode 100644 index 000000000..83d59c693 --- /dev/null +++ b/services/auth-broker/src/modules/better-auth/management-token.ts @@ -0,0 +1,230 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +const MANAGEMENT_TOKEN_VERSION = 1 as const; +const DEFAULT_MANAGEMENT_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const MANAGEMENT_TOKEN_TYPE = "hack_linear_management" as const; + +export type BrokerManagementTokenClaims = { + readonly typ: typeof MANAGEMENT_TOKEN_TYPE; + readonly v: typeof MANAGEMENT_TOKEN_VERSION; + readonly sub: string; + readonly profileId?: string; + readonly organizationId?: string; + readonly teamId?: string; + readonly iat: number; + readonly exp: number; +}; + +/** + * Issue a signed broker management token for non-browser clients that still + * need Better Auth-owned access to protected Linear broker routes. + */ +export function issueBrokerManagementToken(input: { + readonly userId: string; + readonly profileId?: string | null; + readonly organizationId?: string | null; + readonly teamId?: string | null; + readonly nowMs?: number; + readonly expiresInMs?: number; +}): { readonly token: string; readonly expiresAt: string } | null { + const secret = resolveManagementTokenSecret(); + const userId = normalizeOptionalString(input.userId); + if (!(secret && userId)) { + return null; + } + const nowMs = input.nowMs ?? Date.now(); + const expiresInMs = Math.max( + input.expiresInMs ?? DEFAULT_MANAGEMENT_TOKEN_TTL_MS, + 60_000 + ); + const claims: BrokerManagementTokenClaims = { + typ: MANAGEMENT_TOKEN_TYPE, + v: MANAGEMENT_TOKEN_VERSION, + sub: userId, + ...(normalizeOptionalString(input.profileId) + ? { profileId: normalizeOptionalString(input.profileId) ?? undefined } + : {}), + ...(normalizeOptionalString(input.organizationId) + ? { + organizationId: + normalizeOptionalString(input.organizationId) ?? undefined, + } + : {}), + ...(normalizeOptionalString(input.teamId) + ? { teamId: normalizeOptionalString(input.teamId) ?? undefined } + : {}), + iat: Math.floor(nowMs / 1000), + exp: Math.floor((nowMs + expiresInMs) / 1000), + }; + const header = { + alg: "HS256", + typ: MANAGEMENT_TOKEN_TYPE, + v: MANAGEMENT_TOKEN_VERSION, + } as const; + const encodedHeader = encodeBase64UrlJson(header); + const encodedPayload = encodeBase64UrlJson(claims); + const signedContent = `${encodedHeader}.${encodedPayload}`; + const signature = signToken({ + secret, + content: signedContent, + }); + return { + token: `${signedContent}.${signature}`, + expiresAt: new Date(claims.exp * 1000).toISOString(), + }; +} + +/** + * Verify a broker management token and return the claims if the token is + * correctly signed and not expired. + */ +export function verifyBrokerManagementToken(input: { + readonly token: string; + readonly nowMs?: number; +}): + | { readonly ok: true; readonly claims: BrokerManagementTokenClaims } + | { readonly ok: false; readonly error: string } { + const secret = resolveManagementTokenSecret(); + const token = normalizeOptionalString(input.token); + if (!secret) { + return { + ok: false, + error: "management_token_secret_missing", + }; + } + if (!token) { + return { + ok: false, + error: "management_token_missing", + }; + } + const parts = token.split("."); + if (parts.length !== 3) { + return { + ok: false, + error: "management_token_invalid", + }; + } + const [encodedHeader = "", encodedPayload = "", encodedSignature = ""] = + parts; + const signedContent = `${encodedHeader}.${encodedPayload}`; + const expectedSignature = signToken({ + secret, + content: signedContent, + }); + if (!safeSignatureEquals(encodedSignature, expectedSignature)) { + return { + ok: false, + error: "management_token_invalid", + }; + } + const parsedClaims = parseClaims({ + value: decodeBase64UrlJson(encodedPayload), + }); + if (!parsedClaims) { + return { + ok: false, + error: "management_token_invalid", + }; + } + const nowSeconds = Math.floor((input.nowMs ?? Date.now()) / 1000); + if (parsedClaims.exp <= nowSeconds) { + return { + ok: false, + error: "management_token_expired", + }; + } + return { + ok: true, + claims: parsedClaims, + }; +} + +function resolveManagementTokenSecret(): string | null { + return ( + normalizeOptionalString(process.env.BETTER_AUTH_SECRET) ?? + normalizeOptionalString(process.env.AUTH_SECRET) ?? + null + ); +} + +function signToken(input: { + readonly secret: string; + readonly content: string; +}): string { + return createHmac("sha256", input.secret) + .update(input.content, "utf8") + .digest("base64url"); +} + +function safeSignatureEquals(actual: string, expected: string): boolean { + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + if (actualBuffer.length !== expectedBuffer.length) { + return false; + } + return timingSafeEqual(actualBuffer, expectedBuffer); +} + +function encodeBase64UrlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function decodeBase64UrlJson(value: string): unknown { + try { + return JSON.parse( + Buffer.from(value, "base64url").toString("utf8") + ) as unknown; + } catch { + return null; + } +} + +function parseClaims(input: { + readonly value: unknown; +}): BrokerManagementTokenClaims | null { + if (!(typeof input.value === "object" && input.value !== null)) { + return null; + } + const record = input.value as Record; + const typ = normalizeOptionalString(record.typ); + const sub = normalizeOptionalString(record.sub); + const profileId = normalizeOptionalString(record.profileId); + const organizationId = normalizeOptionalString(record.organizationId); + const teamId = normalizeOptionalString(record.teamId); + const iat = readPositiveInteger(record.iat); + const exp = readPositiveInteger(record.exp); + if ( + !( + typ === MANAGEMENT_TOKEN_TYPE && + record.v === MANAGEMENT_TOKEN_VERSION && + sub && + iat !== null && + exp !== null + ) + ) { + return null; + } + return { + typ: MANAGEMENT_TOKEN_TYPE, + v: MANAGEMENT_TOKEN_VERSION, + sub, + ...(profileId ? { profileId } : {}), + ...(organizationId ? { organizationId } : {}), + ...(teamId ? { teamId } : {}), + iat, + exp, + }; +} + +function normalizeOptionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function readPositiveInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : null; +} diff --git a/services/auth-broker/src/modules/better-auth/session.ts b/services/auth-broker/src/modules/better-auth/session.ts index 6e2c60b92..133d455db 100644 --- a/services/auth-broker/src/modules/better-auth/session.ts +++ b/services/auth-broker/src/modules/better-auth/session.ts @@ -1,10 +1,22 @@ import type { BetterAuthRuntime } from "../../better-auth.ts"; +import { verifyBrokerManagementToken } from "./management-token.ts"; + +const AUTHORIZATION_BEARER_PATTERN = /^Bearer\s+(.+)$/i; export type BrokerBetterAuthSession = { readonly userId: string; readonly email: string | null; readonly name: string | null; readonly organizationId: string | null; + readonly teamId: string | null; + readonly managementTokenProfileId: string | null; +}; + +export type BetterAuthOwnedRecord = { + readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; + readonly profileId?: string | null; }; export type BetterAuthSessionResolution = @@ -16,6 +28,7 @@ export type BetterAuthSessionResolution = | { readonly enabled: true; readonly accessControlMode: + | "better_auth_team_owned" | "better_auth_session_owned" | "better_auth_organization_owned"; readonly session: BrokerBetterAuthSession | null; @@ -42,23 +55,145 @@ export async function resolveBetterAuthSession(input: { const session = await input.runtime.auth.api.getSession({ headers: input.request.headers, }); - const resolvedSession = session?.user?.id - ? { - userId: session.user.id, - email: normalizeOptionalString(session.user.email), - name: normalizeOptionalString(session.user.name), - organizationId: extractBetterAuthOrganizationId(session), - } - : null; + const resolvedSession = + (session?.user?.id ? toResolvedSession({ session }) : null) ?? + resolveManagementTokenSession({ + request: input.request, + }); + const accessControlMode = resolveAccessControlMode({ + session: resolvedSession, + }); return { enabled: true, - accessControlMode: resolvedSession?.organizationId - ? "better_auth_organization_owned" - : "better_auth_session_owned", + accessControlMode, session: resolvedSession, }; } +function resolveManagementTokenSession(input: { + readonly request: Request; +}): BrokerBetterAuthSession | null { + const token = readBearerToken({ + authorizationHeader: input.request.headers.get("authorization"), + }); + if (!token) { + return null; + } + const verification = verifyBrokerManagementToken({ token }); + if (!verification.ok) { + return null; + } + return { + userId: verification.claims.sub, + email: null, + name: null, + organizationId: verification.claims.organizationId ?? null, + teamId: verification.claims.teamId ?? null, + managementTokenProfileId: verification.claims.profileId ?? null, + }; +} + +function toResolvedSession(input: { + readonly session: NonNullable< + Awaited< + ReturnType< + NonNullable["api"]>["getSession"] + > + > + >; +}): BrokerBetterAuthSession { + return { + userId: input.session.user.id, + email: normalizeOptionalString(input.session.user.email), + name: normalizeOptionalString(input.session.user.name), + organizationId: extractBetterAuthOrganizationId(input.session), + teamId: extractBetterAuthTeamId(input.session), + managementTokenProfileId: null, + }; +} + +function resolveAccessControlMode(input: { + readonly session: BrokerBetterAuthSession | null; +}): + | "better_auth_team_owned" + | "better_auth_session_owned" + | "better_auth_organization_owned" { + if (input.session?.teamId) { + return "better_auth_team_owned"; + } + if (input.session?.organizationId) { + return "better_auth_organization_owned"; + } + return "better_auth_session_owned"; +} + +export function hasBetterAuthAccess(input: { + readonly session: BrokerBetterAuthSession; + readonly record: BetterAuthOwnedRecord; +}): boolean { + if ( + !hasBetterAuthProfileAccess({ + session: input.session, + profileId: normalizeOptionalString(input.record.profileId), + }) + ) { + return false; + } + + const recordTeamId = normalizeOptionalString(input.record.betterAuthTeamId); + if (recordTeamId) { + if (normalizeOptionalString(input.session.teamId) !== recordTeamId) { + return false; + } + const recordOrganizationId = normalizeOptionalString( + input.record.betterAuthOrganizationId + ); + const sessionOrganizationId = normalizeOptionalString( + input.session.organizationId + ); + if ( + recordOrganizationId && + sessionOrganizationId && + recordOrganizationId !== sessionOrganizationId + ) { + return false; + } + return true; + } + + const recordOrganizationId = normalizeOptionalString( + input.record.betterAuthOrganizationId + ); + if (recordOrganizationId) { + return ( + normalizeOptionalString(input.session.organizationId) === + recordOrganizationId + ); + } + + return ( + normalizeOptionalString(input.record.betterAuthUserId) === + normalizeOptionalString(input.session.userId) + ); +} + +export function hasBetterAuthProfileAccess(input: { + readonly session: BrokerBetterAuthSession | null; + readonly profileId?: string | null; +}): boolean { + const managementTokenProfileId = normalizeOptionalString( + input.session?.managementTokenProfileId + ); + if (!managementTokenProfileId) { + return true; + } + const requestedProfileId = normalizeOptionalString(input.profileId); + if (!requestedProfileId) { + return true; + } + return requestedProfileId === managementTokenProfileId; +} + function extractBetterAuthOrganizationId(session: unknown): string | null { const record = readRecord(session); const sessionRecord = readRecord(record?.session); @@ -73,6 +208,20 @@ function extractBetterAuthOrganizationId(session: unknown): string | null { ); } +function extractBetterAuthTeamId(session: unknown): string | null { + const record = readRecord(session); + const sessionRecord = readRecord(record?.session); + return ( + normalizeOptionalString(sessionRecord?.activeTeamId) ?? + normalizeOptionalString(sessionRecord?.teamId) ?? + readNestedRecordId(sessionRecord?.activeTeam) ?? + normalizeOptionalString(record?.activeTeamId) ?? + normalizeOptionalString(record?.teamId) ?? + readNestedRecordId(record?.activeTeam) ?? + null + ); +} + function readRecord(value: unknown): Record | null { return typeof value === "object" && value !== null ? (value as Record) @@ -89,3 +238,14 @@ function normalizeOptionalString(value: unknown): string | null { ? value.trim() : null; } + +function readBearerToken(input: { + readonly authorizationHeader: string | null; +}): string | null { + const header = normalizeOptionalString(input.authorizationHeader); + if (!header) { + return null; + } + const match = AUTHORIZATION_BEARER_PATTERN.exec(header); + return match ? normalizeOptionalString(match[1]) : null; +} diff --git a/services/auth-broker/src/modules/linear-agent/plugin.ts b/services/auth-broker/src/modules/linear-agent/plugin.ts index 11a1303f1..a13dfdc7f 100644 --- a/services/auth-broker/src/modules/linear-agent/plugin.ts +++ b/services/auth-broker/src/modules/linear-agent/plugin.ts @@ -3,6 +3,7 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { Elysia } from "elysia"; import type { BrokerConfig } from "../../config.ts"; +import type { LinearAutosyncStore } from "../linear-autosync/service.ts"; import type { LinearConnectionStore } from "../linear-connections/service.ts"; import type { LinearSyncStore } from "../linear-sync-store/service.ts"; @@ -15,6 +16,7 @@ type CreateLinearAgentPluginOptions = { readonly config: BrokerConfig; readonly syncStore: LinearSyncStore; readonly connectionStore: LinearConnectionStore; + readonly autosyncStore: LinearAutosyncStore; }; type LinearWebhookEvent = { @@ -42,6 +44,8 @@ type LinearWebhookRouteResult = { readonly eventType: string | null; readonly action: string | null; readonly webhookTimestamp: string | null; + readonly autoApplied?: boolean; + readonly subscriptionId?: string; } | { readonly ok: false; @@ -67,6 +71,7 @@ export function createLinearAgentPlugin({ config, syncStore, connectionStore, + autosyncStore, }: CreateLinearAgentPluginOptions) { const app = new Elysia({ name: "hack-auth-broker.linear-agent", @@ -81,6 +86,7 @@ export function createLinearAgentPlugin({ config, syncStore, connectionStore, + autosyncStore, }); set.status = result.statusCode; return result.body; @@ -94,6 +100,7 @@ async function handleLinearWebhookRequest(input: { readonly config: BrokerConfig; readonly syncStore: LinearSyncStore; readonly connectionStore: LinearConnectionStore; + readonly autosyncStore: LinearAutosyncStore; }): Promise { const prepared = await prepareWebhookRequest({ request: input.request, @@ -112,6 +119,7 @@ async function handleLinearWebhookRequest(input: { prepared: prepared.value, syncStore: input.syncStore, connectionStore: input.connectionStore, + autosyncStore: input.autosyncStore, }); } @@ -163,6 +171,7 @@ async function persistWebhookDelivery(input: { readonly prepared: PreparedWebhookRequest; readonly syncStore: LinearSyncStore; readonly connectionStore: LinearConnectionStore; + readonly autosyncStore: LinearAutosyncStore; }): Promise { try { const ownership = await input.connectionStore.resolveWebhookOwnership({ @@ -196,6 +205,17 @@ async function persistWebhookDelivery(input: { input.prepared.event.organizationId ?? ownership.organizationId ?? null, teamId: input.prepared.event.teamId ?? ownership.teamId ?? null, }); + const matchingSubscription = + ownership.status === "matched" + ? await input.autosyncStore.findMatchingSubscription({ + profileId: delivery.profileId, + projectId: delivery.projectId, + teamId: delivery.teamId, + betterAuthUserId: delivery.betterAuthUserId, + betterAuthOrganizationId: delivery.betterAuthOrganizationId, + betterAuthTeamId: delivery.betterAuthTeamId, + }) + : null; return { statusCode: 202, body: { @@ -208,6 +228,9 @@ async function persistWebhookDelivery(input: { eventType: input.prepared.event.type ?? null, action: input.prepared.event.action ?? null, webhookTimestamp: input.prepared.event.webhookTimestamp ?? null, + ...(matchingSubscription + ? { subscriptionId: matchingSubscription.id } + : {}), }, }; } catch { @@ -347,7 +370,9 @@ function parseLinearWebhookEvent(input: { const profileId = normalizeString(input.payload.profileId) ?? normalizeString(organization?.id); - const organizationId = normalizeString(organization?.id); + const organizationId = + normalizeString(organization?.id) ?? + normalizeString(input.payload.organizationId); return { ok: true, event: { diff --git a/services/auth-broker/src/modules/linear-autosync/plugin.ts b/services/auth-broker/src/modules/linear-autosync/plugin.ts new file mode 100644 index 000000000..3c72db618 --- /dev/null +++ b/services/auth-broker/src/modules/linear-autosync/plugin.ts @@ -0,0 +1,249 @@ +import { Elysia, t } from "elysia"; + +import type { BetterAuthRuntime } from "../../better-auth.ts"; +import { + hasBetterAuthAccess, + hasBetterAuthProfileAccess, + resolveBetterAuthSession, +} from "../better-auth/session.ts"; +import type { + LinearAutosyncMode, + LinearAutosyncStatus, + LinearAutosyncStore, +} from "./service.ts"; + +const AUTOSYNC_MODES = ["manual", "auto_apply"] as const; +const AUTOSYNC_STATUSES = ["active", "paused"] as const; + +const listSubscriptionsQuerySchema = t.Object({ + profileId: t.Optional(t.String()), + projectId: t.Optional(t.String()), + teamId: t.Optional(t.String()), +}); + +const upsertSubscriptionBodySchema = t.Object({ + profileId: t.String(), + projectId: t.Optional(t.String()), + teamId: t.Optional(t.String()), + mode: t.Optional(t.String()), + status: t.Optional(t.String()), +}); + +const removeSubscriptionBodySchema = t.Object({ + profileId: t.String(), + projectId: t.Optional(t.String()), + teamId: t.Optional(t.String()), +}); + +type CreateLinearAutosyncPluginOptions = { + readonly autosyncStore: LinearAutosyncStore; + readonly betterAuthRuntime: BetterAuthRuntime; +}; + +export function createLinearAutosyncPlugin({ + autosyncStore, + betterAuthRuntime, +}: CreateLinearAutosyncPluginOptions) { + return new Elysia({ + name: "hack-auth-broker.linear-autosync", + }) + .get( + "/v1/auth/linear/subscriptions", + async ({ query, request, set }) => { + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } + if ( + !hasBetterAuthProfileAccess({ + session: session.session, + profileId: normalizeOptionalQueryValue(query.profileId), + }) + ) { + set.status = 403; + return { + ok: false, + error: "better_auth_profile_forbidden", + } as const; + } + + const subscriptions = await autosyncStore.listSubscriptions({ + profileId: normalizeOptionalQueryValue(query.profileId), + projectId: normalizeOptionalQueryValue(query.projectId), + teamId: normalizeOptionalQueryValue(query.teamId), + }); + + const activeSession = session.session; + + return { + ok: true, + accessControlMode: session.accessControlMode, + subscriptions: activeSession + ? subscriptions.filter((subscription) => + hasBetterAuthAccess({ + session: activeSession, + record: subscription, + }) + ) + : subscriptions, + } as const; + }, + { query: listSubscriptionsQuerySchema } + ) + .post( + "/v1/auth/linear/subscriptions", + async ({ body, request, set }) => { + const mode = normalizeMode(body.mode); + if (!mode) { + set.status = 400; + return { ok: false, error: "invalid_linear_autosync_mode" } as const; + } + const status = normalizeStatus(body.status); + if (!status) { + set.status = 400; + return { + ok: false, + error: "invalid_linear_autosync_status", + } as const; + } + + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } + if ( + !hasBetterAuthProfileAccess({ + session: session.session, + profileId: body.profileId, + }) + ) { + set.status = 403; + return { + ok: false, + error: "better_auth_profile_forbidden", + } as const; + } + + const subscription = await autosyncStore.upsertSubscription({ + profileId: body.profileId, + projectId: normalizeOptionalQueryValue(body.projectId), + teamId: normalizeOptionalQueryValue(body.teamId), + mode, + status, + betterAuthUserId: session.session?.userId ?? null, + betterAuthOrganizationId: session.session?.organizationId ?? null, + betterAuthTeamId: session.session?.teamId ?? null, + }); + return { ok: true, subscription } as const; + }, + { body: upsertSubscriptionBodySchema } + ) + .post( + "/v1/auth/linear/subscriptions/remove", + async ({ body, request, set }) => { + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } + if ( + !hasBetterAuthProfileAccess({ + session: session.session, + profileId: body.profileId, + }) + ) { + set.status = 403; + return { + ok: false, + error: "better_auth_profile_forbidden", + } as const; + } + + const existing = + ( + await autosyncStore.listSubscriptions({ + profileId: body.profileId, + projectId: normalizeOptionalQueryValue(body.projectId), + teamId: normalizeOptionalQueryValue(body.teamId), + }) + ).find( + (subscription) => + !session.session || + hasBetterAuthAccess({ + session: session.session, + record: subscription, + }) + ) ?? null; + if (!existing) { + set.status = 404; + return { + ok: false, + error: "linear_autosync_subscription_not_found", + } as const; + } + + const removed = await autosyncStore.removeSubscription({ + profileId: body.profileId, + projectId: normalizeOptionalQueryValue(body.projectId), + teamId: normalizeOptionalQueryValue(body.teamId), + }); + if (!removed) { + set.status = 404; + return { + ok: false, + error: "linear_autosync_subscription_not_found", + } as const; + } + return { ok: true, subscription: removed } as const; + }, + { body: removeSubscriptionBodySchema } + ); +} + +function normalizeOptionalQueryValue(value?: string): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeMode(value?: string): LinearAutosyncMode | null { + if (AUTOSYNC_MODES.includes(value as LinearAutosyncMode)) { + return value as LinearAutosyncMode; + } + if (value == null) { + return "auto_apply"; + } + return null; +} + +function normalizeStatus(value?: string): LinearAutosyncStatus | null { + if (AUTOSYNC_STATUSES.includes(value as LinearAutosyncStatus)) { + return value as LinearAutosyncStatus; + } + if (value == null) { + return "active"; + } + return null; +} diff --git a/services/auth-broker/src/modules/linear-autosync/service.ts b/services/auth-broker/src/modules/linear-autosync/service.ts new file mode 100644 index 000000000..46f34cefe --- /dev/null +++ b/services/auth-broker/src/modules/linear-autosync/service.ts @@ -0,0 +1,450 @@ +import { randomUUID } from "node:crypto"; + +import { and, desc, eq, type SQL, sql } from "drizzle-orm"; + +import { linearSyncSubscriptions } from "../../db/schema.ts"; +import { createDbClient } from "../../db.ts"; + +export type LinearAutosyncMode = "manual" | "auto_apply"; +export type LinearAutosyncStatus = "active" | "paused"; +export type LinearAutosyncConfig = Record; + +export type LinearAutosyncSubscription = { + readonly id: string; + readonly subscriptionKey: string; + readonly profileId: string; + readonly projectId: string | null; + readonly teamId: string | null; + readonly mode: LinearAutosyncMode; + readonly status: LinearAutosyncStatus; + readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; + readonly config: LinearAutosyncConfig; + readonly createdAt: string; + readonly updatedAt: string; +}; + +export type UpsertLinearAutosyncSubscriptionInput = { + readonly profileId: string; + readonly projectId?: string | null; + readonly teamId?: string | null; + readonly mode?: LinearAutosyncMode; + readonly status?: LinearAutosyncStatus; + readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; + readonly config?: LinearAutosyncConfig; +}; + +export type ListLinearAutosyncSubscriptionsInput = { + readonly profileId?: string | null; + readonly projectId?: string | null; + readonly teamId?: string | null; +}; + +export type RemoveLinearAutosyncSubscriptionInput = { + readonly profileId: string; + readonly projectId?: string | null; + readonly teamId?: string | null; +}; + +export type MatchLinearAutosyncSubscriptionInput = { + readonly profileId?: string | null; + readonly projectId?: string | null; + readonly teamId?: string | null; + readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; +}; + +export type LinearAutosyncStore = { + readonly upsertSubscription: ( + input: UpsertLinearAutosyncSubscriptionInput + ) => Promise; + readonly listSubscriptions: ( + input?: ListLinearAutosyncSubscriptionsInput + ) => Promise; + readonly removeSubscription: ( + input: RemoveLinearAutosyncSubscriptionInput + ) => Promise; + readonly findMatchingSubscription: ( + input: MatchLinearAutosyncSubscriptionInput + ) => Promise; +}; + +export class InMemoryLinearAutosyncStore implements LinearAutosyncStore { + private readonly recordsByKey = new Map(); + + upsertSubscription( + input: UpsertLinearAutosyncSubscriptionInput + ): Promise { + const subscriptionKey = buildSubscriptionKey(input); + const existing = this.recordsByKey.get(subscriptionKey); + const now = new Date().toISOString(); + const record: LinearAutosyncSubscription = { + id: existing?.id ?? randomUUID(), + subscriptionKey, + profileId: normalizeRequiredText(input.profileId), + projectId: normalizeText(input.projectId), + teamId: normalizeText(input.teamId), + mode: normalizeMode(input.mode), + status: normalizeStatus(input.status), + betterAuthUserId: normalizeText(input.betterAuthUserId), + betterAuthOrganizationId: normalizeText(input.betterAuthOrganizationId), + betterAuthTeamId: normalizeText(input.betterAuthTeamId), + config: { ...(input.config ?? {}) }, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + this.recordsByKey.set(subscriptionKey, record); + return Promise.resolve(record); + } + + listSubscriptions( + input: ListLinearAutosyncSubscriptionsInput = {} + ): Promise { + return Promise.resolve( + [...this.recordsByKey.values()] + .filter((record) => + matchesSubscriptionFilter({ record, filter: input }) + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + ); + } + + removeSubscription( + input: RemoveLinearAutosyncSubscriptionInput + ): Promise { + const subscriptionKey = buildSubscriptionKey(input); + const existing = this.recordsByKey.get(subscriptionKey) ?? null; + if (existing) { + this.recordsByKey.delete(subscriptionKey); + } + return Promise.resolve(existing); + } + + async findMatchingSubscription( + input: MatchLinearAutosyncSubscriptionInput + ): Promise { + const matches = ( + await this.listSubscriptions({ + profileId: input.profileId, + projectId: input.projectId, + teamId: input.teamId, + }) + ).filter( + (record) => + record.status === "active" && + record.mode === "auto_apply" && + matchesOwnership({ + record, + betterAuthUserId: input.betterAuthUserId, + betterAuthOrganizationId: input.betterAuthOrganizationId, + betterAuthTeamId: input.betterAuthTeamId, + }) + ); + + return matches.length === 1 ? (matches[0] ?? null) : null; + } +} + +export function createLinearAutosyncStoreFromDb(input: { + readonly databaseUrl: string; +}): LinearAutosyncStore { + const db = createDbClient({ databaseUrl: input.databaseUrl }); + const ensureTable = createSubscriptionsTableEnsurer({ db }); + + return { + upsertSubscription: async (subscription) => { + await ensureTable(); + const subscriptionKey = buildSubscriptionKey(subscription); + const now = new Date(); + const configJson = JSON.stringify(subscription.config ?? {}); + const inserted = await db + .insert(linearSyncSubscriptions) + .values({ + id: randomUUID(), + subscriptionKey, + profileId: normalizeRequiredText(subscription.profileId), + projectId: normalizeText(subscription.projectId), + teamId: normalizeText(subscription.teamId), + mode: normalizeMode(subscription.mode), + status: normalizeStatus(subscription.status), + betterAuthUserId: normalizeText(subscription.betterAuthUserId), + betterAuthOrganizationId: normalizeText( + subscription.betterAuthOrganizationId + ), + betterAuthTeamId: normalizeText(subscription.betterAuthTeamId), + configJson, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: linearSyncSubscriptions.subscriptionKey, + set: { + mode: normalizeMode(subscription.mode), + status: normalizeStatus(subscription.status), + betterAuthUserId: normalizeText(subscription.betterAuthUserId), + betterAuthOrganizationId: normalizeText( + subscription.betterAuthOrganizationId + ), + betterAuthTeamId: normalizeText(subscription.betterAuthTeamId), + configJson, + updatedAt: now, + }, + }) + .returning(); + + const row = inserted[0]; + if (!row) { + throw new Error("Failed to persist Linear autosync subscription."); + } + return toAutosyncSubscription({ row }); + }, + + listSubscriptions: async (input = {}) => { + await ensureTable(); + const filters = buildListFilters({ input }); + const rows = + filters.length === 0 + ? await db + .select() + .from(linearSyncSubscriptions) + .orderBy(desc(linearSyncSubscriptions.updatedAt)) + : await db + .select() + .from(linearSyncSubscriptions) + .where(and(...filters)) + .orderBy(desc(linearSyncSubscriptions.updatedAt)); + return rows + .map((row) => toAutosyncSubscription({ row })) + .filter((record) => + matchesSubscriptionFilter({ record, filter: input }) + ); + }, + + removeSubscription: async (inputToRemove) => { + await ensureTable(); + const subscriptionKey = buildSubscriptionKey(inputToRemove); + const existing = await db + .select() + .from(linearSyncSubscriptions) + .where(eq(linearSyncSubscriptions.subscriptionKey, subscriptionKey)) + .limit(1); + const row = existing[0]; + if (!row) { + return null; + } + await db + .delete(linearSyncSubscriptions) + .where(eq(linearSyncSubscriptions.subscriptionKey, subscriptionKey)); + return toAutosyncSubscription({ row }); + }, + + findMatchingSubscription: async (inputToMatch) => { + await ensureTable(); + const profileId = normalizeRequiredText(inputToMatch.profileId); + const projectId = normalizeText(inputToMatch.projectId); + const teamId = normalizeText(inputToMatch.teamId); + const matches = ( + await db + .select() + .from(linearSyncSubscriptions) + .where( + and( + eq(linearSyncSubscriptions.profileId, profileId), + ...(projectId + ? [eq(linearSyncSubscriptions.projectId, projectId)] + : []), + ...(teamId ? [eq(linearSyncSubscriptions.teamId, teamId)] : []), + eq(linearSyncSubscriptions.status, "active"), + eq(linearSyncSubscriptions.mode, "auto_apply") + ) + ) + ) + .map((row) => toAutosyncSubscription({ row })) + .filter((record) => + matchesOwnership({ + record, + betterAuthUserId: inputToMatch.betterAuthUserId, + betterAuthOrganizationId: inputToMatch.betterAuthOrganizationId, + betterAuthTeamId: inputToMatch.betterAuthTeamId, + }) + ); + + return matches.length === 1 ? (matches[0] ?? null) : null; + }, + }; +} + +function createSubscriptionsTableEnsurer(input: { + readonly db: ReturnType; +}) { + let promise: Promise | null = null; + return async (): Promise => { + if (!promise) { + promise = ensureSubscriptionsTable({ db: input.db }); + } + await promise; + }; +} + +async function ensureSubscriptionsTable(input: { + readonly db: ReturnType; +}): Promise { + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS linear_sync_subscriptions ( + id uuid PRIMARY KEY, + subscription_key text NOT NULL UNIQUE, + profile_id text NOT NULL, + project_id text, + team_id text, + mode text NOT NULL DEFAULT 'manual', + status text NOT NULL DEFAULT 'active', + better_auth_user_id text, + better_auth_organization_id text, + better_auth_team_id text, + config_json text NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); +} + +function buildSubscriptionKey(input: { + readonly profileId: string; + readonly projectId?: string | null; + readonly teamId?: string | null; +}): string { + return [ + normalizeRequiredText(input.profileId), + normalizeText(input.projectId) ?? "*", + normalizeText(input.teamId) ?? "*", + ].join("::"); +} + +function buildListFilters(input: { + readonly input: ListLinearAutosyncSubscriptionsInput; +}): SQL[] { + const filters: SQL[] = []; + const profileId = normalizeText(input.input.profileId); + if (profileId) { + filters.push(eq(linearSyncSubscriptions.profileId, profileId)); + } + const projectId = normalizeText(input.input.projectId); + if (projectId) { + filters.push(eq(linearSyncSubscriptions.projectId, projectId)); + } + const teamId = normalizeText(input.input.teamId); + if (teamId) { + filters.push(eq(linearSyncSubscriptions.teamId, teamId)); + } + return filters; +} + +function matchesSubscriptionFilter(input: { + readonly record: LinearAutosyncSubscription; + readonly filter: ListLinearAutosyncSubscriptionsInput; +}): boolean { + const profileId = normalizeText(input.filter.profileId); + if (profileId && input.record.profileId !== profileId) { + return false; + } + const projectId = normalizeText(input.filter.projectId); + if (projectId && input.record.projectId !== projectId) { + return false; + } + const teamId = normalizeText(input.filter.teamId); + if (teamId && input.record.teamId !== teamId) { + return false; + } + return true; +} + +function matchesOwnership(input: { + readonly record: LinearAutosyncSubscription; + readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; +}): boolean { + const subscriptionTeamId = normalizeText(input.record.betterAuthTeamId); + if (subscriptionTeamId) { + return subscriptionTeamId === normalizeText(input.betterAuthTeamId); + } + + const subscriptionOrganizationId = normalizeText( + input.record.betterAuthOrganizationId + ); + if (subscriptionOrganizationId) { + return ( + subscriptionOrganizationId === + normalizeText(input.betterAuthOrganizationId) + ); + } + + const subscriptionUserId = normalizeText(input.record.betterAuthUserId); + if (subscriptionUserId) { + return subscriptionUserId === normalizeText(input.betterAuthUserId); + } + + return false; +} + +function toAutosyncSubscription(input: { + readonly row: typeof linearSyncSubscriptions.$inferSelect; +}): LinearAutosyncSubscription { + return { + id: input.row.id, + subscriptionKey: input.row.subscriptionKey, + profileId: input.row.profileId, + projectId: input.row.projectId ?? null, + teamId: input.row.teamId ?? null, + mode: normalizeMode(input.row.mode), + status: normalizeStatus(input.row.status), + betterAuthUserId: input.row.betterAuthUserId ?? null, + betterAuthOrganizationId: input.row.betterAuthOrganizationId ?? null, + betterAuthTeamId: input.row.betterAuthTeamId ?? null, + config: parseConfigJson({ raw: input.row.configJson }), + createdAt: input.row.createdAt.toISOString(), + updatedAt: input.row.updatedAt.toISOString(), + }; +} + +function parseConfigJson(input: { + readonly raw: string; +}): LinearAutosyncConfig { + try { + const parsed = JSON.parse(input.raw) as unknown; + return typeof parsed === "object" && parsed !== null + ? (parsed as LinearAutosyncConfig) + : {}; + } catch { + return {}; + } +} + +function normalizeMode(value: string | null | undefined): LinearAutosyncMode { + return value === "auto_apply" ? "auto_apply" : "manual"; +} + +function normalizeStatus( + value: string | null | undefined +): LinearAutosyncStatus { + return value === "paused" ? "paused" : "active"; +} + +function normalizeRequiredText(value: string | null | undefined): string { + const normalized = normalizeText(value); + if (!normalized) { + throw new Error("Linear autosync subscription requires profileId."); + } + return normalized; +} + +function normalizeText(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/services/auth-broker/src/modules/linear-connections/plugin.ts b/services/auth-broker/src/modules/linear-connections/plugin.ts index dac5b9ddb..852e89281 100644 --- a/services/auth-broker/src/modules/linear-connections/plugin.ts +++ b/services/auth-broker/src/modules/linear-connections/plugin.ts @@ -1,7 +1,11 @@ import { Elysia, t } from "elysia"; import type { BetterAuthRuntime } from "../../better-auth.ts"; -import { resolveBetterAuthSession } from "../better-auth/session.ts"; +import { + hasBetterAuthAccess, + hasBetterAuthProfileAccess, + resolveBetterAuthSession, +} from "../better-auth/session.ts"; import type { LinearConnectionStore } from "./service.ts"; const listConnectionsQuerySchema = t.Object({ @@ -34,15 +38,36 @@ export function createLinearConnectionsPlugin({ error: "better_auth_session_required", } as const; } + if ( + !hasBetterAuthProfileAccess({ + session: session.session, + profileId: normalizeOptionalQueryValue(query.profileId), + }) + ) { + set.status = 403; + return { + ok: false, + error: "better_auth_profile_forbidden", + } as const; + } + const activeSession = session.session; + const allConnections = await connectionStore.listConnections({ + profileId: normalizeOptionalQueryValue(query.profileId), + organizationId: normalizeOptionalQueryValue(query.organizationId), + }); + const connections = + session.enabled && activeSession + ? allConnections.filter((connection) => + hasBetterAuthAccess({ + session: activeSession, + record: connection, + }) + ) + : allConnections; return { ok: true, accessControlMode: session.accessControlMode, - connections: await connectionStore.listConnections({ - profileId: normalizeOptionalQueryValue(query.profileId), - organizationId: normalizeOptionalQueryValue(query.organizationId), - betterAuthUserId: session.session?.userId ?? null, - betterAuthOrganizationId: session.session?.organizationId ?? null, - }), + connections, } as const; }, { query: listConnectionsQuerySchema } diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts index 69605b22e..2a6c099bd 100644 --- a/services/auth-broker/src/modules/linear-oauth/service.ts +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -11,6 +11,7 @@ import { refreshAccessToken, } from "../../linear.ts"; import type { OAuthFlow } from "../../types.ts"; +import { issueBrokerManagementToken } from "../better-auth/management-token.ts"; import { resolveBetterAuthSession } from "../better-auth/session.ts"; import type { LinearConnectionStore } from "../linear-connections/service.ts"; import type { LinearOAuthModel } from "./model.ts"; @@ -202,6 +203,12 @@ export async function handleLinearCallback(input: { ? { betterAuthLinkState: betterAuthLink.state } : {}), } as const; + const managementToken = issueBrokerManagementToken({ + userId: betterAuthSession.session?.userId ?? betterAuthLink.userId ?? "", + profileId: flow.profileId, + organizationId: betterAuthSession.session?.organizationId ?? null, + teamId: betterAuthSession.session?.teamId ?? null, + }); input.flowStore.markComplete({ flowId: flow.id, @@ -210,6 +217,8 @@ export async function handleLinearCallback(input: { tokenExpiresAt: exchange.tokenExpiresAt, refreshToken: exchange.refreshToken, refreshTokenExpiresAt: exchange.refreshTokenExpiresAt, + managementToken: managementToken?.token, + managementTokenExpiresAt: managementToken?.expiresAt, }); try { await input.connectionStore.upsertConnection({ @@ -220,6 +229,7 @@ export async function handleLinearCallback(input: { betterAuthUserId: betterAuthLink.userId ?? null, betterAuthOrganizationId: betterAuthSession.session?.organizationId ?? null, + betterAuthTeamId: betterAuthSession.session?.teamId ?? null, organizationId: identity.account.organizationId, teamId: identity.account.teamIds?.[0] ?? null, metadata: { diff --git a/services/auth-broker/src/modules/linear-sync-store/plugin.ts b/services/auth-broker/src/modules/linear-sync-store/plugin.ts index a891038b5..d7bf09d67 100644 --- a/services/auth-broker/src/modules/linear-sync-store/plugin.ts +++ b/services/auth-broker/src/modules/linear-sync-store/plugin.ts @@ -1,7 +1,11 @@ import { Elysia, t } from "elysia"; import type { BetterAuthRuntime } from "../../better-auth.ts"; -import { resolveBetterAuthSession } from "../better-auth/session.ts"; +import { + hasBetterAuthAccess, + hasBetterAuthProfileAccess, + resolveBetterAuthSession, +} from "../better-auth/session.ts"; import type { LinearSyncStore, LinearWebhookDeliveryStatus, @@ -57,15 +61,35 @@ export function createLinearSyncStorePlugin({ error: "better_auth_session_required", } as const; } + if ( + !hasBetterAuthProfileAccess({ + session: session.session, + profileId: normalizeOptionalQueryValue({ value: query.profileId }), + }) + ) { + set.status = 403; + return { + ok: false, + error: "better_auth_profile_forbidden", + } as const; + } + const activeSession = session.session; - const deliveries = await syncStore.listWebhookDeliveries({ + const allDeliveries = await syncStore.listWebhookDeliveries({ status: statusResult.status, profileId: normalizeOptionalQueryValue({ value: query.profileId }), projectId: normalizeOptionalQueryValue({ value: query.projectId }), teamId: normalizeOptionalQueryValue({ value: query.teamId }), - betterAuthUserId: session.session?.userId ?? null, - betterAuthOrganizationId: session.session?.organizationId ?? null, }); + const deliveries = + session.enabled && activeSession + ? allDeliveries.filter((delivery) => + hasBetterAuthAccess({ + session: activeSession, + record: delivery, + }) + ) + : allDeliveries; return { ok: true, accessControlMode: session.accessControlMode, @@ -94,16 +118,15 @@ export function createLinearSyncStorePlugin({ const existing = await syncStore.getWebhookDelivery({ deliveryId: params.deliveryId, }); - const hasOrganizationAccess = - session.session.organizationId != null && - (existing?.betterAuthOrganizationId === - session.session.organizationId || - (existing?.betterAuthOrganizationId == null && - existing?.betterAuthUserId === session.session.userId)); - const hasUserAccess = - session.session.organizationId == null && - existing?.betterAuthUserId === session.session.userId; - if (!(existing && (hasOrganizationAccess || hasUserAccess))) { + if ( + !( + existing && + hasBetterAuthAccess({ + session: session.session, + record: existing, + }) + ) + ) { set.status = 404; return { ok: false, diff --git a/services/auth-broker/src/modules/linear-sync-store/service.ts b/services/auth-broker/src/modules/linear-sync-store/service.ts index 197de04b8..1cbf74973 100644 --- a/services/auth-broker/src/modules/linear-sync-store/service.ts +++ b/services/auth-broker/src/modules/linear-sync-store/service.ts @@ -56,6 +56,7 @@ export type LinearWebhookDelivery = { readonly betterAuthTeamId: string | null; readonly organizationId: string | null; readonly ownerTeamId: string | null; + readonly claimedBy: string | null; readonly status: LinearWebhookDeliveryStatus; readonly receivedAt: string; readonly updatedAt: string; @@ -74,6 +75,7 @@ export type LinearSyncStore = { }) => Promise; readonly markWebhookDeliveryApplied: (input: { readonly deliveryId: string; + readonly claimedBy?: string | null; }) => Promise; }; @@ -109,6 +111,7 @@ export class InMemoryLinearSyncStore implements LinearSyncStore { betterAuthTeamId: input.betterAuthTeamId ?? null, organizationId: input.organizationId, ownerTeamId: normalizeText(input.teamId), + claimedBy: null, status: "pending", receivedAt: now, updatedAt: now, @@ -138,6 +141,7 @@ export class InMemoryLinearSyncStore implements LinearSyncStore { markWebhookDeliveryApplied(input: { readonly deliveryId: string; + readonly claimedBy?: string | null; }): Promise { const delivery = this.deliveriesById.get(input.deliveryId); if (!delivery) { @@ -146,6 +150,7 @@ export class InMemoryLinearSyncStore implements LinearSyncStore { const appliedAt = delivery.appliedAt ?? new Date().toISOString(); const updated: LinearWebhookDelivery = { ...delivery, + claimedBy: normalizeText(input.claimedBy) ?? delivery.claimedBy, status: "applied", updatedAt: new Date().toISOString(), appliedAt, @@ -275,7 +280,7 @@ export function createLinearSyncStoreFromDb(input: { return rows[0] ? toWebhookDelivery({ row: rows[0] }) : null; }, - markWebhookDeliveryApplied: async ({ deliveryId }) => { + markWebhookDeliveryApplied: async ({ deliveryId, claimedBy }) => { await ensureOwnershipColumns(); const now = new Date(); const updated = await db @@ -283,7 +288,9 @@ export function createLinearSyncStoreFromDb(input: { .set({ status: "applied", applyError: null, - claimedBy: sql`coalesce(${linearWebhookEvents.claimedBy}, 'manual')`, + claimedBy: + normalizeText(claimedBy) ?? + sql`coalesce(${linearWebhookEvents.claimedBy}, 'manual')`, updatedAt: now, appliedAt: now, }) @@ -326,6 +333,7 @@ export function toWebhookDelivery(input: { input.row.betterAuthTeamId ?? storedOwnership.betterAuthTeamId, organizationId: input.row.organizationId ?? null, ownerTeamId: input.row.ownerTeamId ?? null, + claimedBy: input.row.claimedBy ?? null, status: normalizeDeliveryStatus({ value: input.row.status }), receivedAt: input.row.createdAt.toISOString(), updatedAt: input.row.updatedAt.toISOString(), diff --git a/services/auth-broker/src/modules/providers/plugin.ts b/services/auth-broker/src/modules/providers/plugin.ts index dfa514a7e..09fa348b1 100644 --- a/services/auth-broker/src/modules/providers/plugin.ts +++ b/services/auth-broker/src/modules/providers/plugin.ts @@ -52,6 +52,7 @@ export function createProvidersPlugin({ apiBaseUrl: config.linearApiBaseUrl, webhookPath: config.linearWebhookPath, connectionsPath: "/v1/auth/linear/connections", + subscriptionsPath: "/v1/auth/linear/subscriptions", accessControlMode: session.accessControlMode, developerAppTokenConfigured: Boolean(config.linearDeveloperAppToken), webhookSignatureVerification: config.linearWebhookSigningSecret diff --git a/services/auth-broker/src/types.ts b/services/auth-broker/src/types.ts index 744f78b4f..5caaaaa6b 100644 --- a/services/auth-broker/src/types.ts +++ b/services/auth-broker/src/types.ts @@ -49,6 +49,8 @@ export type OAuthFlow = { tokenExpiresAt?: string; refreshToken?: string; refreshTokenExpiresAt?: string; + managementToken?: string; + managementTokenExpiresAt?: string; error?: string; completedAt?: string; claimedAt?: string; @@ -89,6 +91,8 @@ export type OAuthFlowPublicStatus = { readonly tokenExpiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; readonly error?: string; }; diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 10ac6ca15..8ff6b1d89 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -4,6 +4,11 @@ import { createHmac } from "node:crypto"; import type { BetterAuthRuntime } from "../src/better-auth.ts"; import { FlowStore } from "../src/flow-store.ts"; import { createAuthBrokerApp } from "../src/index.ts"; +import { issueBrokerManagementToken } from "../src/modules/better-auth/management-token.ts"; +import { + InMemoryLinearAutosyncStore, + type LinearAutosyncStore, +} from "../src/modules/linear-autosync/service.ts"; import { InMemoryLinearConnectionStore, materializeLinearConnectionRecord, @@ -71,6 +76,27 @@ function withActiveOrganization( } as unknown as BetterAuthSession; } +function withActiveTeam( + session: NonNullable, + input: { + readonly organizationId: string; + readonly teamId: string; + } +): BetterAuthSession { + const base = session as unknown as { + readonly session: Record; + readonly user: Record; + }; + return { + user: base.user, + session: { + ...base.session, + activeOrganizationId: input.organizationId, + activeTeamId: input.teamId, + }, + } as unknown as BetterAuthSession; +} + /** * Build deterministic config values for broker route tests. */ @@ -113,6 +139,30 @@ function resolveFetchUrl(input: string | Request | URL): string { return input.toString(); } +async function withManagementTokenSecret( + value: string, + fn: () => Promise +): Promise { + const previousBetterAuthSecret = process.env.BETTER_AUTH_SECRET; + const previousAuthSecret = process.env.AUTH_SECRET; + process.env.BETTER_AUTH_SECRET = value; + process.env.AUTH_SECRET = value; + try { + return await fn(); + } finally { + if (typeof previousBetterAuthSecret === "string") { + process.env.BETTER_AUTH_SECRET = previousBetterAuthSecret; + } else { + process.env.BETTER_AUTH_SECRET = undefined; + } + if (typeof previousAuthSecret === "string") { + process.env.AUTH_SECRET = previousAuthSecret; + } else { + process.env.AUTH_SECRET = undefined; + } + } +} + describe("auth broker github flow routes", () => { test("linear connection materialization prefers first-class ownership columns and falls back to legacy metadata", () => { const fromColumns = materializeLinearConnectionRecord({ @@ -352,6 +402,7 @@ describe("auth broker github flow routes", () => { readonly enabled: boolean; readonly webhookPath?: string; readonly connectionsPath?: string; + readonly subscriptionsPath?: string; readonly accessControlMode?: string; readonly webhookSignatureVerification?: string; }>; @@ -361,6 +412,9 @@ describe("auth broker github flow routes", () => { expect(linearProvider?.enabled).toBe(true); expect(linearProvider?.webhookPath).toBe("/linear/webhooks"); expect(linearProvider?.connectionsPath).toBe("/v1/auth/linear/connections"); + expect(linearProvider?.subscriptionsPath).toBe( + "/v1/auth/linear/subscriptions" + ); expect(linearProvider?.accessControlMode).toBe("manual_unenforced"); expect(linearProvider?.webhookSignatureVerification).toBe("hmac-sha256"); }); @@ -429,6 +483,51 @@ describe("auth broker github flow routes", () => { ); }); + test("providers route reports team-owned Linear access when Better Auth has an active team", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-team", + userId: "user-team", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team", + }, + user: { + id: "user-team", + email: "team@example.com", + emailVerified: true, + name: "Team User", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "better-auth-org", + teamId: "better-auth-team", + } + ) + ), + }); + const response = await app.handle( + new Request("http://localhost/v1/auth/providers") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly providers: ReadonlyArray<{ + readonly id: string; + readonly accessControlMode?: string; + }>; + }; + const linearProvider = payload.providers.find((p) => p.id === "linear"); + expect(linearProvider?.accessControlMode).toBe("better_auth_team_owned"); + }); + test("linear start flow requests app actor mode by default", async () => { const app = createAuthBrokerApp({ config: createTestConfig(), @@ -811,6 +910,199 @@ describe("auth broker github flow routes", () => { expect(payload.error).toBe("better_auth_session_required"); }); + test("linear autosync subscriptions can be created, listed, and removed for the active Better Auth team", async () => { + const autosyncStore: LinearAutosyncStore = + new InMemoryLinearAutosyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearAutosyncStore: autosyncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-team-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "shared-org", + teamId: "shared-team", + } + ) + ), + }); + + const createResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/subscriptions", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + profileId: "default", + projectId: "project-a", + teamId: "linear-team-a", + mode: "manual", + status: "paused", + }), + }) + ); + expect(createResponse.status).toBe(200); + const created = (await createResponse.json()) as { + readonly ok: true; + readonly subscription: { + readonly profileId: string; + readonly projectId: string | null; + readonly teamId: string | null; + readonly mode: string; + readonly status: string; + readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; + }; + }; + expect(created.ok).toBe(true); + expect(created.subscription.profileId).toBe("default"); + expect(created.subscription.projectId).toBe("project-a"); + expect(created.subscription.teamId).toBe("linear-team-a"); + expect(created.subscription.mode).toBe("manual"); + expect(created.subscription.status).toBe("paused"); + expect(created.subscription.betterAuthOrganizationId).toBe("shared-org"); + expect(created.subscription.betterAuthTeamId).toBe("shared-team"); + + const listResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/subscriptions") + ); + expect(listResponse.status).toBe(200); + const listed = (await listResponse.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly subscriptions: ReadonlyArray<{ + readonly profileId: string; + readonly projectId: string | null; + }>; + }; + expect(listed.ok).toBe(true); + expect(listed.accessControlMode).toBe("better_auth_team_owned"); + expect(listed.subscriptions).toHaveLength(1); + expect(listed.subscriptions[0]).toMatchObject({ + profileId: "default", + projectId: "project-a", + }); + + const removeResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/subscriptions/remove", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + profileId: "default", + projectId: "project-a", + teamId: "linear-team-a", + }), + }) + ); + expect(removeResponse.status).toBe(200); + const removed = (await removeResponse.json()) as { + readonly ok: true; + readonly subscription: { + readonly profileId: string; + }; + }; + expect(removed.ok).toBe(true); + expect(removed.subscription.profileId).toBe("default"); + }); + + test("linear webhook keeps matching autosync deliveries pending until sync work runs", async () => { + const secret = "linear-hook-secret"; + const syncStore = new InMemoryLinearSyncStore(); + const connectionStore = new InMemoryLinearConnectionStore(); + const autosyncStore = new InMemoryLinearAutosyncStore(); + await connectionStore.upsertConnection({ + profileId: "default", + accountEmail: "linear@example.com", + betterAuthUserId: "user-a", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "linear-org", + teamId: "linear-team-a", + }); + await autosyncStore.upsertSubscription({ + profileId: "default", + projectId: "project-a", + teamId: "linear-team-a", + mode: "auto_apply", + betterAuthUserId: "user-a", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + }); + const app = createAuthBrokerApp({ + config: { + ...createTestConfig(), + linearWebhookSigningSecret: secret, + }, + flowStore: new FlowStore(), + linearSyncStore: syncStore, + linearConnectionStore: connectionStore, + linearAutosyncStore: autosyncStore, + }); + + const rawBody = JSON.stringify({ + type: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + organizationId: "linear-org", + data: { + id: "issue-a", + identifier: "LIN-123", + projectId: "project-a", + teamId: "linear-team-a", + }, + }); + const signature = createHmac("sha256", secret) + .update(rawBody, "utf8") + .digest("hex"); + const response = await app.handle( + new Request("http://localhost/linear/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + "linear-signature": signature, + }, + body: rawBody, + }) + ); + expect(response.status).toBe(202); + const payload = (await response.json()) as { + readonly ok: true; + readonly deliveryId: string; + readonly deliveryStatus: string; + readonly subscriptionId?: string; + }; + expect(payload.ok).toBe(true); + expect(payload.deliveryStatus).toBe("pending"); + expect(payload.subscriptionId).toBeDefined(); + + const delivery = await syncStore.getWebhookDelivery({ + deliveryId: payload.deliveryId, + }); + expect(delivery?.status).toBe("pending"); + expect(delivery?.claimedBy ?? null).toBeNull(); + }); + test("linear pending deliveries route only returns the current user's deliveries when Better Auth is enabled", async () => { const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); const app = createAuthBrokerApp({ @@ -999,55 +1291,304 @@ describe("auth broker github flow routes", () => { ).toBe(true); }); - test("linear pending deliveries route can mark a delivery applied", async () => { + test("linear pending deliveries route returns team-owned, org-owned, and legacy user-owned deliveries for the active Better Auth team", async () => { const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); const app = createAuthBrokerApp({ config: createTestConfig(), flowStore: new FlowStore(), linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-team-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "shared-org", + teamId: "shared-team", + } + ) + ), }); - const delivery = await syncStore.recordWebhookDelivery({ + const teamDelivery = await syncStore.recordWebhookDelivery({ path: "/linear/webhooks", rawBody: '{"type":"Issue"}', - payloadJson: { - type: "Issue", - }, + payloadJson: { type: "Issue" }, signatureVerified: true, eventType: "Issue", action: "create", webhookTimestamp: new Date().toISOString(), - profileId: "profile-a", - projectId: "project-a", - issueId: "issue-a", - issueIdentifier: "HACK-1", + profileId: "profile-team", + projectId: "project-team", + issueId: "issue-team", + issueIdentifier: "HACK-TEAM", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "org-team", + teamId: "linear-team-a", + }); + const orgDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-org", + projectId: "project-org", + issueId: "issue-org", + issueIdentifier: "HACK-ORG", + betterAuthUserId: "user-c", + betterAuthOrganizationId: "shared-org", + organizationId: "org-shared", + teamId: "linear-team-b", + }); + const legacyUserDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "comment", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-user", + projectId: "project-user", + issueId: "issue-user", + issueIdentifier: "HACK-USER", betterAuthUserId: "user-a", - organizationId: "org-a", - teamId: "team-a", + organizationId: "org-user", + teamId: "linear-team-c", + }); + await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-other-team", + projectId: "project-other-team", + issueId: "issue-other-team", + issueIdentifier: "HACK-OTHER-TEAM", + betterAuthUserId: "user-d", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "other-team", + organizationId: "org-other-team", + teamId: "linear-team-d", }); const response = await app.handle( - new Request( - `http://localhost/v1/auth/linear/deliveries/${delivery.id}/apply`, - { - method: "POST", - } - ) + new Request("http://localhost/v1/auth/linear/deliveries") ); expect(response.status).toBe(200); const payload = (await response.json()) as { readonly ok: true; - readonly delivery: { + readonly accessControlMode: string; + readonly deliveries: ReadonlyArray<{ readonly id: string; - readonly status: string; - readonly appliedAt: string | null; - readonly betterAuthUserId: string | null; - readonly organizationId: string | null; - readonly ownerTeamId: string | null; - }; + }>; }; expect(payload.ok).toBe(true); - expect(payload.delivery.id).toBe(delivery.id); - expect(payload.delivery.status).toBe("applied"); + expect(payload.accessControlMode).toBe("better_auth_team_owned"); + expect(payload.deliveries).toHaveLength(3); + expect( + [...payload.deliveries.map((delivery) => delivery.id)].sort() + ).toEqual( + [ + ...([teamDelivery.id, orgDelivery.id, legacyUserDelivery.id] as const), + ].sort() + ); + }); + + test("linear pending deliveries route returns team-owned records plus org and user fallbacks for the active Better Auth team", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-team-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "shared-org", + teamId: "shared-team", + } + ) + ), + }); + const teamDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-team", + projectId: "project-team", + issueId: "issue-team", + issueIdentifier: "HACK-10", + betterAuthUserId: "user-other", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "org-team", + teamId: "linear-team-a", + }); + const orgDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-org", + projectId: "project-org", + issueId: "issue-org", + issueIdentifier: "HACK-11", + betterAuthUserId: "user-other", + betterAuthOrganizationId: "shared-org", + organizationId: "org-org", + teamId: "linear-team-b", + }); + const userDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "comment", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-user", + projectId: "project-user", + issueId: "issue-user", + issueIdentifier: "HACK-12", + betterAuthUserId: "user-a", + organizationId: "org-user", + teamId: "linear-team-c", + }); + await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-other-team", + projectId: "project-other-team", + issueId: "issue-other-team", + issueIdentifier: "HACK-13", + betterAuthUserId: "user-other", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "other-team", + organizationId: "org-other-team", + teamId: "linear-team-d", + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/deliveries") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly deliveries: ReadonlyArray<{ + readonly id: string; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.accessControlMode).toBe("better_auth_team_owned"); + expect( + [...payload.deliveries.map((delivery) => delivery.id)].sort() + ).toEqual( + [...([teamDelivery.id, orgDelivery.id, userDelivery.id] as const)].sort() + ); + }); + + test("linear pending deliveries route can mark a delivery applied", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + }); + const delivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { + type: "Issue", + }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-a", + projectId: "project-a", + issueId: "issue-a", + issueIdentifier: "HACK-1", + betterAuthUserId: "user-a", + organizationId: "org-a", + teamId: "team-a", + }); + + const response = await app.handle( + new Request( + `http://localhost/v1/auth/linear/deliveries/${delivery.id}/apply`, + { + method: "POST", + } + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly delivery: { + readonly id: string; + readonly status: string; + readonly appliedAt: string | null; + readonly betterAuthUserId: string | null; + readonly organizationId: string | null; + readonly ownerTeamId: string | null; + }; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery.id).toBe(delivery.id); + expect(payload.delivery.status).toBe("applied"); expect(payload.delivery.appliedAt).not.toBeNull(); expect(payload.delivery.betterAuthUserId).toBe("user-a"); expect(payload.delivery.organizationId).toBe("org-a"); @@ -1204,6 +1745,276 @@ describe("auth broker github flow routes", () => { expect(payload.delivery.status).toBe("applied"); }); + test("linear pending deliveries apply route does not allow an org-only session to apply a team-owned delivery", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveOrganization( + { + session: { + id: "sess-org-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-org-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + "shared-org" + ) + ), + }); + const teamOwnedDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-team", + projectId: "project-team", + issueId: "issue-team", + issueIdentifier: "HACK-TEAM", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "org-team", + teamId: "linear-team-a", + }); + + const response = await app.handle( + new Request( + `http://localhost/v1/auth/linear/deliveries/${teamOwnedDelivery.id}/apply`, + { method: "POST" } + ) + ); + expect(response.status).toBe(404); + const payload = (await response.json()) as { + readonly ok: false; + readonly error: string; + }; + expect(payload.ok).toBe(false); + expect(payload.error).toBe("linear_delivery_not_found"); + }); + + test("linear pending deliveries apply route allows the active Better Auth team", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-team-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "shared-org", + teamId: "shared-team", + } + ) + ), + }); + const teamOwnedDelivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-team", + projectId: "project-team", + issueId: "issue-team", + issueIdentifier: "HACK-TEAM", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "org-team", + teamId: "linear-team-a", + }); + + const response = await app.handle( + new Request( + `http://localhost/v1/auth/linear/deliveries/${teamOwnedDelivery.id}/apply`, + { method: "POST" } + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly delivery: { + readonly id: string; + readonly status: string; + }; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery.id).toBe(teamOwnedDelivery.id); + expect(payload.delivery.status).toBe("applied"); + }); + + test("linear pending deliveries apply route allows the active Better Auth team to apply a team-owned delivery", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-team-apply", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team-apply", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "shared-org", + teamId: "shared-team", + } + ) + ), + }); + const delivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-team", + projectId: "project-team", + issueId: "issue-team", + issueIdentifier: "HACK-20", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "org-team", + teamId: "linear-team-a", + }); + + const response = await app.handle( + new Request( + `http://localhost/v1/auth/linear/deliveries/${delivery.id}/apply`, + { method: "POST" } + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly delivery: { + readonly id: string; + readonly status: string; + }; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery.id).toBe(delivery.id); + expect(payload.delivery.status).toBe("applied"); + }); + + test("linear pending deliveries apply route denies org-only access to a team-owned delivery", async () => { + const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveOrganization( + { + session: { + id: "sess-org-only", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-org-only", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + "shared-org" + ) + ), + }); + const delivery = await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: '{"type":"Issue"}', + payloadJson: { type: "Issue" }, + signatureVerified: true, + eventType: "Issue", + action: "create", + webhookTimestamp: new Date().toISOString(), + profileId: "profile-team", + projectId: "project-team", + issueId: "issue-team", + issueIdentifier: "HACK-21", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "org-team", + teamId: "linear-team-a", + }); + + const response = await app.handle( + new Request( + `http://localhost/v1/auth/linear/deliveries/${delivery.id}/apply`, + { method: "POST" } + ) + ); + expect(response.status).toBe(404); + const payload = (await response.json()) as { + readonly ok: false; + readonly error: string; + }; + expect(payload.ok).toBe(false); + expect(payload.error).toBe("linear_delivery_not_found"); + }); + test("linear start route issues flow payload and polling reads pending state", async () => { const flowStore = new FlowStore(); const app = createAuthBrokerApp({ @@ -1250,90 +2061,243 @@ describe("auth broker github flow routes", () => { expect(pollPayload.status.setDefault).toBe(true); }); - test("linear callback completes flow and claim returns token", async () => { - const flowStore = new FlowStore(); - const connectionStore = new InMemoryLinearConnectionStore(); - const app = createAuthBrokerApp({ - config: createTestConfig(), - flowStore, - linearConnectionStore: connectionStore, - betterAuthRuntime: createBetterAuthRuntimeWithSession( - withActiveOrganization( - { - session: { - id: "sess-linear", - userId: "better-auth-linear-user", - expiresAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - token: "token-linear", - }, - user: { - id: "better-auth-linear-user", - email: "linear@example.com", - emailVerified: true, - name: "Linear User", - createdAt: new Date(), - updatedAt: new Date(), - }, - }, - "better-auth-org" + test("linear callback completes flow and claim returns token", async () => { + await withManagementTokenSecret("broker-management-secret", async () => { + const flowStore = new FlowStore(); + const connectionStore = new InMemoryLinearConnectionStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + linearConnectionStore: connectionStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-linear", + userId: "better-auth-linear-user", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-linear", + }, + user: { + id: "better-auth-linear-user", + email: "linear@example.com", + emailVerified: true, + name: "Linear User", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "better-auth-org", + teamId: "better-auth-team", + } + ), + createBetterAuthDb([{ id: "better-auth-linear-user" }]) + ), + }); + + const startResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/start?profile=work") + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + if (!flow) { + return; + } + + const originalFetch = globalThis.fetch; + globalThis.fetch = (( + input: string | URL | Request, + init?: RequestInit + ) => { + const url = resolveFetchUrl(input); + if (url === createTestConfig().linearTokenUrl) { + return new Response( + JSON.stringify({ + access_token: "lin_oauth_token", + expires_in: 3600, + refresh_token: "lin_refresh_token", + refresh_token_expires_in: 86_400, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + } + if (url === createTestConfig().linearApiBaseUrl) { + const bodyText = + typeof init?.body === "string" + ? init.body + : String(init?.body ?? ""); + if (bodyText.includes("viewer")) { + return new Response( + JSON.stringify({ + data: { + viewer: { + id: "usr_linear", + name: "Linear User", + email: "linear@example.com", + displayName: "Linear User", + organization: { + id: "org-linear", + name: "Hack Dance", + }, + teams: { + nodes: [{ id: "team-linear", name: "Platform" }], + }, + }, + }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + } + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + try { + const callbackResponse = await app.handle( + new Request( + `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` + ) + ); + expect(callbackResponse.status).toBe(200); + const callbackHtml = await callbackResponse.text(); + expect(callbackHtml).toContain("Linear connected"); + + const claimResponse = await app.handle( + new Request( + `http://localhost/v1/auth/linear/flows/${startPayload.flow.flowId}?deviceCode=${encodeURIComponent(startPayload.flow.deviceCode)}&claim=1` + ) + ); + expect(claimResponse.status).toBe(200); + const claimPayload = (await claimResponse.json()) as { + readonly ok: true; + readonly status: { + readonly status: string; + readonly token?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; + readonly accountEmail?: string; + readonly betterAuthUserId?: string; + readonly betterAuthLinkState?: string; + }; + }; + expect(claimPayload.ok).toBe(true); + expect(claimPayload.status.status).toBe("claimed"); + expect(claimPayload.status.token).toBe("lin_oauth_token"); + expect(claimPayload.status.refreshToken).toBe("lin_refresh_token"); + expect(claimPayload.status.refreshTokenExpiresAt).toBeDefined(); + expect(claimPayload.status.managementToken).toBeDefined(); + expect(claimPayload.status.managementTokenExpiresAt).toBeDefined(); + expect(claimPayload.status.accountEmail).toBe("linear@example.com"); + expect(claimPayload.status.betterAuthUserId).toBe( + "better-auth-linear-user" + ); + expect(claimPayload.status.betterAuthLinkState).toBe("linked_existing"); + + const connectionsResponse = await app.handle( + new Request( + "http://localhost/v1/auth/linear/connections?profileId=work" + ) + ); + expect(connectionsResponse.status).toBe(200); + const connectionsPayload = (await connectionsResponse.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly connections: ReadonlyArray<{ + readonly profileId: string | null; + readonly accountEmail: string | null; + readonly organizationId: string | null; + readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; + readonly metadata: Record; + }>; + }; + expect(connectionsPayload.ok).toBe(true); + expect(connectionsPayload.accessControlMode).toBe( + "better_auth_team_owned" + ); + expect(connectionsPayload.connections).toHaveLength(1); + expect(connectionsPayload.connections[0]?.profileId).toBe("work"); + expect(connectionsPayload.connections[0]?.accountEmail).toBe( + "linear@example.com" + ); + expect(connectionsPayload.connections[0]?.organizationId).toBe( + "org-linear" + ); + expect(connectionsPayload.connections[0]?.betterAuthUserId).toBe( + "better-auth-linear-user" + ); + expect( + connectionsPayload.connections[0]?.betterAuthOrganizationId + ).toBe("better-auth-org"); + expect(connectionsPayload.connections[0]?.betterAuthTeamId).toBe( + "better-auth-team" + ); + expect(connectionsPayload.connections[0]?.metadata).toMatchObject({ + organizationName: "Hack Dance", + teamIds: ["team-linear"], + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + test("claimed management token authorizes protected Linear routes without a browser session", async () => { + await withManagementTokenSecret("broker-management-secret", async () => { + const flowStore = new FlowStore(); + const connectionStore = new InMemoryLinearConnectionStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + linearConnectionStore: connectionStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + null, + createBetterAuthDb([{ id: "better-auth-linear-user" }]) ), - createBetterAuthDb([{ id: "better-auth-linear-user" }]) - ), - }); - - const startResponse = await app.handle( - new Request("http://localhost/v1/auth/linear/start?profile=work") - ); - expect(startResponse.status).toBe(200); - const startPayload = (await startResponse.json()) as StartFlowResponse; - const flow = flowStore.getById(startPayload.flow.flowId); - expect(flow).not.toBeNull(); - if (!flow) { - return; - } + }); - const originalFetch = globalThis.fetch; - globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { - const url = resolveFetchUrl(input); - if (url === createTestConfig().linearTokenUrl) { - return new Response( - JSON.stringify({ - access_token: "lin_oauth_token", - expires_in: 3600, - refresh_token: "lin_refresh_token", - refresh_token_expires_in: 86_400, - }), - { - status: 200, - headers: { - "content-type": "application/json", - }, - } - ); + const startResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/start?profile=work") + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + if (!flow) { + return; } - if (url === createTestConfig().linearApiBaseUrl) { - const bodyText = - typeof init?.body === "string" ? init.body : String(init?.body ?? ""); - if (bodyText.includes("viewer")) { + + const originalFetch = globalThis.fetch; + globalThis.fetch = (( + input: string | URL | Request, + init?: RequestInit + ) => { + const url = resolveFetchUrl(input); + if (url === createTestConfig().linearTokenUrl) { return new Response( JSON.stringify({ - data: { - viewer: { - id: "usr_linear", - name: "Linear User", - email: "linear@example.com", - displayName: "Linear User", - organization: { - id: "org-linear", - name: "Hack Dance", - }, - teams: { - nodes: [{ id: "team-linear", name: "Platform" }], - }, - }, - }, + access_token: "lin_oauth_token", + expires_in: 3600, + refresh_token: "lin_refresh_token", + refresh_token_expires_in: 86_400, }), { status: 200, @@ -1343,92 +2307,218 @@ describe("auth broker github flow routes", () => { } ); } - } - return new Response("not found", { status: 404 }); - }) as unknown as typeof fetch; + if (url === createTestConfig().linearApiBaseUrl) { + const bodyText = + typeof init?.body === "string" + ? init.body + : String(init?.body ?? ""); + if (bodyText.includes("viewer")) { + return new Response( + JSON.stringify({ + data: { + viewer: { + id: "usr_linear", + name: "Linear User", + email: "linear@example.com", + displayName: "Linear User", + organization: { + id: "org-linear", + name: "Hack Dance", + }, + teams: { + nodes: [{ id: "team-linear", name: "Platform" }], + }, + }, + }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + } + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; - try { - const callbackResponse = await app.handle( - new Request( - `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` - ) - ); - expect(callbackResponse.status).toBe(200); - const callbackHtml = await callbackResponse.text(); - expect(callbackHtml).toContain("Linear connected"); + try { + const callbackResponse = await app.handle( + new Request( + `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` + ) + ); + expect(callbackResponse.status).toBe(200); - const claimResponse = await app.handle( - new Request( - `http://localhost/v1/auth/linear/flows/${startPayload.flow.flowId}?deviceCode=${encodeURIComponent(startPayload.flow.deviceCode)}&claim=1` - ) - ); - expect(claimResponse.status).toBe(200); - const claimPayload = (await claimResponse.json()) as { - readonly ok: true; - readonly status: { - readonly status: string; - readonly token?: string; - readonly refreshToken?: string; - readonly refreshTokenExpiresAt?: string; - readonly accountEmail?: string; - readonly betterAuthUserId?: string; - readonly betterAuthLinkState?: string; + const claimResponse = await app.handle( + new Request( + `http://localhost/v1/auth/linear/flows/${startPayload.flow.flowId}?deviceCode=${encodeURIComponent(startPayload.flow.deviceCode)}&claim=1` + ) + ); + expect(claimResponse.status).toBe(200); + const claimPayload = (await claimResponse.json()) as { + readonly ok: true; + readonly status: { + readonly managementToken?: string; + }; }; - }; - expect(claimPayload.ok).toBe(true); - expect(claimPayload.status.status).toBe("claimed"); - expect(claimPayload.status.token).toBe("lin_oauth_token"); - expect(claimPayload.status.refreshToken).toBe("lin_refresh_token"); - expect(claimPayload.status.refreshTokenExpiresAt).toBeDefined(); - expect(claimPayload.status.accountEmail).toBe("linear@example.com"); - expect(claimPayload.status.betterAuthUserId).toBe( - "better-auth-linear-user" + const managementToken = claimPayload.status.managementToken; + expect(managementToken).toBeDefined(); + if (!managementToken) { + return; + } + + const unauthenticatedResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/connections") + ); + expect(unauthenticatedResponse.status).toBe(401); + + const connectionsResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/connections", { + headers: { + authorization: `Bearer ${managementToken}`, + }, + }) + ); + expect(connectionsResponse.status).toBe(200); + const connectionsPayload = (await connectionsResponse.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly connections: ReadonlyArray<{ + readonly profileId: string | null; + readonly betterAuthUserId: string | null; + }>; + }; + expect(connectionsPayload.ok).toBe(true); + expect(connectionsPayload.accessControlMode).toBe( + "better_auth_session_owned" + ); + expect(connectionsPayload.connections).toHaveLength(1); + expect(connectionsPayload.connections[0]?.profileId).toBe("work"); + expect(connectionsPayload.connections[0]?.betterAuthUserId).toBe( + "better-auth-linear-user" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + test("protected Linear routes reject invalid and wrong management tokens", async () => { + await withManagementTokenSecret("broker-management-secret", async () => { + const connectionStore = new InMemoryLinearConnectionStore(); + const syncStore = new InMemoryLinearSyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearConnectionStore: connectionStore, + linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + + await connectionStore.upsertConnection({ + profileId: "work", + accountEmail: "linear@example.com", + betterAuthUserId: "user-a", + organizationId: "org-linear", + }); + await syncStore.recordWebhookDelivery({ + path: "/linear/webhooks", + rawBody: JSON.stringify({ type: "Issue", action: "update" }), + payloadJson: { + type: "Issue", + action: "update", + }, + signatureVerified: true, + profileId: "work", + eventType: "Issue", + action: "update", + webhookTimestamp: new Date().toISOString(), + issueId: "issue-1", + issueIdentifier: "LIN-1", + betterAuthUserId: "user-a", + organizationId: "org-linear", + teamId: null, + }); + const existingDelivery = await syncStore.listWebhookDeliveries({ + status: "pending", + }); + const deliveryId = existingDelivery[0]?.id; + expect(deliveryId).toBeDefined(); + if (!deliveryId) { + return; + } + + const invalidTokenResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/connections", { + headers: { + authorization: "Bearer definitely-invalid", + }, + }) ); - expect(claimPayload.status.betterAuthLinkState).toBe("linked_existing"); + expect(invalidTokenResponse.status).toBe(401); + + const wrongManagementToken = issueBrokerManagementToken({ + userId: "user-b", + profileId: "work", + }); + expect(wrongManagementToken).not.toBeNull(); + if (!wrongManagementToken) { + return; + } - const connectionsResponse = await app.handle( + const wrongTokenApplyResponse = await app.handle( new Request( - "http://localhost/v1/auth/linear/connections?profileId=work" + `http://localhost/v1/auth/linear/deliveries/${encodeURIComponent(deliveryId)}/apply`, + { + method: "POST", + headers: { + authorization: `Bearer ${wrongManagementToken.token}`, + "content-type": "application/json", + }, + } ) ); - expect(connectionsResponse.status).toBe(200); - const connectionsPayload = (await connectionsResponse.json()) as { - readonly ok: true; - readonly accessControlMode: string; - readonly connections: ReadonlyArray<{ - readonly profileId: string | null; - readonly accountEmail: string | null; - readonly organizationId: string | null; - readonly betterAuthUserId: string | null; - readonly betterAuthOrganizationId: string | null; - readonly metadata: Record; - }>; + expect(wrongTokenApplyResponse.status).toBe(404); + const wrongTokenPayload = (await wrongTokenApplyResponse.json()) as { + readonly ok: false; + readonly error: string; }; - expect(connectionsPayload.ok).toBe(true); - expect(connectionsPayload.accessControlMode).toBe( - "better_auth_organization_owned" - ); - expect(connectionsPayload.connections).toHaveLength(1); - expect(connectionsPayload.connections[0]?.profileId).toBe("work"); - expect(connectionsPayload.connections[0]?.accountEmail).toBe( - "linear@example.com" - ); - expect(connectionsPayload.connections[0]?.organizationId).toBe( - "org-linear" - ); - expect(connectionsPayload.connections[0]?.betterAuthUserId).toBe( - "better-auth-linear-user" - ); - expect(connectionsPayload.connections[0]?.betterAuthOrganizationId).toBe( - "better-auth-org" - ); - expect(connectionsPayload.connections[0]?.metadata).toMatchObject({ - organizationName: "Hack Dance", - teamIds: ["team-linear"], + expect(wrongTokenPayload.error).toBe("linear_delivery_not_found"); + + const profileScopedToken = issueBrokerManagementToken({ + userId: "user-a", + profileId: "work", }); - } finally { - globalThis.fetch = originalFetch; - } + expect(profileScopedToken).not.toBeNull(); + if (!profileScopedToken) { + return; + } + + const wrongProfileSubscriptionResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/subscriptions", { + method: "POST", + headers: { + authorization: `Bearer ${profileScopedToken.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + profileId: "other-profile", + projectId: "project-a", + teamId: "team-a", + mode: "manual", + }), + }) + ); + expect(wrongProfileSubscriptionResponse.status).toBe(403); + const wrongProfilePayload = + (await wrongProfileSubscriptionResponse.json()) as { + readonly ok: false; + readonly error: string; + }; + expect(wrongProfilePayload.error).toBe("better_auth_profile_forbidden"); + }); }); test("linear connections route requires Better Auth session when enabled", async () => { @@ -1594,6 +2684,169 @@ describe("auth broker github flow routes", () => { ).toBe(true); }); + test("linear connections route returns team-owned, org-owned, and legacy user-owned connections for the active Better Auth team", async () => { + const connectionStore = new InMemoryLinearConnectionStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearConnectionStore: connectionStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-team-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "shared-org", + teamId: "shared-team", + } + ) + ), + }); + await connectionStore.upsertConnection({ + profileId: "team", + accountEmail: "team@example.com", + betterAuthUserId: "user-b", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "org-team", + }); + await connectionStore.upsertConnection({ + profileId: "org", + accountEmail: "org@example.com", + betterAuthUserId: "user-c", + betterAuthOrganizationId: "shared-org", + organizationId: "org-shared", + }); + await connectionStore.upsertConnection({ + profileId: "user", + accountEmail: "user@example.com", + betterAuthUserId: "user-a", + organizationId: "org-user", + }); + await connectionStore.upsertConnection({ + profileId: "other-team", + accountEmail: "other-team@example.com", + betterAuthUserId: "user-d", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "other-team", + organizationId: "org-other-team", + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/connections") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly connections: ReadonlyArray<{ + readonly profileId: string | null; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.accessControlMode).toBe("better_auth_team_owned"); + expect(payload.connections).toHaveLength(3); + expect( + [...payload.connections.map((connection) => connection.profileId)].sort() + ).toEqual(["org", "team", "user"]); + }); + + test("linear connections route returns team-owned records plus org and user fallbacks for the active Better Auth team", async () => { + const connectionStore = new InMemoryLinearConnectionStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearConnectionStore: connectionStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-team-connections", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team-connections", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "shared-org", + teamId: "shared-team", + } + ) + ), + }); + await connectionStore.upsertConnection({ + profileId: "team", + accountEmail: "team@example.com", + betterAuthUserId: "user-other", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "shared-team", + organizationId: "org-team", + }); + await connectionStore.upsertConnection({ + profileId: "org", + accountEmail: "org@example.com", + betterAuthUserId: "user-other", + betterAuthOrganizationId: "shared-org", + organizationId: "org-org", + }); + await connectionStore.upsertConnection({ + profileId: "user", + accountEmail: "user@example.com", + betterAuthUserId: "user-a", + organizationId: "org-user", + }); + await connectionStore.upsertConnection({ + profileId: "other-team", + accountEmail: "other-team@example.com", + betterAuthUserId: "user-other", + betterAuthOrganizationId: "shared-org", + betterAuthTeamId: "other-team", + organizationId: "org-other-team", + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/connections") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly accessControlMode: string; + readonly connections: ReadonlyArray<{ + readonly profileId: string | null; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.accessControlMode).toBe("better_auth_team_owned"); + expect( + [...payload.connections.map((connection) => connection.profileId)].sort() + ).toEqual(["org", "team", "user"]); + }); + test("linear refresh route exchanges refresh token", async () => { const app = createAuthBrokerApp({ config: createTestConfig(), diff --git a/src/control-plane/extensions/linear/auth.ts b/src/control-plane/extensions/linear/auth.ts index 08c99873e..bc8af2f38 100644 --- a/src/control-plane/extensions/linear/auth.ts +++ b/src/control-plane/extensions/linear/auth.ts @@ -123,6 +123,8 @@ type LinearTokenEnvelope = { readonly expiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; }; type LinearProfileSelection = { @@ -440,6 +442,8 @@ export async function saveLinearToken(input: { readonly expiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; readonly authRef?: string; readonly service?: string; readonly store?: SecretStore; @@ -475,6 +479,12 @@ export async function saveLinearToken(input: { ...(input.refreshTokenExpiresAt ? { refreshTokenExpiresAt: input.refreshTokenExpiresAt.trim() } : {}), + ...(input.managementToken + ? { managementToken: input.managementToken.trim() } + : {}), + ...(input.managementTokenExpiresAt + ? { managementTokenExpiresAt: input.managementTokenExpiresAt.trim() } + : {}), }); await store.set({ service, @@ -489,6 +499,84 @@ export async function saveLinearToken(input: { }; } +/** + * Resolve a stored broker management token for Linear protected broker routes. + */ +export async function resolveLinearBrokerManagementToken(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; + readonly authRef?: string; + readonly service?: string; + readonly store?: SecretStore; +}): Promise< + | { + readonly ok: true; + readonly managementToken: string; + readonly managementTokenExpiresAt?: string; + readonly profileId: string; + readonly authRef: string; + readonly service: string; + } + | { + readonly ok: false; + readonly error: string; + readonly profileId: string; + readonly authRef: string; + readonly service: string; + } +> { + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + }); + const authRef = (input.authRef ?? settings.authRef).trim(); + const service = (input.service ?? settings.service).trim(); + const store = input.store ?? DEFAULT_SECRET_STORE; + const stored = await store.get({ + service, + name: authRef, + }); + const envelope = parseStoredTokenEnvelope(stored); + const managementToken = envelope?.managementToken?.trim() ?? ""; + if (!managementToken) { + return { + ok: false, + error: `Linear broker management token is missing for profile "${settings.profileId}". Reconnect this Linear profile to manage broker-backed deliveries and autosync subscriptions.`, + profileId: settings.profileId, + authRef, + service, + }; + } + const managementTokenExpiresAtMs = envelope?.managementTokenExpiresAt + ? parseTimestampMs(envelope.managementTokenExpiresAt) + : null; + if ( + envelope?.managementTokenExpiresAt && + managementTokenExpiresAtMs !== null && + managementTokenExpiresAtMs <= Date.now() + ) { + return { + ok: false, + error: `Linear broker management token expired for profile "${settings.profileId}". Reconnect this Linear profile to manage broker-backed deliveries and autosync subscriptions.`, + profileId: settings.profileId, + authRef, + service, + }; + } + return { + ok: true, + managementToken, + ...(envelope?.managementTokenExpiresAt + ? { managementTokenExpiresAt: envelope.managementTokenExpiresAt } + : {}), + profileId: settings.profileId, + authRef, + service, + }; +} + /** * Delete a Linear token from keychain for the selected profile. */ @@ -705,6 +793,12 @@ function serializeStoredToken(input: LinearTokenEnvelope): string { ...(input.refreshTokenExpiresAt ? { refreshTokenExpiresAt: input.refreshTokenExpiresAt } : {}), + ...(input.managementToken + ? { managementToken: input.managementToken } + : {}), + ...(input.managementTokenExpiresAt + ? { managementTokenExpiresAt: input.managementTokenExpiresAt } + : {}), }); } @@ -741,6 +835,13 @@ function parseStoredTokenEnvelope( ...(typeof parsed.refreshTokenExpiresAt === "string" ? { refreshTokenExpiresAt: parsed.refreshTokenExpiresAt } : {}), + ...(typeof parsed.managementToken === "string" && + parsed.managementToken.trim().length > 0 + ? { managementToken: parsed.managementToken.trim() } + : {}), + ...(typeof parsed.managementTokenExpiresAt === "string" + ? { managementTokenExpiresAt: parsed.managementTokenExpiresAt } + : {}), }; } catch { return { token: text }; diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index 332524daa..03823795f 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -23,6 +23,7 @@ import { listLinearAuthProfiles, resolveLinearAuthSettings, resolveLinearAuthSettingsResult, + resolveLinearBrokerManagementToken, resolveLinearToken, saveLinearToken, } from "./auth.ts"; @@ -661,6 +662,44 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ projectDir: ctx.project.projectDir, }); + const existingBinding = resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); + + if ( + !( + parsed.value.clear || + parsed.value.projectId || + parsed.value.projectName || + parsed.value.profileId || + parsed.value.teamId + ) + ) { + const payload = buildProjectBindingPayload({ + binding: existingBinding, + }); + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear project binding", + entries: [ + ["profile", payload.profileId ?? ""], + ["project_id", payload.projectId ?? ""], + ["project_name", payload.projectName ?? ""], + ["team_id", payload.teamId ?? ""], + [ + "additional_projects", + payload.additionalProjects + .map((project) => project.projectId) + .join(", "), + ], + ], + }); + } + return 0; + } + if (parsed.value.clear) { await Promise.all([ updateProjectConfig({ @@ -683,27 +722,32 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ path: "controlPlane.routing.overrides.linear.teamId", value: "", }), + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: [], + }), ]); + const payload = buildProjectBindingPayload({ + binding: { additionalProjects: [] }, + cleared: true, + }); if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ ok: true, cleared: true }, null, 2)}\n` - ); + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); } else { await display.panel({ title: "Linear project binding", tone: "success", - lines: ["Cleared project-level Linear routing overrides."], + lines: [ + "Cleared project-level Linear routing overrides, including additional synced projects.", + ], }); } return 0; } - const boundProfile = - parsed.value.profileId ?? - resolveProjectLinearBinding({ - controlPlaneConfig: ctx.controlPlaneConfig, - }).profileId; + const boundProfile = parsed.value.profileId ?? existingBinding.profileId; if (boundProfile) { await updateProjectConfig({ @@ -750,15 +794,28 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ path: "controlPlane.routing.overrides.linear.teamId", value: resolvedTeamAndName.teamId, }), + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: removeAdditionalProjectBinding({ + existing: existingBinding.additionalProjects, + projectId, + }), + }), ]); - const payload = { - ok: true, - profileId: boundProfile ?? null, - projectId, - projectName: resolvedTeamAndName.projectName, - teamId: resolvedTeamAndName.teamId, - }; + const payload = buildProjectBindingPayload({ + binding: { + ...(boundProfile ? { profileId: boundProfile } : {}), + projectId, + projectName: resolvedTeamAndName.projectName, + teamId: resolvedTeamAndName.teamId, + additionalProjects: removeAdditionalProjectBinding({ + existing: existingBinding.additionalProjects, + projectId, + }), + }, + }); if (parsed.value.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); @@ -770,6 +827,12 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ ["project_id", payload.projectId], ["project_name", payload.projectName], ["team_id", payload.teamId], + [ + "additional_projects", + payload.additionalProjects + .map((project) => project.projectId) + .join(", "), + ], ], }); } @@ -777,9 +840,9 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ }, }, { - name: "sync-issue", + name: "project-link", summary: - "One-off manual sync for a single issue/ticket in either direction", + "Add an additional Linear project to the current hack project sync scope", scope: "project", allowWhenDisabled: true, handler: async ({ ctx, args }) => { @@ -787,18 +850,202 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ ctx.logger.error({ message: "No project found. Run inside a repo." }); return 1; } - const parsed = parseSyncIssueArgs({ args }); + const parsed = parseProjectLinkArgs({ args }); if (!parsed.ok) { ctx.logger.error({ message: parsed.error }); return 1; } - const runtime = await createSyncRuntime({ - ctx, - profileId: parsed.value.profileId, + await enableLinearProjectExtension({ + projectDir: ctx.project.projectDir, }); - if (!runtime.ok) { - ctx.logger.error({ message: runtime.error }); + + const projectId = parsed.value.projectId; + if (!projectId) { + ctx.logger.error({ + message: + "Missing --project-id. Pass a Linear project id to add to the additional sync scope.", + }); + return 1; + } + + const binding = resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); + const targetProfile = parsed.value.profileId ?? binding.profileId; + const resolvedTeamAndName = await resolveProjectBindingDetails({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: targetProfile, + projectId, + projectName: parsed.value.projectName, + teamId: parsed.value.teamId, + }); + if (!resolvedTeamAndName.ok) { + ctx.logger.error({ message: resolvedTeamAndName.error }); + return 1; + } + + const additionalProjects = upsertAdditionalProjectBinding({ + existing: binding.additionalProjects, + target: { + projectId, + projectName: resolvedTeamAndName.projectName, + teamId: resolvedTeamAndName.teamId, + ...(targetProfile ? { profileId: targetProfile } : {}), + }, + defaultProjectId: binding.projectId, + }); + const writes: Promise<{ readonly changed: boolean }>[] = [ + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: additionalProjects, + }), + ]; + if (!binding.profileId && targetProfile) { + writes.push( + updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.profile", + value: targetProfile, + }) + ); + } + await Promise.all(writes); + + const payload = buildProjectBindingPayload({ + binding: { + ...((binding.profileId ?? targetProfile) + ? { profileId: binding.profileId ?? targetProfile } + : {}), + ...(binding.projectId ? { projectId: binding.projectId } : {}), + ...(binding.projectName ? { projectName: binding.projectName } : {}), + ...(binding.teamId ? { teamId: binding.teamId } : {}), + additionalProjects, + }, + additionalProjectChanged: true, + }); + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear additional project linked", + entries: [ + ["profile", payload.profileId ?? ""], + ["project_id", projectId], + ["project_name", resolvedTeamAndName.projectName], + ["team_id", resolvedTeamAndName.teamId], + [ + "additional_projects", + payload.additionalProjects + .map((project) => project.projectId) + .join(", "), + ], + ], + }); + } + return 0; + }, + }, + { + name: "project-unlink", + summary: + "Remove an additional Linear project from the current hack project sync scope", + scope: "project", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + const parsed = parseProjectLinkArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const projectId = parsed.value.projectId; + if (!projectId) { + ctx.logger.error({ + message: + "Missing --project-id. Pass a Linear project id to remove from the additional sync scope.", + }); + return 1; + } + + const binding = resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); + if ( + binding.projectId && + binding.projectId.toLowerCase() === projectId.toLowerCase() + ) { + ctx.logger.error({ + message: + "Cannot unlink the default Linear project with `project-unlink`. Use `project-bind --clear` or bind a different default project instead.", + }); + return 1; + } + + const additionalProjects = removeAdditionalProjectBinding({ + existing: binding.additionalProjects, + projectId, + }); + const removed = + additionalProjects.length !== binding.additionalProjects.length; + await updateProjectConfig({ + projectDir: ctx.project.projectDir, + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: additionalProjects, + }); + + const payload = buildProjectBindingPayload({ + binding: { + ...(binding.profileId ? { profileId: binding.profileId } : {}), + ...(binding.projectId ? { projectId: binding.projectId } : {}), + ...(binding.projectName ? { projectName: binding.projectName } : {}), + ...(binding.teamId ? { teamId: binding.teamId } : {}), + additionalProjects, + }, + additionalProjectChanged: removed, + removedProjectId: removed ? projectId : null, + }); + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear additional project unlinked", + entries: [ + ["removed", removed ? "yes" : "no"], + ["project_id", projectId], + [ + "additional_projects", + payload.additionalProjects + .map((project) => project.projectId) + .join(", "), + ], + ], + }); + } + return removed ? 0 : 1; + }, + }, + { + name: "sync-issue", + summary: + "One-off manual sync for a single issue/ticket in either direction", + scope: "project", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + const parsed = parseSyncIssueArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); return 1; } @@ -808,6 +1055,15 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ }); if (parsed.value.from === "linear") { + const runtime = await createSyncRuntime({ + ctx, + profileId: parsed.value.profileId, + }); + if (!runtime.ok) { + ctx.logger.error({ message: runtime.error }); + return 1; + } + const issueIdentifier = parsed.value.issueIdentifier; if (!issueIdentifier) { ctx.logger.error({ @@ -853,11 +1109,40 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 1; } + const binding = resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); + const selectedTarget = + (parsed.value.projectId + ? findProjectBindingTarget({ + binding, + projectId: parsed.value.projectId, + }) + : null) ?? + (binding.projectId + ? { + projectId: binding.projectId, + ...(binding.projectName + ? { projectName: binding.projectName } + : {}), + ...(binding.teamId ? { teamId: binding.teamId } : {}), + ...(binding.profileId ? { profileId: binding.profileId } : {}), + } + : null); + const runtime = await createSyncRuntime({ + ctx, + profileId: parsed.value.profileId ?? selectedTarget?.profileId, + }); + if (!runtime.ok) { + ctx.logger.error({ message: runtime.error }); + return 1; + } + const synced = await syncTicketToLinearIssue({ runtime: runtime.value, ticketId: normalizedTicketId, - explicitProjectId: parsed.value.projectId, - explicitTeamId: parsed.value.teamId, + explicitProjectId: parsed.value.projectId ?? selectedTarget?.projectId, + explicitTeamId: parsed.value.teamId ?? selectedTarget?.teamId, syncToggles: toggles, }); if (!synced.ok) { @@ -897,51 +1182,99 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 1; } - const runtime = await createSyncRuntime({ - ctx, - profileId: parsed.value.profileId, - }); - if (!runtime.ok) { - ctx.logger.error({ message: runtime.error }); - return 1; - } - const toggles = resolveSyncToggles({ controlPlaneConfig: ctx.controlPlaneConfig, labelsOverride: parsed.value.syncLabels, }); + const binding = resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }); if (parsed.value.from === "linear") { - const bound = resolveProjectLinearBinding({ - controlPlaneConfig: ctx.controlPlaneConfig, + const projectTargets = resolveProjectPullTargets({ + binding, + explicitProjectId: parsed.value.projectId, }); - const projectId = parsed.value.projectId ?? bound.projectId; - if (!projectId) { + if (projectTargets.length === 0) { ctx.logger.error({ message: - "Missing project id. Pass --project-id or run `hack x linear project-bind --project-id ...` first.", + "Missing project id. Pass --project-id, bind a default project, or add additional linked projects first.", }); return 1; } - const syncResult = await syncProjectFromLinearToTickets({ - runtime: runtime.value, - projectId, - limit: parsed.value.limit, - syncToggles: toggles, - }); - if (!syncResult.ok) { - ctx.logger.error({ message: syncResult.error }); - return 1; + const targetsByProfile = new Map< + string, + LinearProjectBindingTarget[] + >(); + for (const target of projectTargets) { + const key = ( + parsed.value.profileId ?? + target.profileId ?? + binding.profileId ?? + "" + ).trim(); + const existingTargets = targetsByProfile.get(key) ?? []; + existingTargets.push(target); + targetsByProfile.set(key, existingTargets); + } + + let processed = 0; + let created = 0; + let updated = 0; + let commentsPulled = 0; + let conflictsRecorded = 0; + let checkpointsRecorded = 0; + const syncedProjectIds: string[] = []; + + for (const [profileId, targets] of targetsByProfile.entries()) { + const runtime = await createSyncRuntime({ + ctx, + profileId: profileId || undefined, + }); + if (!runtime.ok) { + ctx.logger.error({ message: runtime.error }); + return 1; + } + + const syncResult = await syncProjectFromLinearProjectsToTickets({ + runtime: runtime.value, + projectIds: targets.map((target) => target.projectId), + limit: parsed.value.limit, + syncToggles: toggles, + }); + if (!syncResult.ok) { + ctx.logger.error({ message: syncResult.error }); + return 1; + } + + processed += syncResult.processed; + created += syncResult.created; + updated += syncResult.updated; + commentsPulled += syncResult.commentsPulled; + conflictsRecorded += syncResult.conflictsRecorded; + checkpointsRecorded += syncResult.checkpointsRecorded; + syncedProjectIds.push(...syncResult.projectIds); } + const syncResult = { + ok: true as const, + projectIds: [...new Set(syncedProjectIds)], + processed, + created, + updated, + commentsPulled, + conflictsRecorded, + checkpointsRecorded, + }; + if (parsed.value.json) { process.stdout.write(`${JSON.stringify(syncResult, null, 2)}\n`); } else { await display.kv({ title: "Linear project -> tickets sync", entries: [ - ["linear_project_id", projectId], + ["linear_project_ids", syncResult.projectIds.join(", ")], ["processed", String(syncResult.processed)], ["created", String(syncResult.created)], ["updated", String(syncResult.updated)], @@ -951,14 +1284,36 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 0; } - const bound = resolveProjectLinearBinding({ - controlPlaneConfig: ctx.controlPlaneConfig, + const selectedTarget = + (parsed.value.projectId + ? findProjectBindingTarget({ + binding, + projectId: parsed.value.projectId, + }) + : null) ?? + (binding.projectId + ? { + projectId: binding.projectId, + ...(binding.projectName + ? { projectName: binding.projectName } + : {}), + ...(binding.teamId ? { teamId: binding.teamId } : {}), + ...(binding.profileId ? { profileId: binding.profileId } : {}), + } + : null); + const projectId = parsed.value.projectId ?? selectedTarget?.projectId; + const runtime = await createSyncRuntime({ + ctx, + profileId: parsed.value.profileId ?? selectedTarget?.profileId, }); - const projectId = parsed.value.projectId ?? bound.projectId; + if (!runtime.ok) { + ctx.logger.error({ message: runtime.error }); + return 1; + } const syncResult = await syncProjectFromTicketsToLinear({ runtime: runtime.value, projectId, - explicitTeamId: parsed.value.teamId ?? bound.teamId, + explicitTeamId: parsed.value.teamId ?? selectedTarget?.teamId, limit: parsed.value.limit, ownerMode: parsed.value.ownerMode, syncToggles: toggles, @@ -999,6 +1354,7 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ const deliveries = await listLinearDeliveries({ controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, status: parsed.value.status, limit: parsed.value.limit, }); @@ -1015,6 +1371,7 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ await display.kv({ title: "Linear deliveries", entries: [ + ["profile", deliveries.data.profileId ?? ""], ["status", deliveries.data.status ?? "all"], ["count", String(deliveries.data.deliveries.length)], ], @@ -1047,6 +1404,7 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ const applied = await applyLinearDelivery({ controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, deliveryId: parsed.value.deliveryId, }); if (!applied.ok) { @@ -1062,6 +1420,7 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ await display.kv({ title: "Linear delivery applied", entries: [ + ["profile", applied.data.profileId ?? ""], ["delivery_id", applied.data.deliveryId], ["status", applied.data.status], ], @@ -1069,6 +1428,154 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 0; }, }, + { + name: "subscriptions", + summary: "List Linear autosync subscriptions from the auth broker", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseAutosyncSubscriptionsArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const profileId = resolveSelectedLinearProfileId({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + }); + const subscriptions = await listLinearAutosyncSubscriptions({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + projectId: parsed.value.projectId, + teamId: parsed.value.teamId, + }); + if (!subscriptions.ok) { + ctx.logger.error({ message: subscriptions.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify(subscriptions.data, null, 2)}\n` + ); + return 0; + } + + await display.kv({ + title: "Linear autosync subscriptions", + entries: [ + ["profile", subscriptions.data.profileId], + ["project_id", parsed.value.projectId ?? ""], + ["team_id", parsed.value.teamId ?? ""], + ["count", String(subscriptions.data.subscriptions.length)], + ], + }); + await display.table({ + columns: ["Project ID", "Team ID", "Mode", "Status", "Updated At"], + rows: subscriptions.data.subscriptions.map((subscription) => [ + subscription.projectId ?? "", + subscription.teamId ?? "", + subscription.mode, + subscription.status, + subscription.updatedAt ?? "", + ]), + }); + return 0; + }, + }, + { + name: "set-subscription", + summary: "Create or update a Linear autosync subscription", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseUpsertAutosyncSubscriptionArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const profileId = resolveSelectedLinearProfileId({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + }); + const saved = await upsertLinearAutosyncSubscription({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + projectId: parsed.value.projectId, + teamId: parsed.value.teamId, + mode: parsed.value.mode, + status: parsed.value.status, + }); + if (!saved.ok) { + ctx.logger.error({ message: saved.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(saved.data, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear autosync subscription saved", + entries: [ + ["profile", saved.data.profileId], + ["project_id", saved.data.subscription.projectId ?? ""], + ["team_id", saved.data.subscription.teamId ?? ""], + ["mode", saved.data.subscription.mode], + ["status", saved.data.subscription.status], + ], + }); + return 0; + }, + }, + { + name: "remove-subscription", + summary: "Remove a Linear autosync subscription", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseRemoveAutosyncSubscriptionArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const profileId = resolveSelectedLinearProfileId({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + }); + const removed = await removeLinearAutosyncSubscription({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + projectId: parsed.value.projectId, + teamId: parsed.value.teamId, + }); + if (!removed.ok) { + ctx.logger.error({ message: removed.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(removed.data, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear autosync subscription removed", + entries: [ + ["profile", removed.data.profileId], + ["project_id", removed.data.subscription.projectId ?? ""], + ["team_id", removed.data.subscription.teamId ?? ""], + ["mode", removed.data.subscription.mode], + ["status", removed.data.subscription.status], + ], + }); + return 0; + }, + }, ]; type LinearCommandContext = ExtensionCommandContext; @@ -1104,6 +1611,8 @@ async function handleLinearOAuthConnectCommand(input: { readonly expiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; }; } | { readonly ok: false; readonly error: string }; @@ -1178,6 +1687,15 @@ async function handleLinearOAuthConnectCommand(input: { ...(oauthFlow.tokenExchange.refreshTokenExpiresAt ? { refreshTokenExpiresAt: oauthFlow.tokenExchange.refreshTokenExpiresAt } : {}), + ...(oauthFlow.tokenExchange.managementToken + ? { managementToken: oauthFlow.tokenExchange.managementToken } + : {}), + ...(oauthFlow.tokenExchange.managementTokenExpiresAt + ? { + managementTokenExpiresAt: + oauthFlow.tokenExchange.managementTokenExpiresAt, + } + : {}), authRef: resolved.authRef, service: resolved.service, }); @@ -1365,6 +1883,8 @@ async function runLinearOAuthFlow(input: OAuthConnectRuntime): Promise< readonly expiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; }; } | { readonly ok: false; readonly error: string } @@ -1433,6 +1953,8 @@ async function runLinearBrokerOAuthFlow( readonly expiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; }; } | { readonly ok: false; readonly error: string } @@ -1519,6 +2041,14 @@ async function runLinearBrokerOAuthFlow( ...(flowStatus.refreshTokenExpiresAt ? { refreshTokenExpiresAt: flowStatus.refreshTokenExpiresAt } : {}), + ...(flowStatus.managementToken + ? { managementToken: flowStatus.managementToken } + : {}), + ...(flowStatus.managementTokenExpiresAt + ? { + managementTokenExpiresAt: flowStatus.managementTokenExpiresAt, + } + : {}), }, }; } @@ -1631,18 +2161,45 @@ type SyncToggles = { }; type LinearDeliveryStatus = "pending" | "applied" | "ignored"; +type LinearAutosyncMode = "manual" | "auto_apply"; +type LinearAutosyncStatus = "active" | "paused"; type DeliveriesArgs = { + profileId?: string; status?: LinearDeliveryStatus; limit?: number; json: boolean; }; type ApplyDeliveryArgs = { + profileId?: string; deliveryId: string; json: boolean; }; +type AutosyncSubscriptionsArgs = { + profileId?: string; + projectId?: string; + teamId?: string; + json: boolean; +}; + +type UpsertAutosyncSubscriptionArgs = { + profileId?: string; + projectId?: string; + teamId?: string; + mode: LinearAutosyncMode; + status: LinearAutosyncStatus; + json: boolean; +}; + +type RemoveAutosyncSubscriptionArgs = { + profileId?: string; + projectId?: string; + teamId?: string; + json: boolean; +}; + type AssigneeMappingsArgs = { profileId?: string; teamId?: string; @@ -2536,16 +3093,38 @@ async function resolveTicketAssigneeForLinear(input: { } type BrokerListDeliveriesPayload = { + readonly profileId?: string; readonly status?: string; readonly limit?: number; readonly deliveries: readonly LinearDeliverySummary[]; }; type BrokerApplyDeliveryPayload = { + readonly profileId?: string; readonly deliveryId: string; readonly status: string; }; +type LinearAutosyncSubscriptionSummary = { + readonly id: string; + readonly profileId: string; + readonly projectId?: string; + readonly teamId?: string; + readonly mode: LinearAutosyncMode; + readonly status: LinearAutosyncStatus; + readonly updatedAt?: string; +}; + +type BrokerListAutosyncSubscriptionsPayload = { + readonly profileId: string; + readonly subscriptions: readonly LinearAutosyncSubscriptionSummary[]; +}; + +type BrokerAutosyncSubscriptionMutationPayload = { + readonly profileId: string; + readonly subscription: LinearAutosyncSubscriptionSummary; +}; + async function syncIssueFromLinearToTicket(input: { readonly runtime: LinearToHackRuntime; readonly issueIdentifier: string; @@ -2917,14 +3496,15 @@ async function upsertLinearIssueForTicketSync(input: { }); } -async function syncProjectFromLinearToTickets(input: { +async function syncProjectFromLinearProjectsToTickets(input: { readonly runtime: SyncRuntime; - readonly projectId: string; + readonly projectIds: readonly string[]; readonly limit?: number; readonly syncToggles: SyncToggles; }): Promise< | { readonly ok: true; + readonly projectIds: readonly string[]; readonly processed: number; readonly created: number; readonly updated: number; @@ -2941,7 +3521,7 @@ async function syncProjectFromLinearToTickets(input: { const issuesResult = await collectLinearProjectIssuesForSync({ runtime: input.runtime, - projectId: input.projectId, + projectIds: input.projectIds, limit, }); if (!issuesResult.ok) { @@ -2975,6 +3555,7 @@ async function syncProjectFromLinearToTickets(input: { return { ok: true, + projectIds: issuesResult.projectIds, processed: issuesResult.processed, created: upserted.created, updated: upserted.updated, @@ -2986,41 +3567,62 @@ async function syncProjectFromLinearToTickets(input: { async function collectLinearProjectIssuesForSync(input: { readonly runtime: SyncRuntime; - readonly projectId: string; + readonly projectIds: readonly string[]; readonly limit: number; }): Promise< | { readonly ok: true; + readonly projectIds: readonly string[]; readonly issues: readonly LinearIssue[]; readonly processed: number; } | { readonly ok: false; readonly error: string } > { - let cursor: string | undefined; - let processed = 0; - const issues: LinearIssue[] = []; - - while (processed < input.limit) { - const page = await input.runtime.linear.listProjectIssuesPage({ - projectId: input.projectId, - first: Math.min(50, input.limit - processed), - ...(cursor ? { after: cursor } : {}), - }); - if (!page.ok) { - return { ok: false, error: page.error }; - } + const normalizedProjectIds = [ + ...new Set(input.projectIds.map((value) => value.trim())), + ].filter((value) => value.length > 0); + const issuesById = new Map(); + + for (const projectId of normalizedProjectIds) { + let cursor: string | undefined; + while (issuesById.size < input.limit) { + const page = await input.runtime.linear.listProjectIssuesPage({ + projectId, + first: Math.min(50, input.limit - issuesById.size), + ...(cursor ? { after: cursor } : {}), + }); + if (!page.ok) { + return { ok: false, error: page.error }; + } - issues.push(...page.data.issues); - processed += page.data.issues.length; - if ( - !(page.data.hasNextPage && page.data.endCursor && processed < input.limit) - ) { + for (const issue of page.data.issues) { + issuesById.set(issue.id, issue); + if (issuesById.size >= input.limit) { + break; + } + } + if ( + !( + page.data.hasNextPage && + page.data.endCursor && + issuesById.size < input.limit + ) + ) { + break; + } + cursor = page.data.endCursor; + } + if (issuesById.size >= input.limit) { break; } - cursor = page.data.endCursor; } - return { ok: true, issues, processed }; + return { + ok: true, + projectIds: normalizedProjectIds, + issues: [...issuesById.values()], + processed: issuesById.size, + }; } async function upsertLinearProjectIssuesToTickets(input: { @@ -3805,17 +4407,27 @@ function linearProfilePath(input: { readonly profileId: string }): string { return `controlPlane.extensions["${EXTENSION_ID}"].config.profiles["${escapedProfileId}"]`; } -function resolveProjectLinearBinding(input: { - readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; -}): { +type LinearProjectBindingTarget = { + readonly projectId: string; + readonly projectName?: string; + readonly teamId?: string; + readonly profileId?: string; +}; + +type ResolvedLinearProjectBinding = { readonly profileId?: string; readonly projectId?: string; readonly projectName?: string; readonly teamId?: string; -} { + readonly additionalProjects: readonly LinearProjectBindingTarget[]; +}; + +function resolveProjectLinearBinding(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; +}): ResolvedLinearProjectBinding { const overrides = input.controlPlaneConfig.routing?.overrides; if (!isRecord(overrides)) { - return {}; + return { additionalProjects: [] }; } const nested = isRecord(overrides.linear) ? overrides.linear : null; @@ -3831,15 +4443,214 @@ function resolveProjectLinearBinding(input: { const teamId = readOptionalString(nested?.teamId) ?? readOptionalString(overrides.linearTeamId); + const additionalProjects = parseAdditionalProjectBindings({ + value: nested?.additionalProjects, + defaultProjectId: projectId, + defaultProfileId: profileId, + }); return { ...(profileId ? { profileId } : {}), ...(projectId ? { projectId } : {}), ...(projectName ? { projectName } : {}), ...(teamId ? { teamId } : {}), + additionalProjects, + }; +} + +function parseAdditionalProjectBindings(input: { + readonly value: unknown; + readonly defaultProjectId?: string; + readonly defaultProfileId?: string; +}): readonly LinearProjectBindingTarget[] { + if (!Array.isArray(input.value)) { + return []; + } + const targets: LinearProjectBindingTarget[] = []; + for (const item of input.value) { + if (!isRecord(item)) { + continue; + } + const target = normalizeProjectBindingTarget({ + projectId: item.projectId, + projectName: item.projectName, + teamId: item.teamId, + profileId: item.profileId ?? input.defaultProfileId, + }); + if (!target) { + continue; + } + if ( + input.defaultProjectId && + target.projectId.toLowerCase() === input.defaultProjectId.toLowerCase() + ) { + continue; + } + targets.push(target); + } + return dedupeProjectBindingTargets({ targets }); +} + +function normalizeProjectBindingTarget(input: { + readonly projectId: unknown; + readonly projectName?: unknown; + readonly teamId?: unknown; + readonly profileId?: unknown; +}): LinearProjectBindingTarget | null { + const projectId = readOptionalString(input.projectId); + if (!projectId) { + return null; + } + const projectName = readOptionalString(input.projectName); + const teamId = readOptionalString(input.teamId); + const profileId = readOptionalString(input.profileId); + return { + projectId, + ...(projectName ? { projectName } : {}), + ...(teamId ? { teamId } : {}), + ...(profileId ? { profileId } : {}), + }; +} + +function dedupeProjectBindingTargets(input: { + readonly targets: readonly LinearProjectBindingTarget[]; +}): readonly LinearProjectBindingTarget[] { + const byProjectId = new Map(); + for (const target of input.targets) { + const key = `${(target.profileId ?? "").toLowerCase()}::${target.projectId.toLowerCase()}`; + byProjectId.set(key, target); + } + return [...byProjectId.values()].sort((left, right) => + `${left.profileId ?? ""}:${left.projectId}`.localeCompare( + `${right.profileId ?? ""}:${right.projectId}` + ) + ); +} + +function upsertAdditionalProjectBinding(input: { + readonly existing: readonly LinearProjectBindingTarget[]; + readonly target: LinearProjectBindingTarget; + readonly defaultProjectId?: string; +}): readonly LinearProjectBindingTarget[] { + if ( + input.defaultProjectId && + input.target.projectId.toLowerCase() === + input.defaultProjectId.toLowerCase() + ) { + return dedupeProjectBindingTargets({ targets: input.existing }); + } + return dedupeProjectBindingTargets({ + targets: [...input.existing, input.target], + }); +} + +function removeAdditionalProjectBinding(input: { + readonly existing: readonly LinearProjectBindingTarget[]; + readonly projectId: string; +}): readonly LinearProjectBindingTarget[] { + const normalizedProjectId = input.projectId.toLowerCase(); + return input.existing.filter( + (target) => target.projectId.toLowerCase() !== normalizedProjectId + ); +} + +function buildProjectBindingPayload(input: { + readonly binding: ResolvedLinearProjectBinding; + readonly cleared?: boolean; + readonly additionalProjectChanged?: boolean; + readonly removedProjectId?: string | null; +}): { + readonly ok: true; + readonly cleared?: boolean; + readonly profileId: string | null; + readonly projectId: string | null; + readonly projectName: string | null; + readonly teamId: string | null; + readonly additionalProjects: readonly LinearProjectBindingTarget[]; + readonly additionalProjectChanged?: boolean; + readonly removedProjectId?: string | null; +} { + return { + ok: true, + ...(input.cleared ? { cleared: true } : {}), + profileId: input.binding.profileId ?? null, + projectId: input.binding.projectId ?? null, + projectName: input.binding.projectName ?? null, + teamId: input.binding.teamId ?? null, + additionalProjects: input.binding.additionalProjects, + ...(input.additionalProjectChanged !== undefined + ? { additionalProjectChanged: input.additionalProjectChanged } + : {}), + ...(input.removedProjectId !== undefined + ? { removedProjectId: input.removedProjectId } + : {}), }; } +function resolveProjectPullTargets(input: { + readonly binding: ResolvedLinearProjectBinding; + readonly explicitProjectId?: string; +}): readonly LinearProjectBindingTarget[] { + if (input.explicitProjectId) { + const matchedTarget = findProjectBindingTarget({ + binding: input.binding, + projectId: input.explicitProjectId, + }); + return [ + matchedTarget ?? { + projectId: input.explicitProjectId, + ...(input.binding.profileId + ? { profileId: input.binding.profileId } + : {}), + }, + ]; + } + return [ + ...(input.binding.projectId + ? [ + { + projectId: input.binding.projectId, + ...(input.binding.projectName + ? { projectName: input.binding.projectName } + : {}), + ...(input.binding.teamId ? { teamId: input.binding.teamId } : {}), + ...(input.binding.profileId + ? { profileId: input.binding.profileId } + : {}), + }, + ] + : []), + ...input.binding.additionalProjects, + ]; +} + +function findProjectBindingTarget(input: { + readonly binding: ResolvedLinearProjectBinding; + readonly projectId: string; +}): LinearProjectBindingTarget | null { + const normalizedProjectId = input.projectId.toLowerCase(); + if ( + input.binding.projectId && + input.binding.projectId.toLowerCase() === normalizedProjectId + ) { + return { + projectId: input.binding.projectId, + ...(input.binding.projectName + ? { projectName: input.binding.projectName } + : {}), + ...(input.binding.teamId ? { teamId: input.binding.teamId } : {}), + ...(input.binding.profileId + ? { profileId: input.binding.profileId } + : {}), + }; + } + return ( + input.binding.additionalProjects.find( + (target) => target.projectId.toLowerCase() === normalizedProjectId + ) ?? null + ); +} + async function resolveProjectBindingDetails(input: { readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; readonly profileId?: string; @@ -3954,6 +4765,8 @@ type BrokerFlowStatusPayload = { readonly tokenExpiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; readonly error?: string; }; @@ -4343,12 +5156,20 @@ async function fetchJson(input: { async function listLinearDeliveries(input: { readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; readonly status?: LinearDeliveryStatus; readonly limit?: number; }): Promise< | { readonly ok: true; readonly data: BrokerListDeliveriesPayload } | { readonly ok: false; readonly error: string } > { + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + if (!brokerAuth.ok) { + return brokerAuth; + } const brokerConfig = resolveOAuthBrokerRuntimeConfig({ controlPlaneConfig: input.controlPlaneConfig, }); @@ -4378,6 +5199,9 @@ async function listLinearDeliveries(input: { } const response = await fetchJson({ url: url.toString(), + init: { + headers: brokerAuth.headers, + }, }); if (!response.ok) { lastError = response.error; @@ -4386,6 +5210,7 @@ async function listLinearDeliveries(input: { const payload = parseLinearDeliveriesListPayload({ payload: response.value, + profileId: brokerAuth.profileId, requestedStatus: input.status, requestedLimit: normalizedLimit, }); @@ -4394,57 +5219,270 @@ async function listLinearDeliveries(input: { } lastError = "Linear delivery payload was invalid."; } - - return { ok: false, error: lastError }; + + return { ok: false, error: lastError }; +} + +async function applyLinearDelivery(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; + readonly deliveryId: string; +}): Promise< + | { readonly ok: true; readonly data: BrokerApplyDeliveryPayload } + | { readonly ok: false; readonly error: string } +> { + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + if (!brokerAuth.ok) { + return brokerAuth; + } + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const candidatePaths = [ + `/v1/linear/deliveries/${encodeURIComponent(input.deliveryId)}/apply`, + `/v1/linear/webhook-deliveries/${encodeURIComponent(input.deliveryId)}/apply`, + `/v1/auth/linear/deliveries/${encodeURIComponent(input.deliveryId)}/apply`, + ]; + + let lastError = "Unable to apply Linear delivery."; + for (const path of candidatePaths) { + const url = new URL(path, `${brokerConfig.baseUrl}/`); + const response = await fetchJson({ + url: url.toString(), + init: { + method: "POST", + headers: { + "content-type": "application/json", + ...brokerAuth.headers, + }, + }, + }); + if (!response.ok) { + lastError = response.error; + continue; + } + const payload = parseLinearDeliveryApplyPayload({ + payload: response.value, + profileId: brokerAuth.profileId, + deliveryId: input.deliveryId, + }); + if (payload) { + return { ok: true, data: payload }; + } + lastError = "Linear delivery apply payload was invalid."; + } + + return { ok: false, error: lastError }; +} + +async function listLinearAutosyncSubscriptions(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId: string; + readonly projectId?: string; + readonly teamId?: string; +}): Promise< + | { readonly ok: true; readonly data: BrokerListAutosyncSubscriptionsPayload } + | { readonly ok: false; readonly error: string } +> { + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + if (!brokerAuth.ok) { + return brokerAuth; + } + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const url = new URL( + "/v1/auth/linear/subscriptions", + `${brokerConfig.baseUrl}/` + ); + if (input.projectId) { + url.searchParams.set("projectId", input.projectId); + } + if (input.teamId) { + url.searchParams.set("teamId", input.teamId); + } + const response = await fetchJson({ + url: url.toString(), + init: { + headers: brokerAuth.headers, + }, + }); + if (!response.ok) { + return response; + } + const payload = parseLinearAutosyncSubscriptionsPayload({ + payload: response.value, + profileId: brokerAuth.profileId, + }); + if (!payload) { + return { + ok: false, + error: "Linear autosync subscriptions payload was invalid.", + }; + } + return { ok: true, data: payload }; +} + +async function upsertLinearAutosyncSubscription(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId: string; + readonly projectId?: string; + readonly teamId?: string; + readonly mode: LinearAutosyncMode; + readonly status: LinearAutosyncStatus; +}): Promise< + | { + readonly ok: true; + readonly data: BrokerAutosyncSubscriptionMutationPayload; + } + | { readonly ok: false; readonly error: string } +> { + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + if (!brokerAuth.ok) { + return brokerAuth; + } + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const response = await fetchJson({ + url: new URL( + "/v1/auth/linear/subscriptions", + `${brokerConfig.baseUrl}/` + ).toString(), + init: { + method: "POST", + headers: { + "content-type": "application/json", + ...brokerAuth.headers, + }, + body: JSON.stringify({ + profileId: brokerAuth.profileId, + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.teamId ? { teamId: input.teamId } : {}), + mode: input.mode, + status: input.status, + }), + }, + }); + if (!response.ok) { + return response; + } + const payload = parseLinearAutosyncSubscriptionMutationPayload({ + payload: response.value, + profileId: brokerAuth.profileId, + }); + if (!payload) { + return { + ok: false, + error: "Linear autosync subscription payload was invalid.", + }; + } + return { ok: true, data: payload }; +} + +async function removeLinearAutosyncSubscription(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId: string; + readonly projectId?: string; + readonly teamId?: string; +}): Promise< + | { + readonly ok: true; + readonly data: BrokerAutosyncSubscriptionMutationPayload; + } + | { readonly ok: false; readonly error: string } +> { + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + if (!brokerAuth.ok) { + return brokerAuth; + } + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const response = await fetchJson({ + url: new URL( + "/v1/auth/linear/subscriptions/remove", + `${brokerConfig.baseUrl}/` + ).toString(), + init: { + method: "POST", + headers: { + "content-type": "application/json", + ...brokerAuth.headers, + }, + body: JSON.stringify({ + profileId: brokerAuth.profileId, + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.teamId ? { teamId: input.teamId } : {}), + }), + }, + }); + if (!response.ok) { + return response; + } + const payload = parseLinearAutosyncSubscriptionMutationPayload({ + payload: response.value, + profileId: brokerAuth.profileId, + }); + if (!payload) { + return { + ok: false, + error: "Linear autosync subscription payload was invalid.", + }; + } + return { ok: true, data: payload }; } -async function applyLinearDelivery(input: { +async function resolveLinearBrokerAuthorization(input: { readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; - readonly deliveryId: string; + readonly profileId?: string; }): Promise< - | { readonly ok: true; readonly data: BrokerApplyDeliveryPayload } + | { + readonly ok: true; + readonly profileId: string; + readonly headers: Record; + } | { readonly ok: false; readonly error: string } > { - const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + const profileId = resolveSelectedLinearProfileId({ controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, }); - const candidatePaths = [ - `/v1/linear/deliveries/${encodeURIComponent(input.deliveryId)}/apply`, - `/v1/linear/webhook-deliveries/${encodeURIComponent(input.deliveryId)}/apply`, - `/v1/auth/linear/deliveries/${encodeURIComponent(input.deliveryId)}/apply`, - ]; - - let lastError = "Unable to apply Linear delivery."; - for (const path of candidatePaths) { - const url = new URL(path, `${brokerConfig.baseUrl}/`); - const response = await fetchJson({ - url: url.toString(), - init: { - method: "POST", - headers: { - "content-type": "application/json", - }, - }, - }); - if (!response.ok) { - lastError = response.error; - continue; - } - const payload = parseLinearDeliveryApplyPayload({ - payload: response.value, - deliveryId: input.deliveryId, - }); - if (payload) { - return { ok: true, data: payload }; - } - lastError = "Linear delivery apply payload was invalid."; + const management = await resolveLinearBrokerManagementToken({ + controlPlaneConfig: input.controlPlaneConfig, + profileId, + allowProjectOverride: false, + }); + if (!management.ok) { + return { + ok: false, + error: management.error, + }; } - - return { ok: false, error: lastError }; + return { + ok: true, + profileId, + headers: { + authorization: `Bearer ${management.managementToken}`, + }, + }; } function parseLinearDeliveriesListPayload(input: { readonly payload: unknown; + readonly profileId?: string; readonly requestedStatus?: LinearDeliveryStatus; readonly requestedLimit: number; }): BrokerListDeliveriesPayload | null { @@ -4465,6 +5503,7 @@ function parseLinearDeliveriesListPayload(input: { .filter((value): value is LinearDeliverySummary => value !== null) .slice(0, input.requestedLimit); return { + ...(input.profileId ? { profileId: input.profileId } : {}), status: readOptionalString(input.payload.status) ?? input.requestedStatus ?? @@ -4512,6 +5551,7 @@ function parseLinearDeliverySummary(input: { function parseLinearDeliveryApplyPayload(input: { readonly payload: unknown; + readonly profileId?: string; readonly deliveryId: string; }): BrokerApplyDeliveryPayload | null { if (!isRecord(input.payload)) { @@ -4526,11 +5566,81 @@ function parseLinearDeliveryApplyPayload(input: { return null; } return { + ...(input.profileId ? { profileId: input.profileId } : {}), deliveryId, status, }; } +function parseLinearAutosyncSubscriptionsPayload(input: { + readonly payload: unknown; + readonly profileId: string; +}): BrokerListAutosyncSubscriptionsPayload | null { + if ( + !(isRecord(input.payload) && Array.isArray(input.payload.subscriptions)) + ) { + return null; + } + const subscriptions = input.payload.subscriptions + .map((value) => parseLinearAutosyncSubscriptionSummary({ value })) + .filter( + (value): value is LinearAutosyncSubscriptionSummary => value !== null + ); + return { + profileId: input.profileId, + subscriptions, + }; +} + +function parseLinearAutosyncSubscriptionMutationPayload(input: { + readonly payload: unknown; + readonly profileId: string; +}): BrokerAutosyncSubscriptionMutationPayload | null { + if (!isRecord(input.payload)) { + return null; + } + const subscription = parseLinearAutosyncSubscriptionSummary({ + value: input.payload.subscription, + }); + if (!subscription) { + return null; + } + return { + profileId: input.profileId, + subscription, + }; +} + +function parseLinearAutosyncSubscriptionSummary(input: { + readonly value: unknown; +}): LinearAutosyncSubscriptionSummary | null { + if (!isRecord(input.value)) { + return null; + } + const id = readOptionalString(input.value.id); + const profileId = readOptionalString(input.value.profileId); + const mode = readOptionalString(input.value.mode); + const status = readOptionalString(input.value.status); + if (!(id && profileId && (mode === "manual" || mode === "auto_apply"))) { + return null; + } + if (!(status === "active" || status === "paused")) { + return null; + } + const projectId = readOptionalString(input.value.projectId) ?? undefined; + const teamId = readOptionalString(input.value.teamId) ?? undefined; + const updatedAt = readOptionalString(input.value.updatedAt) ?? undefined; + return { + id, + profileId, + ...(projectId ? { projectId } : {}), + ...(teamId ? { teamId } : {}), + mode, + status, + ...(updatedAt ? { updatedAt } : {}), + }; +} + async function exchangeLinearOAuthCode(input: { readonly tokenUrl: string; readonly clientId: string; @@ -4869,6 +5979,14 @@ type ProjectBindArgs = { json: boolean; }; +type ProjectLinkArgs = { + profileId?: string; + projectId?: string; + projectName?: string; + teamId?: string; + json: boolean; +}; + type SyncIssueArgs = { from: "linear" | "hack"; issueIdentifier?: string; @@ -4926,6 +6044,7 @@ function parseDeliveriesArgs(input: { index: i, out: value, keys: { + profile: "profileId", status: "status", }, }); @@ -4972,6 +6091,7 @@ function parseApplyDeliveryArgs(input: { index: i, out: value, keys: { + profile: "profileId", "delivery-id": "deliveryId", }, }); @@ -4997,6 +6117,117 @@ function parseApplyDeliveryArgs(input: { }; } +function parseAutosyncSubscriptionsArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: AutosyncSubscriptionsArgs } + | { readonly ok: false; readonly error: string } { + const value: AutosyncSubscriptionsArgs = { json: false }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "project-id": "projectId", + "team-id": "teamId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + +function parseUpsertAutosyncSubscriptionArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: UpsertAutosyncSubscriptionArgs } + | { readonly ok: false; readonly error: string } { + const value: UpsertAutosyncSubscriptionArgs = { + mode: "auto_apply", + status: "active", + json: false, + }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "project-id": "projectId", + "team-id": "teamId", + mode: "mode", + status: "status", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + if (!(value.mode === "manual" || value.mode === "auto_apply")) { + return { + ok: false, + error: `Invalid --mode value: ${String(value.mode)}. Expected manual|auto_apply.`, + }; + } + if (!(value.status === "active" || value.status === "paused")) { + return { + ok: false, + error: `Invalid --status value: ${String(value.status)}. Expected active|paused.`, + }; + } + return { ok: true, value }; +} + +function parseRemoveAutosyncSubscriptionArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: RemoveAutosyncSubscriptionArgs } + | { readonly ok: false; readonly error: string } { + const value: RemoveAutosyncSubscriptionArgs = { json: false }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "project-id": "projectId", + "team-id": "teamId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + function parseAssigneeMappingsArgs(input: { readonly args: readonly string[]; }): @@ -5448,6 +6679,40 @@ function parseProjectBindArgs(input: { return { ok: true, value }; } +function parseProjectLinkArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ProjectLinkArgs } + | { readonly ok: false; readonly error: string } { + const value: ProjectLinkArgs = { + json: false, + }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "project-id": "projectId", + "project-name": "projectName", + "team-id": "teamId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + function parseSyncIssueArgs(input: { readonly args: readonly string[]; }): @@ -5619,13 +6884,19 @@ function normalizeOAuthActor(value: string | undefined): "user" | "app" | null { export const __testOnly = { buildOAuthArgsFromConnectArgs, detectAuthoritativeFieldConflicts, + findProjectBindingTarget, parseApplyDeliveryArgs, + parseAutosyncSubscriptionsArgs, parseAssigneeMappingsArgs, parseConnectArgs, parseDeliveriesArgs, parseProjectBindArgs, + parseProjectLinkArgs, parseProjectsArgs, parseRemoveAssigneeMappingArgs, + parseRemoveAutosyncSubscriptionArgs, + resolveProjectLinearBinding, + resolveProjectPullTargets, resolveOAuthBrokerRuntimeConfig, resolveTicketAssigneeForLinear, parseSetupArgs, @@ -5633,6 +6904,7 @@ export const __testOnly = { parseSyncIssueArgs, parseSyncProjectArgs, parseUpsertAssigneeMappingArgs, + parseUpsertAutosyncSubscriptionArgs, selectLinearCommentsToAppend, selectTicketCommentsToPush, shouldFallbackConnectToOAuth, diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts index cfec75171..cd8eef03d 100644 --- a/src/control-plane/extensions/tickets/commands.ts +++ b/src/control-plane/extensions/tickets/commands.ts @@ -613,6 +613,79 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ return 0; }, }, + { + name: "review-note", + summary: "Append a shared ticket review note", + scope: "project", + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + + const parsed = parseTicketsArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const ticketId = (parsed.value.rest[0] ?? "").trim(); + if (!ticketId) { + ctx.logger.error({ + message: + 'Usage: hack x tickets review-note [--body "..."] [--body-file ] [--body-stdin] [--json]', + }); + return 1; + } + + const body = await resolveTicketBody({ + body: parsed.value.body, + bodyFile: parsed.value.bodyFile, + bodyStdin: parsed.value.bodyStdin, + }); + if (!body) { + ctx.logger.error({ + message: + "Review note body is required. Use --body, --body-file, or --body-stdin.", + }); + return 1; + } + + await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); + + const store = createTicketsStore({ + projectRoot: ctx.project.projectRoot, + projectId: ctx.projectId, + projectName: ctx.projectName, + controlPlaneConfig: ctx.controlPlaneConfig, + logger: ctx.logger, + }); + + const appended = await store.appendReviewNote({ + ticketId, + body, + actor: parsed.value.actor, + }); + if (!appended.ok) { + ctx.logger.error({ message: appended.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify({ reviewNote: appended.reviewNote }, null, 2)}\n` + ); + return 0; + } + + await display.panel({ + title: "Ticket review note", + tone: "success", + lines: [`${ticketId} review note appended`, appended.reviewNote.body], + }); + return 0; + }, + }, { name: "list", summary: "List tickets", @@ -761,6 +834,7 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ { ticket, comments: detail.comments, + reviewNotes: detail.reviewNotes, syncCheckpoints: detail.syncCheckpoints, conflicts: detail.conflicts, events: detail.events, @@ -818,6 +892,19 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ }); } + if (detail.reviewNotes.length > 0) { + await display.table({ + columns: ["note_id", "actor", "created_at", "context", "body"], + rows: detail.reviewNotes.map((reviewNote) => [ + reviewNote.noteId, + reviewNote.actor, + reviewNote.createdAt, + reviewNote.context ?? "", + reviewNote.body, + ]), + }); + } + if (detail.syncCheckpoints.length > 0) { await display.table({ columns: [ diff --git a/src/control-plane/extensions/tickets/store.ts b/src/control-plane/extensions/tickets/store.ts index c1a92fc70..cdf69ba2c 100644 --- a/src/control-plane/extensions/tickets/store.ts +++ b/src/control-plane/extensions/tickets/store.ts @@ -58,6 +58,15 @@ export type TicketComment = { readonly externalUrl?: string; }; +export type TicketReviewNote = { + readonly noteId: string; + readonly ticketId: string; + readonly body: string; + readonly actor: string; + readonly createdAt: string; + readonly context?: string; +}; + export type TicketSyncCheckpoint = { readonly checkpointId: string; readonly ticketId: string; @@ -112,6 +121,10 @@ export type TicketStoreSnapshot = { readonly tickets: readonly TicketSummary[]; readonly eventsByTicket: ReadonlyMap; readonly commentsByTicket: ReadonlyMap; + readonly reviewNotesByTicket: ReadonlyMap< + string, + readonly TicketReviewNote[] + >; readonly syncCheckpointsByTicket: ReadonlyMap< string, readonly TicketSyncCheckpoint[] @@ -139,6 +152,7 @@ type SyncResult = type MaterializedTicketState = { readonly tickets: Map; readonly commentsByTicket: Map; + readonly reviewNotesByTicket: Map; readonly syncCheckpointsByTicket: Map; readonly conflictsByTicket: Map; }; @@ -203,6 +217,7 @@ export function createTicketsStore(opts: { readonly ticket: TicketSummary | null; readonly events: readonly TicketEvent[]; readonly comments: readonly TicketComment[]; + readonly reviewNotes: readonly TicketReviewNote[]; readonly syncCheckpoints: readonly TicketSyncCheckpoint[]; readonly conflicts: readonly TicketSyncConflict[]; }>; @@ -217,6 +232,15 @@ export function createTicketsStore(opts: { | { readonly ok: true; readonly comment: TicketComment } | { readonly ok: false; readonly error: string } >; + readonly appendReviewNote: (input: { + readonly ticketId: string; + readonly body: string; + readonly context?: string; + readonly actor?: string; + }) => Promise< + | { readonly ok: true; readonly reviewNote: TicketReviewNote } + | { readonly ok: false; readonly error: string } + >; readonly linkCommentExternalId: (input: { readonly ticketId: string; readonly commentId: string; @@ -362,6 +386,7 @@ export function createTicketsStore(opts: { }): MaterializedTicketState => { const tickets = new Map(); const commentsByTicket = new Map(); + const reviewNotesByTicket = new Map(); const syncCheckpointsByTicket = new Map(); const conflictsByTicket = new Map(); @@ -369,6 +394,7 @@ export function createTicketsStore(opts: { applyTicketEvent({ tickets, commentsByTicket, + reviewNotesByTicket, syncCheckpointsByTicket, conflictsByTicket, event, @@ -378,6 +404,7 @@ export function createTicketsStore(opts: { return { tickets: applyDerivedBlocks(tickets), commentsByTicket, + reviewNotesByTicket, syncCheckpointsByTicket, conflictsByTicket, }; @@ -657,6 +684,7 @@ export function createTicketsStore(opts: { ticket: snapshot.tickets.get(ticketId) ?? null, events: events.filter((event) => event.ticketId === ticketId), comments: snapshot.commentsByTicket.get(ticketId) ?? [], + reviewNotes: snapshot.reviewNotesByTicket.get(ticketId) ?? [], syncCheckpoints: snapshot.syncCheckpointsByTicket.get(ticketId) ?? [], conflicts: snapshot.conflictsByTicket.get(ticketId) ?? [], }; @@ -718,6 +746,50 @@ export function createTicketsStore(opts: { }; }, + appendReviewNote: async (input) => { + const tickets = await materializeTickets(); + if (!tickets.has(input.ticketId)) { + return { ok: false, error: `Ticket not found: ${input.ticketId}` }; + } + + const body = input.body.trim(); + if (!body) { + return { ok: false, error: "Review note body cannot be empty." }; + } + + const noteId = randomUUID(); + const context = normalizeOptionalMetadataString({ + value: input.context, + }); + const event = buildEvent({ + ticketId: input.ticketId, + type: "ticket.review_note_appended", + payload: { + noteId, + body, + ...(context ? { context } : {}), + }, + actor: input.actor, + }); + + const wrote = await git.appendEvents({ events: [event] }); + if (!wrote.ok) { + return wrote; + } + + return { + ok: true, + reviewNote: { + noteId, + ticketId: input.ticketId, + body, + actor: event.actor, + createdAt: event.tsIso, + ...(context ? { context } : {}), + }, + }; + }, + linkCommentExternalId: async (input) => { const detail = await readAllEvents(); const snapshot = materializeSnapshotFromEvents({ events: detail }); @@ -925,6 +997,7 @@ export function createTicketsStore(opts: { tickets: sortTickets({ tickets: snapshot.tickets.values() }), eventsByTicket: groupEventsByTicket({ events }), commentsByTicket: snapshot.commentsByTicket, + reviewNotesByTicket: snapshot.reviewNotesByTicket, syncCheckpointsByTicket: snapshot.syncCheckpointsByTicket, conflictsByTicket: snapshot.conflictsByTicket, }; @@ -941,6 +1014,7 @@ export function createTicketsStore(opts: { function applyTicketEvent(input: { readonly tickets: Map; readonly commentsByTicket: Map; + readonly reviewNotesByTicket: Map; readonly syncCheckpointsByTicket: Map; readonly conflictsByTicket: Map; readonly event: TicketEvent; @@ -982,6 +1056,14 @@ function applyTicketEvent(input: { }); break; } + case "ticket.review_note_appended": { + applyTicketReviewNoteAppendedEvent({ + tickets: input.tickets, + reviewNotesByTicket: input.reviewNotesByTicket, + event: input.event, + }); + break; + } case "ticket.sync_checkpoint_recorded": { applyTicketSyncCheckpointRecordedEvent({ tickets: input.tickets, @@ -1233,7 +1315,13 @@ function applyTicketCommentAppendedEvent(input: { return; } - const body = readOptionalStringPayload({ value: input.event.payload.body }); + const body = + readOptionalStringPayload({ + value: input.event.payload.body, + }) ?? + readOptionalStringPayload({ + value: input.event.payload.markdown, + }); if (!body) { return; } @@ -1301,6 +1389,42 @@ function applyTicketCommentLinkedEvent(input: { ); } +function applyTicketReviewNoteAppendedEvent(input: { + readonly tickets: Map; + readonly reviewNotesByTicket: Map; + readonly event: TicketEvent; +}): void { + if (!input.tickets.has(input.event.ticketId)) { + return; + } + + const body = + readOptionalStringPayload({ value: input.event.payload.body }) ?? + readOptionalStringPayload({ value: input.event.payload.markdown }); + if (!body) { + return; + } + + const context = readOptionalStringPayload({ + value: input.event.payload.context, + }); + + appendMapValue({ + map: input.reviewNotesByTicket, + key: input.event.ticketId, + value: { + noteId: + readOptionalStringPayload({ value: input.event.payload.noteId }) ?? + input.event.eventId, + ticketId: input.event.ticketId, + body, + actor: input.event.actor, + createdAt: input.event.tsIso, + ...(context ? { context } : {}), + }, + }); +} + function applyTicketSyncCheckpointRecordedEvent(input: { readonly tickets: Map; readonly syncCheckpointsByTicket: Map; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ab946dc21..d63db1e8f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -769,7 +769,7 @@ function registerTools(opts: { readonly server: McpServer }): void { ...projectSelectorInput, ...linearCommonInput, projectId: z.string().describe("Linear project id to bind").optional(), - projectName: z + linearProjectName: z .string() .describe("Linear project name override") .optional(), @@ -794,7 +794,9 @@ function registerTools(opts: { readonly server: McpServer }): void { "--json", ...(input.profile ? ["--profile", input.profile] : []), ...(input.projectId ? ["--project-id", input.projectId] : []), - ...(input.projectName ? ["--project-name", input.projectName] : []), + ...(input.linearProjectName + ? ["--project-name", input.linearProjectName] + : []), ...(input.teamId ? ["--team-id", input.teamId] : []), ...(input.clear ? ["--clear"] : []), ]; @@ -812,6 +814,108 @@ function registerTools(opts: { readonly server: McpServer }): void { } ); + opts.server.registerTool( + "hack.linear.project.link", + { + title: "Link additional Linear project", + description: + "Add an additional Linear project to the current hack project sync scope.", + inputSchema: { + ...projectSelectorInput, + ...linearCommonInput, + projectId: z.string().describe("Linear project id to add"), + linearProjectName: z + .string() + .describe("Linear project name override") + .optional(), + projectName: z + .string() + .describe("Deprecated alias for the Linear project name override") + .optional(), + teamId: z.string().describe("Linear team id override").optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const projectCwd = await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: true, + }); + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const args = [ + "linear", + "project-link", + "--json", + "--project-id", + input.projectId, + ...(input.profile ? ["--profile", input.profile] : []), + ...((input.linearProjectName ?? input.projectName) + ? [ + "--project-name", + input.linearProjectName ?? input.projectName ?? "", + ] + : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.project.link", + args, + cwd: projectCwd.cwd, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.project.unlink", + { + title: "Unlink additional Linear project", + description: + "Remove an additional Linear project from the current hack project sync scope.", + inputSchema: { + ...projectSelectorInput, + projectId: z.string().describe("Linear project id to remove"), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const projectCwd = await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: true, + }); + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const result = await runHackCommand({ + tool: "hack.linear.project.unlink", + args: [ + "linear", + "project-unlink", + "--json", + "--project-id", + input.projectId, + ], + cwd: projectCwd.cwd, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + opts.server.registerTool( "hack.linear.sync.issue", { @@ -938,6 +1042,7 @@ function registerTools(opts: { readonly server: McpServer }): void { description: "List Linear webhook deliveries from the auth broker for manual review/apply flows.", inputSchema: { + ...linearCommonInput, status: z .enum(["pending", "applied", "ignored"]) .describe("Delivery status filter") @@ -951,6 +1056,7 @@ function registerTools(opts: { readonly server: McpServer }): void { "linear", "deliveries", "--json", + ...(input.profile ? ["--profile", input.profile] : []), ...(input.status ? ["--status", input.status] : []), ...(input.limit ? ["--limit", String(input.limit)] : []), ]; @@ -974,6 +1080,7 @@ function registerTools(opts: { readonly server: McpServer }): void { description: "Mark a pending Linear webhook delivery as applied after manual handling.", inputSchema: { + ...linearCommonInput, deliveryId: z.string().describe("Recorded Linear delivery id"), }, outputSchema: toolOutputSchema, @@ -983,6 +1090,7 @@ function registerTools(opts: { readonly server: McpServer }): void { "linear", "apply-delivery", "--json", + ...(input.profile ? ["--profile", input.profile] : []), "--delivery-id", input.deliveryId, ]; @@ -999,6 +1107,133 @@ function registerTools(opts: { readonly server: McpServer }): void { } ); + opts.server.registerTool( + "hack.linear.subscriptions.list", + { + title: "List Linear autosync subscriptions", + description: + "List broker-backed Linear autosync subscriptions for the selected profile and optional project/team scope.", + inputSchema: { + ...linearCommonInput, + projectId: z + .string() + .describe("Optional Linear project id filter") + .optional(), + teamId: z + .string() + .describe("Optional Linear team id filter") + .optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const args = [ + "linear", + "subscriptions", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.projectId ? ["--project-id", input.projectId] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.subscriptions.list", + args, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.subscriptions.upsert", + { + title: "Upsert Linear autosync subscription", + description: + "Create or update a broker-backed Linear autosync subscription for a profile/project/team scope.", + inputSchema: { + ...linearCommonInput, + projectId: z + .string() + .describe("Optional Linear project id scope") + .optional(), + teamId: z.string().describe("Optional Linear team id scope").optional(), + mode: z + .enum(["manual", "auto_apply"]) + .describe("Autosync mode") + .optional(), + status: z + .enum(["active", "paused"]) + .describe("Subscription status") + .optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const args = [ + "linear", + "set-subscription", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.projectId ? ["--project-id", input.projectId] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ...(input.mode ? ["--mode", input.mode] : []), + ...(input.status ? ["--status", input.status] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.subscriptions.upsert", + args, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + + opts.server.registerTool( + "hack.linear.subscriptions.remove", + { + title: "Remove Linear autosync subscription", + description: + "Remove a broker-backed Linear autosync subscription for a profile/project/team scope.", + inputSchema: { + ...linearCommonInput, + projectId: z + .string() + .describe("Optional Linear project id scope") + .optional(), + teamId: z.string().describe("Optional Linear team id scope").optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const args = [ + "linear", + "remove-subscription", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.projectId ? ["--project-id", input.projectId] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.subscriptions.remove", + args, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + opts.server.registerTool( "hack.linear.assignee-mappings.list", { diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index 7cf1a91a5..6f5c9795c 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -2,6 +2,43 @@ import { expect, test } from "bun:test"; import { __testOnly } from "../src/control-plane/extensions/linear/commands.ts"; +const minimalLinearBindingConfig = { + routing: { + mode: "existing_only", + bootstrap: { + enabled: false, + setAsProjectNode: false, + }, + overrides: { + linear: { + profile: "work", + projectId: "proj_default", + projectName: "Default", + teamId: "team_default", + additionalProjects: [ + { + projectId: "proj_extra", + projectName: "Extra", + teamId: "team_extra", + }, + { + projectId: "proj_default", + projectName: "Duplicate default", + teamId: "team_default", + }, + { + projectId: "proj_extra", + projectName: "Duplicate extra", + teamId: "team_extra", + }, + ], + }, + }, + }, +} as unknown as Parameters< + typeof __testOnly.resolveProjectLinearBinding +>[0]["controlPlaneConfig"]; + test("parseSetupArgs parses project binding flags", () => { const parsed = __testOnly.parseSetupArgs({ args: [ @@ -122,6 +159,149 @@ test("parseSyncProjectArgs parses owner filter and limits", () => { }); }); +test("parseProjectLinkArgs parses additional project routing flags", () => { + const parsed = __testOnly.parseProjectLinkArgs({ + args: [ + "--profile", + "work", + "--project-id", + "proj_456", + "--project-name", + "Operations", + "--team-id", + "team_456", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + projectId: "proj_456", + projectName: "Operations", + teamId: "team_456", + json: true, + }); +}); + +test("resolveProjectLinearBinding keeps a default project and deduped additional projects", () => { + const binding = __testOnly.resolveProjectLinearBinding({ + controlPlaneConfig: minimalLinearBindingConfig, + }); + + expect(binding).toEqual({ + profileId: "work", + projectId: "proj_default", + projectName: "Default", + teamId: "team_default", + additionalProjects: [ + { + profileId: "work", + projectId: "proj_extra", + projectName: "Duplicate extra", + teamId: "team_extra", + }, + ], + }); +}); + +test("resolveProjectPullTargets includes default and additional bound projects", () => { + const targets = __testOnly.resolveProjectPullTargets({ + binding: { + profileId: "work", + projectId: "proj_default", + projectName: "Default", + teamId: "team_default", + additionalProjects: [ + { + projectId: "proj_extra", + projectName: "Extra", + teamId: "team_extra", + }, + ], + }, + }); + + expect(targets).toEqual([ + { + profileId: "work", + projectId: "proj_default", + projectName: "Default", + teamId: "team_default", + }, + { + projectId: "proj_extra", + projectName: "Extra", + teamId: "team_extra", + }, + ]); +}); + +test("resolveProjectPullTargets keeps per-project profile overrides for linked projects", () => { + const targets = __testOnly.resolveProjectPullTargets({ + binding: { + profileId: "work", + projectId: "proj_default", + projectName: "Default", + teamId: "team_default", + additionalProjects: [ + { + profileId: "ops", + projectId: "proj_ops", + projectName: "Operations", + teamId: "team_ops", + }, + ], + }, + }); + + expect(targets).toEqual([ + { + profileId: "work", + projectId: "proj_default", + projectName: "Default", + teamId: "team_default", + }, + { + profileId: "ops", + projectId: "proj_ops", + projectName: "Operations", + teamId: "team_ops", + }, + ]); +}); + +test("findProjectBindingTarget resolves additional linked projects by id", () => { + const target = __testOnly.findProjectBindingTarget({ + binding: { + profileId: "work", + projectId: "proj_default", + projectName: "Default", + teamId: "team_default", + additionalProjects: [ + { + profileId: "ops", + projectId: "proj_ops", + projectName: "Operations", + teamId: "team_ops", + }, + ], + }, + projectId: "proj_ops", + }); + + expect(target).toEqual({ + profileId: "ops", + projectId: "proj_ops", + projectName: "Operations", + teamId: "team_ops", + }); +}); + test("connect falls back to oauth when no token input exists", () => { const envKey = "HACK_LINEAR_TEST_TOKEN"; const previous = process.env[envKey]; @@ -223,7 +403,15 @@ test("buildOAuthArgsFromConnectArgs maps connect defaults into oauth args", () = test("parseDeliveriesArgs parses status, limit, and json flags", () => { const parsed = __testOnly.parseDeliveriesArgs({ - args: ["--status", "applied", "--limit", "10", "--json"], + args: [ + "--profile", + "work", + "--status", + "applied", + "--limit", + "10", + "--json", + ], }); expect(parsed.ok).toBe(true); @@ -232,6 +420,7 @@ test("parseDeliveriesArgs parses status, limit, and json flags", () => { } expect(parsed.value).toEqual({ + profileId: "work", status: "applied", limit: 10, json: true, @@ -268,6 +457,103 @@ test("parseAssigneeMappingsArgs parses profile and team filters", () => { }); }); +test("parseAutosyncSubscriptionsArgs parses profile and scope filters", () => { + const parsed = __testOnly.parseAutosyncSubscriptionsArgs({ + args: [ + "--profile", + "work", + "--project-id", + "proj-1", + "--team-id", + "team-1", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + projectId: "proj-1", + teamId: "team-1", + json: true, + }); +}); + +test("parseUpsertAutosyncSubscriptionArgs validates mode and status", () => { + const parsed = __testOnly.parseUpsertAutosyncSubscriptionArgs({ + args: ["--mode", "invalid"], + }); + + expect(parsed.ok).toBe(false); + if (parsed.ok) { + return; + } + + expect(parsed.error).toContain("manual|auto_apply"); +}); + +test("parseUpsertAutosyncSubscriptionArgs parses autosync scope and state", () => { + const parsed = __testOnly.parseUpsertAutosyncSubscriptionArgs({ + args: [ + "--profile", + "work", + "--project-id", + "proj-1", + "--team-id", + "team-1", + "--mode", + "auto_apply", + "--status", + "paused", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + projectId: "proj-1", + teamId: "team-1", + mode: "auto_apply", + status: "paused", + json: true, + }); +}); + +test("parseRemoveAutosyncSubscriptionArgs parses optional scope", () => { + const parsed = __testOnly.parseRemoveAutosyncSubscriptionArgs({ + args: [ + "--profile", + "work", + "--project-id", + "proj-1", + "--team-id", + "team-1", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + projectId: "proj-1", + teamId: "team-1", + json: true, + }); +}); + test("parseUpsertAssigneeMappingArgs requires a local assignee and remote target", () => { const parsed = __testOnly.parseUpsertAssigneeMappingArgs({ args: ["--profile", "work", "--local-assignee", "alice@hack"], @@ -511,7 +797,9 @@ test("syncIssueFromLinearToTicket preserves hack authority, appends comments, an const runtime = { profileId: "default", apiUrl: "https://api.linear.app/graphql", - projectBinding: {}, + projectBinding: { + additionalProjects: [], + }, tickets: { listTickets: async () => [ { @@ -556,6 +844,7 @@ test("syncIssueFromLinearToTicket preserves hack authority, appends comments, an externalId: "linear-comment-1", }, ], + reviewNotes: [], syncCheckpoints: [], conflicts: [], }), @@ -684,6 +973,7 @@ test("syncTicketToLinearIssue pushes missing local comments and records a checkp apiUrl: "https://api.linear.app/graphql", projectBinding: { teamId: "team-1", + additionalProjects: [], }, assigneeMappings: [], tickets: { @@ -728,6 +1018,7 @@ test("syncTicketToLinearIssue pushes missing local comments and records a checkp createdAt: "2026-03-05T10:01:00.000Z", }, ], + reviewNotes: [], syncCheckpoints: [], conflicts: [], }), diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index b39a81bfc..8e6139f4e 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -40,9 +40,15 @@ testIntegration( expect(names).toContain("hack.project.logs.tail"); expect(names).toContain("hack.project.open"); expect(names).toContain("hack.linear.status"); + expect(names).toContain("hack.linear.project.bind"); + expect(names).toContain("hack.linear.project.link"); + expect(names).toContain("hack.linear.project.unlink"); expect(names).toContain("hack.linear.sync.project"); expect(names).toContain("hack.linear.deliveries.list"); expect(names).toContain("hack.linear.deliveries.apply"); + expect(names).toContain("hack.linear.subscriptions.list"); + expect(names).toContain("hack.linear.subscriptions.upsert"); + expect(names).toContain("hack.linear.subscriptions.remove"); expect(names).toContain("hack.linear.assignee-mappings.list"); expect(names).toContain("hack.linear.assignee-mappings.upsert"); expect(names).toContain("hack.linear.assignee-mappings.remove"); @@ -95,6 +101,79 @@ testIntegration( } ); +testIntegration( + "hack.linear.project.link forwards additional project routing flags", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.project.link", + arguments: { + profile: "work", + projectId: "proj-1", + linearProjectName: "Ops", + teamId: "team-1", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + ok: true, + profileId: "work", + projectId: null, + projectName: null, + teamId: null, + additionalProjects: [ + { + profileId: "work", + projectId: "proj-1", + projectName: "Ops", + teamId: "team-1", + }, + ], + additionalProjectChanged: true, + }); + } +); + +testIntegration( + "hack.linear.project.unlink forwards the project id", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.project.unlink", + arguments: { + projectId: "proj-1", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + ok: true, + profileId: "default", + projectId: "proj-default", + projectName: "Default Project", + teamId: "team-default", + additionalProjects: [], + additionalProjectChanged: true, + removedProjectId: "proj-1", + }); + } +); + testIntegration( "hack.linear.deliveries.list forwards status and limit flags", { timeout: 20_000 }, @@ -116,6 +195,7 @@ testIntegration( expect(result.isError).toBeUndefined(); expect(structured.ok).toBe(true); expect(structured.data).toEqual({ + profileId: "default", status: "pending", limit: 5, deliveries: [{ id: "delivery-1", status: "pending" }], @@ -143,12 +223,122 @@ testIntegration( expect(result.isError).toBeUndefined(); expect(structured.ok).toBe(true); expect(structured.data).toEqual({ + profileId: "default", deliveryId: "delivery-1", status: "applied", }); } ); +testIntegration( + "hack.linear.subscriptions.list forwards profile and scope filters", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.subscriptions.list", + arguments: { + profile: "work", + projectId: "proj-1", + teamId: "team-1", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + profileId: "work", + subscriptions: [ + { + id: "sub-1", + profileId: "work", + projectId: "proj-1", + teamId: "team-1", + mode: "auto_apply", + status: "active", + }, + ], + }); + } +); + +testIntegration( + "hack.linear.subscriptions.upsert forwards autosync settings", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.subscriptions.upsert", + arguments: { + profile: "work", + projectId: "proj-1", + teamId: "team-1", + mode: "auto_apply", + status: "active", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + profileId: "work", + subscription: { + id: "sub-1", + profileId: "work", + projectId: "proj-1", + teamId: "team-1", + mode: "auto_apply", + status: "active", + }, + }); + } +); + +testIntegration( + "hack.linear.subscriptions.remove forwards autosync scope", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.subscriptions.remove", + arguments: { + profile: "work", + projectId: "proj-1", + teamId: "team-1", + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + profileId: "work", + subscription: { + id: "sub-1", + profileId: "work", + projectId: "proj-1", + teamId: "team-1", + mode: "auto_apply", + status: "active", + }, + }); + } +); + testIntegration( "hack.linear.assignee-mappings.list forwards profile and team filters", { timeout: 20_000 }, @@ -298,18 +488,87 @@ function buildHackStubScript(): string { ' console.log(JSON.stringify({ extensionId: "dance.hack.linear", selectedProfile: "default", tokenResolved: true }))', " process.exit(0)", " }", + ' if (sub === "project-bind") {', + ' const profileIndex = args.indexOf("--profile")', + ' const projectIdIndex = args.indexOf("--project-id")', + ' const projectNameIndex = args.indexOf("--project-name")', + ' const teamIdIndex = args.indexOf("--team-id")', + ' const clear = args.includes("--clear")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', + " const projectId = clear || projectIdIndex === -1 ? null : args[projectIdIndex + 1]", + " const projectName = clear || projectNameIndex === -1 ? null : args[projectNameIndex + 1]", + " const teamId = clear || teamIdIndex === -1 ? null : args[teamIdIndex + 1]", + " console.log(JSON.stringify({ ok: true, cleared: clear ? true : undefined, profileId, projectId, projectName, teamId, additionalProjects: [] }))", + " process.exit(0)", + " }", + ' if (sub === "project-link") {', + ' const profileIndex = args.indexOf("--profile")', + ' const projectIdIndex = args.indexOf("--project-id")', + ' const projectNameIndex = args.indexOf("--project-name")', + ' const teamIdIndex = args.indexOf("--team-id")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', + " const projectId = projectIdIndex === -1 ? null : args[projectIdIndex + 1]", + " const projectName = projectNameIndex === -1 ? null : args[projectNameIndex + 1]", + " const teamId = teamIdIndex === -1 ? null : args[teamIdIndex + 1]", + " console.log(JSON.stringify({ ok: true, profileId, projectId: null, projectName: null, teamId: null, additionalProjects: [{ profileId, projectId, projectName, teamId }], additionalProjectChanged: true }))", + " process.exit(0)", + " }", + ' if (sub === "project-unlink") {', + ' const projectIdIndex = args.indexOf("--project-id")', + " const removedProjectId = projectIdIndex === -1 ? null : args[projectIdIndex + 1]", + ' console.log(JSON.stringify({ ok: true, profileId: "default", projectId: "proj-default", projectName: "Default Project", teamId: "team-default", additionalProjects: [], additionalProjectChanged: true, removedProjectId }))', + " process.exit(0)", + " }", ' if (sub === "deliveries") {', + ' const profileIndex = args.indexOf("--profile")', ' const statusIndex = args.indexOf("--status")', ' const limitIndex = args.indexOf("--limit")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', ' const status = statusIndex === -1 ? "pending" : args[statusIndex + 1]', " const limit = limitIndex === -1 ? null : Number(args[limitIndex + 1])", - ' console.log(JSON.stringify({ status, limit, deliveries: [{ id: "delivery-1", status }] }))', + ' console.log(JSON.stringify({ profileId, status, limit, deliveries: [{ id: "delivery-1", status }] }))', " process.exit(0)", " }", ' if (sub === "apply-delivery") {', + ' const profileIndex = args.indexOf("--profile")', ' const idIndex = args.indexOf("--delivery-id")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', " const deliveryId = idIndex === -1 ? null : args[idIndex + 1]", - ' console.log(JSON.stringify({ deliveryId, status: "applied" }))', + ' console.log(JSON.stringify({ profileId, deliveryId, status: "applied" }))', + " process.exit(0)", + " }", + ' if (sub === "subscriptions") {', + ' const profileIndex = args.indexOf("--profile")', + ' const projectIndex = args.indexOf("--project-id")', + ' const teamIndex = args.indexOf("--team-id")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', + " const projectId = projectIndex === -1 ? null : args[projectIndex + 1]", + " const teamId = teamIndex === -1 ? null : args[teamIndex + 1]", + ' console.log(JSON.stringify({ profileId, subscriptions: [{ id: "sub-1", profileId, projectId, teamId, mode: "auto_apply", status: "active" }] }))', + " process.exit(0)", + " }", + ' if (sub === "set-subscription") {', + ' const profileIndex = args.indexOf("--profile")', + ' const projectIndex = args.indexOf("--project-id")', + ' const teamIndex = args.indexOf("--team-id")', + ' const modeIndex = args.indexOf("--mode")', + ' const statusIndex = args.indexOf("--status")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', + " const projectId = projectIndex === -1 ? null : args[projectIndex + 1]", + " const teamId = teamIndex === -1 ? null : args[teamIndex + 1]", + ' const mode = modeIndex === -1 ? "auto_apply" : args[modeIndex + 1]', + ' const status = statusIndex === -1 ? "active" : args[statusIndex + 1]', + ' console.log(JSON.stringify({ profileId, subscription: { id: "sub-1", profileId, projectId, teamId, mode, status } }))', + " process.exit(0)", + " }", + ' if (sub === "remove-subscription") {', + ' const profileIndex = args.indexOf("--profile")', + ' const projectIndex = args.indexOf("--project-id")', + ' const teamIndex = args.indexOf("--team-id")', + ' const profileId = profileIndex === -1 ? "default" : args[profileIndex + 1]', + " const projectId = projectIndex === -1 ? null : args[projectIndex + 1]", + " const teamId = teamIndex === -1 ? null : args[teamIndex + 1]", + ' console.log(JSON.stringify({ profileId, subscription: { id: "sub-1", profileId, projectId, teamId, mode: "auto_apply", status: "active" } }))', " process.exit(0)", " }", ' if (sub === "assignee-mappings") {', diff --git a/tests/tickets-extension.test.ts b/tests/tickets-extension.test.ts index 8e0a0ee4a..dc336da44 100644 --- a/tests/tickets-extension.test.ts +++ b/tests/tickets-extension.test.ts @@ -122,7 +122,7 @@ testIntegration( ); testIntegration( - "tickets extension: assignee, comment, and conflict resolution commands work end to end", + "tickets extension: assignee, review note, comment, and conflict resolution commands work end to end", { timeout: 60_000 }, async () => { const root = await mkdirTempDir({ prefix: "hack-cli-tickets-sync-e2e-" }); @@ -182,6 +182,22 @@ testIntegration( expect(commentJson.comment.body).toBe("Append only note"); expect(commentJson.comment.source).toBe("hack"); + const reviewed = await runHack({ + cwd: projectDir, + args: [ + "tickets", + "review-note", + ticketId, + "--body", + "Shared review note", + "--json", + ], + }); + const reviewJson = JSON.parse(reviewed.stdout) as { + reviewNote: { body: string }; + }; + expect(reviewJson.reviewNote.body).toBe("Shared review note"); + const store = await createStore({ projectRoot: projectDir }); const conflict = await store.recordSyncConflict({ ticketId, @@ -227,12 +243,16 @@ testIntegration( const showJson = JSON.parse(shown.stdout) as { ticket: { assignee?: string }; comments: Array<{ body: string }>; + reviewNotes: Array<{ body: string }>; conflicts: Array<{ status: string; resolution?: string }>; }; expect(showJson.ticket.assignee).toBe("alice@hack"); expect(showJson.comments.map((comment) => comment.body)).toContain( "Append only note" ); + expect(showJson.reviewNotes.map((reviewNote) => reviewNote.body)).toContain( + "Shared review note" + ); expect(showJson.conflicts[0]?.status).toBe("resolved"); expect(showJson.conflicts[0]?.resolution).toBe("accept_remote"); diff --git a/tests/tickets-store.test.ts b/tests/tickets-store.test.ts index e7606f0a3..0ae0d5377 100644 --- a/tests/tickets-store.test.ts +++ b/tests/tickets-store.test.ts @@ -47,7 +47,7 @@ afterEach(async () => { } }); -test("tickets store materializes assignee, comments, checkpoints, and conflicts", async () => { +test("tickets store materializes assignee, review notes, comments, checkpoints, and conflicts", async () => { const projectRoot = await createTempGitProject({ prefix: "hack-cli-tickets-store-", }); @@ -96,6 +96,17 @@ test("tickets store materializes assignee, comments, checkpoints, and conflicts" throw new Error(secondComment.error); } + const reviewNote = await store.appendReviewNote({ + ticketId, + body: "Investigate assignee before the next pull.", + context: "conflict_review", + actor: "alice@hack", + }); + expect(reviewNote.ok).toBe(true); + if (!reviewNote.ok) { + throw new Error(reviewNote.error); + } + const linkedComment = await store.linkCommentExternalId({ ticketId, commentId: secondComment.comment.commentId, @@ -164,6 +175,14 @@ test("tickets store materializes assignee, comments, checkpoints, and conflicts" externalUrl: "https://linear.app/issue/HACK-1#comment-2", }); + const reviewNotes = snapshot.reviewNotesByTicket.get(ticketId) ?? []; + expect(reviewNotes).toHaveLength(1); + expect(reviewNotes[0]).toMatchObject({ + actor: "alice@hack", + body: "Investigate assignee before the next pull.", + context: "conflict_review", + }); + const checkpoints = snapshot.syncCheckpointsByTicket.get(ticketId) ?? []; expect(checkpoints).toHaveLength(1); expect(checkpoints[0]).toMatchObject({ @@ -193,12 +212,13 @@ test("tickets store materializes assignee, comments, checkpoints, and conflicts" "ticket.updated", "ticket.comment_appended", "ticket.comment_appended", + "ticket.review_note_appended", "ticket.comment_linked", "ticket.sync_checkpoint_recorded", "ticket.sync_conflict_recorded", "ticket.sync_conflict_resolved", ]); -}); +}, 20_000); test("tickets show json includes materialized sync metadata", async () => { const projectRoot = await createTempGitProject({ @@ -233,6 +253,13 @@ test("tickets show json includes materialized sync metadata", async () => { }); expect(comment.ok).toBe(true); + const reviewNote = await store.appendReviewNote({ + ticketId, + body: "Shared review note.", + actor: "alice@hack", + }); + expect(reviewNote.ok).toBe(true); + const checkpoint = await store.recordSyncCheckpoint({ ticketId, provider: "linear", @@ -264,6 +291,7 @@ test("tickets show json includes materialized sync metadata", async () => { const payload = JSON.parse(shown.stdout) as { ticket: { ticketId: string; assignee?: string }; comments: { body: string }[]; + reviewNotes: { body: string }[]; syncCheckpoints: { provider: string; remoteCursor?: string }[]; conflicts: { field: string; status: string }[]; events: { type: string }[]; @@ -273,6 +301,8 @@ test("tickets show json includes materialized sync metadata", async () => { expect(payload.ticket.assignee).toBe("alice@hack"); expect(payload.comments).toHaveLength(1); expect(payload.comments[0]?.body).toBe("Needs review."); + expect(payload.reviewNotes).toHaveLength(1); + expect(payload.reviewNotes[0]?.body).toBe("Shared review note."); expect(payload.syncCheckpoints[0]).toMatchObject({ provider: "linear", remoteCursor: "issue/LIN-321#v3", @@ -284,7 +314,7 @@ test("tickets show json includes materialized sync metadata", async () => { expect( payload.events.some((event) => event.type === "ticket.comment_appended") ).toBe(true); -}); +}, 20_000); async function createStore(opts: { readonly projectRoot: string }) { const configResult = await readControlPlaneConfig({ From 29477c996f7184f2715384b4032d3a7a1608bcc6 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 5 Mar 2026 22:39:41 -0500 Subject: [PATCH 06/54] Add Linear autosync executor and desktop sync controls --- .../DashboardFeature/DashboardModel.swift | 22 + .../DashboardFeature/ProjectDetailView.swift | 55 ++ .../DashboardFeature/TicketsView.swift | 144 +++- .../HackCLIService/HackCLIClient.swift | 24 + .../Sources/HackDesktopModels/Models.swift | 43 ++ .../src/modules/linear-autosync/plugin.ts | 11 +- .../src/modules/linear-autosync/service.ts | 296 ++++++-- .../src/modules/linear-sync-store/plugin.ts | 14 +- services/auth-broker/tests/index.test.ts | 88 +++ .../extensions/linear/commands.ts | 668 +++++++++++++++++- src/mcp/server.ts | 54 ++ tests/linear-commands.test.ts | 178 +++++ tests/mcp.test.ts | 50 ++ 13 files changed, 1568 insertions(+), 79 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 86f5a0b05..c18379590 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -931,6 +931,28 @@ public final class DashboardModel { } } + public func runLinearAutosync( + for project: ProjectSummary, + profileId: String? = nil, + projectId: String? = nil, + teamId: String? = nil, + limit: Int? = nil + ) async -> LinearAutosyncRunResponse? { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return nil + } + return await runActionResult(message: "Running Linear autosync…") { + try await self.client.runLinearAutosync( + path: path, + profileId: profileId, + projectId: projectId, + teamId: teamId, + limit: limit + ) + } + } + public func syncLinearIssue( for project: ProjectSummary, from direction: String, diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 27af573d5..9b748b70f 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -986,6 +986,16 @@ struct ProjectDetailView: View { } .adaptiveToolbarButton() .disabled(!canSyncToLinear) + + if hasAnyLinearAutosyncRoutes { + Button { + Task { await runBoundLinearAutosync() } + } label: { + Label("Run all autosync routes", systemImage: "bolt.badge.clock") + } + .adaptiveToolbarButton() + .disabled(linearResolvedStatus?.tokenResolved != true) + } } Spacer() @@ -1561,6 +1571,13 @@ struct ProjectDetailView: View { .disabled(isLinearAutosyncBusy(for: normalizedTarget)) Menu { + if isLinearAutosyncEnabled(for: normalizedTarget) { + Button("Run this autosync route") { + Task { await runSpecificLinearAutosync(normalizedTarget) } + } + .disabled(linearResolvedStatus?.tokenResolved != true) + Divider() + } Button("Pull now") { Task { await syncSpecificLinearProject(normalizedTarget, from: "linear") } } @@ -1696,6 +1713,10 @@ struct ProjectDetailView: View { linearAutosyncRouteKeys.contains(linearRouteKey(for: target)) } + private var hasAnyLinearAutosyncRoutes: Bool { + !linearAutosyncRouteKeys.isEmpty + } + private func isLinearAutosyncBusy(for target: LinearProjectBindingTarget) -> Bool { togglingLinearAutosyncRouteKeys.contains(linearRouteKey(for: target)) } @@ -2550,6 +2571,19 @@ struct ProjectDetailView: View { githubProfileMessage = "" } + private func runBoundLinearAutosync() async { + let result = await model.runLinearAutosync(for: project) + guard let result, result.ok else { + linearProjectMessage = model.errorMessage ?? "Linear autosync failed." + return + } + + linearProjectMessage = + "Processed \(result.processedDeliveries) pending delivery\(result.processedDeliveries == 1 ? "" : "ies") across \(result.subscribedRoutes) subscribed route\(result.subscribedRoutes == 1 ? "" : "s") • applied \(result.appliedDeliveries) • failed \(result.failedDeliveries) • created \(result.created) • updated \(result.updated)." + executionTargetMessage = "" + githubProfileMessage = "" + } + private func syncSpecificLinearProject( _ target: LinearProjectBindingTarget, from direction: String @@ -2581,6 +2615,27 @@ struct ProjectDetailView: View { } } + private func runSpecificLinearAutosync( + _ target: LinearProjectBindingTarget + ) async { + let normalizedTarget = normalizedLinearRouteTarget(target) + let result = await model.runLinearAutosync( + for: project, + profileId: normalizedTarget.profileId, + projectId: normalizedTarget.projectId, + teamId: normalizedTarget.teamId, + limit: nil + ) + guard let result, result.ok else { + linearProjectMessage = model.errorMessage ?? "Linear autosync failed." + return + } + + let routeLabel = linearProjectBindingTargetLabel(normalizedTarget) + linearProjectMessage = + "Processed \(result.processedDeliveries) pending delivery\(result.processedDeliveries == 1 ? "" : "ies") for \(routeLabel) • applied \(result.appliedDeliveries) • failed \(result.failedDeliveries) • created \(result.created) • updated \(result.updated)." + } + private struct LifecycleSummaryCounts { let startupHookCount: Int let shutdownHookCount: Int diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index 95fb53ea9..66e3606df 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -32,6 +32,7 @@ struct TicketsView: View { @State private var linearRouteProjectName = "" @State private var linearRouteTeamId = "" @State private var linearAdditionalProjects: [LinearProjectBindingTarget] = [] + @State private var linearAutosyncRouteKeys: Set = [] @State private var linearRouteStatus: LinearStatusResponse? = nil @State private var pendingBulkSyncAction: TicketBulkSyncAction? = nil @State private var activeSyncAction: TicketSyncAction? = nil @@ -154,6 +155,12 @@ struct TicketsView: View { requestBulkSyncConfirmation(.pushHack) } .disabled(!canSyncProjectToLinear || isAnySyncInFlight) + if hasAnyLinearAutosyncRoutes { + Button("Run project autosync routes") { + Task { await runProjectLinearAutosync() } + } + .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) + } if let selectedTicket { Divider() Button("Push selected ticket to Linear") { @@ -359,6 +366,46 @@ struct TicketsView: View { return !linearAdditionalProjects.isEmpty } + private var hasAnyLinearAutosyncRoutes: Bool { + !linearAutosyncRouteKeys.isEmpty + } + + private var allLinearRouteTargets: [LinearProjectBindingTarget] { + var targets: [LinearProjectBindingTarget] = [] + if !linearRouteProjectId.isEmpty { + targets.append( + LinearProjectBindingTarget( + profileId: linearRouteProfile.isEmpty ? nil : linearRouteProfile, + projectId: linearRouteProjectId, + projectName: linearRouteProjectName.isEmpty ? nil : linearRouteProjectName, + teamId: linearRouteTeamId.isEmpty ? nil : linearRouteTeamId + ) + ) + } + targets.append(contentsOf: linearAdditionalProjects.map(normalizedLinearRouteTarget(_:))) + return targets + } + + private func normalizedLinearRouteTarget(_ target: LinearProjectBindingTarget) -> LinearProjectBindingTarget { + if let profileId = target.profileId?.trimmingCharacters(in: .whitespacesAndNewlines), + !profileId.isEmpty { + return target + } + return LinearProjectBindingTarget( + profileId: linearRouteProfile.isEmpty ? nil : linearRouteProfile, + projectId: target.projectId, + projectName: target.projectName, + teamId: target.teamId + ) + } + + private func linearRouteKey(for target: LinearProjectBindingTarget) -> String { + let normalized = normalizedLinearRouteTarget(target) + let profileId = normalized.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "*" + let teamId = normalized.teamId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "*" + return [profileId, normalized.projectId, teamId].joined(separator: "::") + } + private var loadNoticeTone: StatusTone { guard let loadNotice else { return .neutral } let normalized = loadNotice.lowercased() @@ -1588,7 +1635,12 @@ struct TicketsView: View { } private func ticketDetailFooter(_ detail: TicketDetailResponse) -> some View { - VStack(alignment: .leading, spacing: 12) { + let prefersPullPrimary = + detail.ticket.linearSyncUXState.authority == .linear && + detail.linearSyncReviewState.needsReview == false && + detail.ticket.externalKey?.isEmpty == false + + return VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { if let nextReviewQueueEntry { Button { @@ -1600,21 +1652,39 @@ struct TicketsView: View { .adaptiveToolbarButton() } - Button { - Task { await syncSelectedTicketToLinear(detail.ticket) } - } label: { - Label("Push Hack changes", systemImage: "arrow.up.right") - } - .adaptiveToolbarButtonProminent() - .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) + if prefersPullPrimary { + Button { + Task { await syncSelectedTicketToLinear(detail.ticket) } + } label: { + Label("Push Hack changes", systemImage: "arrow.up.right") + } + .adaptiveToolbarButton() + .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) - Button { - Task { await syncSelectedTicketFromLinear(detail.ticket) } - } label: { - Label("Pull Linear changes", systemImage: "arrow.down.left") + Button { + Task { await syncSelectedTicketFromLinear(detail.ticket) } + } label: { + Label("Pull Linear changes", systemImage: "arrow.down.left") + } + .adaptiveToolbarButtonProminent() + .disabled(detail.ticket.externalKey?.isEmpty != false || isAnySyncInFlight) + } else { + Button { + Task { await syncSelectedTicketToLinear(detail.ticket) } + } label: { + Label("Push Hack changes", systemImage: "arrow.up.right") + } + .adaptiveToolbarButtonProminent() + .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) + + Button { + Task { await syncSelectedTicketFromLinear(detail.ticket) } + } label: { + Label("Pull Linear changes", systemImage: "arrow.down.left") + } + .adaptiveToolbarButton() + .disabled(detail.ticket.externalKey?.isEmpty != false || isAnySyncInFlight) } - .adaptiveToolbarButton() - .disabled(detail.ticket.externalKey?.isEmpty != false || isAnySyncInFlight) if let externalURL = detail.ticket.externalUrl, let url = URL(string: externalURL) { @@ -1953,6 +2023,35 @@ struct TicketsView: View { linearRouteStatus = await model.inspectLinearStatus( profileId: linearRouteProfile.isEmpty ? nil : linearRouteProfile ) + linearAutosyncRouteKeys = await loadLinearAutosyncRouteKeys(targets: allLinearRouteTargets) + } + + private func loadLinearAutosyncRouteKeys( + targets: [LinearProjectBindingTarget] + ) async -> Set { + var nextKeys: Set = [] + for target in targets { + let normalizedTarget = normalizedLinearRouteTarget(target) + guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { + continue + } + guard let subscriptions = await model.listLinearAutosyncSubscriptions( + profileId: profileId, + projectId: normalizedTarget.projectId, + teamId: normalizedTarget.teamId + ) else { + continue + } + if subscriptions.subscriptions.contains(where: { + $0.projectId == normalizedTarget.projectId && + $0.teamId == normalizedTarget.teamId && + $0.mode == "auto_apply" && + $0.status == "active" + }) { + nextKeys.insert(linearRouteKey(for: normalizedTarget)) + } + } + return nextKeys } private func syncProjectFromLinear() async { @@ -1977,6 +2076,20 @@ struct TicketsView: View { } } + private func runProjectLinearAutosync() async { + await runSyncAction(.runProjectAutosync) { + loadNotice = nil + let result = await model.runLinearAutosync(for: project) + guard let result, result.ok else { + loadNotice = model.errorMessage ?? "Linear autosync failed." + return + } + loadNotice = + "Processed \(result.processedDeliveries) pending delivery\(result.processedDeliveries == 1 ? "" : "ies") across \(result.subscribedRoutes) subscribed route\(result.subscribedRoutes == 1 ? "" : "s") • applied \(result.appliedDeliveries) • failed \(result.failedDeliveries)." + await refreshTickets() + } + } + private func syncProjectToLinear() async { await runSyncAction(.pushProjectToLinear) { loadNotice = nil @@ -2834,6 +2947,7 @@ private enum TicketBulkSyncAction: String, Identifiable { private enum TicketSyncAction: Equatable { case pullProjectFromLinear + case runProjectAutosync case pushProjectToLinear case pushTicketToLinear(String) case pullTicketFromLinear(String) @@ -2842,6 +2956,8 @@ private enum TicketSyncAction: Equatable { switch self { case .pullProjectFromLinear: return "Pulling Linear project…" + case .runProjectAutosync: + return "Running Linear autosync…" case .pushProjectToLinear: return "Pushing Hack tickets…" case let .pushTicketToLinear(ticketId): diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index af833c767..8312648e4 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -709,6 +709,30 @@ public actor HackCLIClient { return try decodeJsonOrThrow(LinearProjectSyncResponse.self, result: result) } + public func runLinearAutosync( + path: String, + profileId: String? = nil, + projectId: String? = nil, + teamId: String? = nil, + limit: Int? = nil + ) async throws -> LinearAutosyncRunResponse { + var args = ["x", "linear", "run-autosync", "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--project-id", projectId]) + } + if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--team-id", teamId]) + } + if let limit { + args.append(contentsOf: ["--limit", String(limit)]) + } + let result = try await run(args, allowNonZeroExit: true, cwd: path) + return try decodeJsonOrThrow(LinearAutosyncRunResponse.self, result: result) + } + public func syncLinearIssue( path: String, from direction: String, diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 22482524a..fe76850ca 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -1659,6 +1659,49 @@ public struct LinearProjectSyncResponse: Decodable, Hashable { } } +public struct LinearAutosyncRunResponse: Decodable, Hashable { + public let ok: Bool + public let subscribedRoutes: Int + public let processedDeliveries: Int + public let appliedDeliveries: Int + public let skippedDeliveries: Int + public let failedDeliveries: Int + public let created: Int + public let updated: Int + public let commentsPulled: Int + public let conflictsRecorded: Int + public let checkpointsRecorded: Int + public let projectIds: [String]? + + public init( + ok: Bool, + subscribedRoutes: Int, + processedDeliveries: Int, + appliedDeliveries: Int, + skippedDeliveries: Int, + failedDeliveries: Int, + created: Int, + updated: Int, + commentsPulled: Int, + conflictsRecorded: Int, + checkpointsRecorded: Int, + projectIds: [String]? = nil + ) { + self.ok = ok + self.subscribedRoutes = subscribedRoutes + self.processedDeliveries = processedDeliveries + self.appliedDeliveries = appliedDeliveries + self.skippedDeliveries = skippedDeliveries + self.failedDeliveries = failedDeliveries + self.created = created + self.updated = updated + self.commentsPulled = commentsPulled + self.conflictsRecorded = conflictsRecorded + self.checkpointsRecorded = checkpointsRecorded + self.projectIds = projectIds + } +} + public struct RailwayBootstrapRequest: Hashable { public let railwayProject: String? public let railwayService: String? diff --git a/services/auth-broker/src/modules/linear-autosync/plugin.ts b/services/auth-broker/src/modules/linear-autosync/plugin.ts index 3c72db618..db848d947 100644 --- a/services/auth-broker/src/modules/linear-autosync/plugin.ts +++ b/services/auth-broker/src/modules/linear-autosync/plugin.ts @@ -61,10 +61,14 @@ export function createLinearAutosyncPlugin({ error: "better_auth_session_required", } as const; } + const profileId = + normalizeOptionalQueryValue(query.profileId) ?? + session.session?.managementTokenProfileId ?? + null; if ( !hasBetterAuthProfileAccess({ session: session.session, - profileId: normalizeOptionalQueryValue(query.profileId), + profileId, }) ) { set.status = 403; @@ -75,7 +79,7 @@ export function createLinearAutosyncPlugin({ } const subscriptions = await autosyncStore.listSubscriptions({ - profileId: normalizeOptionalQueryValue(query.profileId), + profileId, projectId: normalizeOptionalQueryValue(query.projectId), teamId: normalizeOptionalQueryValue(query.teamId), }); @@ -206,6 +210,9 @@ export function createLinearAutosyncPlugin({ profileId: body.profileId, projectId: normalizeOptionalQueryValue(body.projectId), teamId: normalizeOptionalQueryValue(body.teamId), + betterAuthUserId: existing.betterAuthUserId, + betterAuthOrganizationId: existing.betterAuthOrganizationId, + betterAuthTeamId: existing.betterAuthTeamId, }); if (!removed) { set.status = 404; diff --git a/services/auth-broker/src/modules/linear-autosync/service.ts b/services/auth-broker/src/modules/linear-autosync/service.ts index 46f34cefe..2e71cd4b2 100644 --- a/services/auth-broker/src/modules/linear-autosync/service.ts +++ b/services/auth-broker/src/modules/linear-autosync/service.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { and, desc, eq, type SQL, sql } from "drizzle-orm"; +import { type AnyColumn, and, desc, eq, type SQL, sql } from "drizzle-orm"; import { linearSyncSubscriptions } from "../../db/schema.ts"; import { createDbClient } from "../../db.ts"; @@ -47,6 +47,9 @@ export type RemoveLinearAutosyncSubscriptionInput = { readonly profileId: string; readonly projectId?: string | null; readonly teamId?: string | null; + readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; }; export type MatchLinearAutosyncSubscriptionInput = { @@ -58,6 +61,15 @@ export type MatchLinearAutosyncSubscriptionInput = { readonly betterAuthTeamId?: string | null; }; +type SubscriptionScopeInput = { + readonly profileId: string; + readonly projectId?: string | null; + readonly teamId?: string | null; + readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; +}; + export type LinearAutosyncStore = { readonly upsertSubscription: ( input: UpsertLinearAutosyncSubscriptionInput @@ -79,8 +91,8 @@ export class InMemoryLinearAutosyncStore implements LinearAutosyncStore { upsertSubscription( input: UpsertLinearAutosyncSubscriptionInput ): Promise { + const existing = this.findExistingSubscription({ input }); const subscriptionKey = buildSubscriptionKey(input); - const existing = this.recordsByKey.get(subscriptionKey); const now = new Date().toISOString(); const record: LinearAutosyncSubscription = { id: existing?.id ?? randomUUID(), @@ -97,6 +109,8 @@ export class InMemoryLinearAutosyncStore implements LinearAutosyncStore { createdAt: existing?.createdAt ?? now, updatedAt: now, }; + + this.removeMatchingSubscriptions({ input }); this.recordsByKey.set(subscriptionKey, record); return Promise.resolve(record); } @@ -105,7 +119,9 @@ export class InMemoryLinearAutosyncStore implements LinearAutosyncStore { input: ListLinearAutosyncSubscriptionsInput = {} ): Promise { return Promise.resolve( - [...this.recordsByKey.values()] + dedupeSubscriptions({ + records: [...this.recordsByKey.values()], + }) .filter((record) => matchesSubscriptionFilter({ record, filter: input }) ) @@ -116,11 +132,11 @@ export class InMemoryLinearAutosyncStore implements LinearAutosyncStore { removeSubscription( input: RemoveLinearAutosyncSubscriptionInput ): Promise { - const subscriptionKey = buildSubscriptionKey(input); - const existing = this.recordsByKey.get(subscriptionKey) ?? null; - if (existing) { - this.recordsByKey.delete(subscriptionKey); + const existing = this.findExistingSubscription({ input }); + if (!existing) { + return Promise.resolve(null); } + this.removeMatchingSubscriptions({ input }); return Promise.resolve(existing); } @@ -147,6 +163,36 @@ export class InMemoryLinearAutosyncStore implements LinearAutosyncStore { return matches.length === 1 ? (matches[0] ?? null) : null; } + + private findExistingSubscription(input: { + readonly input: SubscriptionScopeInput; + }): LinearAutosyncSubscription | null { + return ( + dedupeSubscriptions({ + records: [...this.recordsByKey.values()], + }).find((record) => + matchesSubscriptionScope({ + record, + input: input.input, + }) + ) ?? null + ); + } + + private removeMatchingSubscriptions(input: { + readonly input: SubscriptionScopeInput; + }): void { + for (const [subscriptionKey, record] of this.recordsByKey.entries()) { + if ( + matchesSubscriptionScope({ + record, + input: input.input, + }) + ) { + this.recordsByKey.delete(subscriptionKey); + } + } + } } export function createLinearAutosyncStoreFromDb(input: { @@ -199,6 +245,13 @@ export function createLinearAutosyncStoreFromDb(input: { if (!row) { throw new Error("Failed to persist Linear autosync subscription."); } + + await deleteSubscriptionAliases({ + db, + keepId: row.id, + input: subscription, + }); + return toAutosyncSubscription({ row }); }, @@ -216,29 +269,34 @@ export function createLinearAutosyncStoreFromDb(input: { .from(linearSyncSubscriptions) .where(and(...filters)) .orderBy(desc(linearSyncSubscriptions.updatedAt)); - return rows - .map((row) => toAutosyncSubscription({ row })) - .filter((record) => - matchesSubscriptionFilter({ record, filter: input }) - ); + return dedupeSubscriptions({ + records: rows.map((row) => toAutosyncSubscription({ row })), + }).filter((record) => + matchesSubscriptionFilter({ record, filter: input }) + ); }, removeSubscription: async (inputToRemove) => { await ensureTable(); - const subscriptionKey = buildSubscriptionKey(inputToRemove); - const existing = await db - .select() - .from(linearSyncSubscriptions) - .where(eq(linearSyncSubscriptions.subscriptionKey, subscriptionKey)) - .limit(1); - const row = existing[0]; - if (!row) { + const rows = await listMatchingSubscriptionRows({ + db, + input: inputToRemove, + }); + const existing = + dedupeSubscriptions({ + records: rows.map((row) => toAutosyncSubscription({ row })), + })[0] ?? null; + if (!existing) { return null; } - await db - .delete(linearSyncSubscriptions) - .where(eq(linearSyncSubscriptions.subscriptionKey, subscriptionKey)); - return toAutosyncSubscription({ row }); + + await deleteSubscriptionAliases({ + db, + keepId: null, + input: inputToRemove, + }); + + return existing; }, findMatchingSubscription: async (inputToMatch) => { @@ -246,31 +304,31 @@ export function createLinearAutosyncStoreFromDb(input: { const profileId = normalizeRequiredText(inputToMatch.profileId); const projectId = normalizeText(inputToMatch.projectId); const teamId = normalizeText(inputToMatch.teamId); - const matches = ( - await db - .select() - .from(linearSyncSubscriptions) - .where( - and( - eq(linearSyncSubscriptions.profileId, profileId), - ...(projectId - ? [eq(linearSyncSubscriptions.projectId, projectId)] - : []), - ...(teamId ? [eq(linearSyncSubscriptions.teamId, teamId)] : []), - eq(linearSyncSubscriptions.status, "active"), - eq(linearSyncSubscriptions.mode, "auto_apply") + const matches = dedupeSubscriptions({ + records: ( + await db + .select() + .from(linearSyncSubscriptions) + .where( + and( + eq(linearSyncSubscriptions.profileId, profileId), + ...(projectId + ? [eq(linearSyncSubscriptions.projectId, projectId)] + : []), + ...(teamId ? [eq(linearSyncSubscriptions.teamId, teamId)] : []), + eq(linearSyncSubscriptions.status, "active"), + eq(linearSyncSubscriptions.mode, "auto_apply") + ) ) - ) - ) - .map((row) => toAutosyncSubscription({ row })) - .filter((record) => - matchesOwnership({ - record, - betterAuthUserId: inputToMatch.betterAuthUserId, - betterAuthOrganizationId: inputToMatch.betterAuthOrganizationId, - betterAuthTeamId: inputToMatch.betterAuthTeamId, - }) - ); + ).map((row) => toAutosyncSubscription({ row })), + }).filter((record) => + matchesOwnership({ + record, + betterAuthUserId: inputToMatch.betterAuthUserId, + betterAuthOrganizationId: inputToMatch.betterAuthOrganizationId, + betterAuthTeamId: inputToMatch.betterAuthTeamId, + }) + ); return matches.length === 1 ? (matches[0] ?? null) : null; }, @@ -311,18 +369,41 @@ async function ensureSubscriptionsTable(input: { `); } -function buildSubscriptionKey(input: { - readonly profileId: string; - readonly projectId?: string | null; - readonly teamId?: string | null; -}): string { +function buildSubscriptionKey(input: SubscriptionScopeInput): string { return [ + buildSubscriptionOwnerKey({ + betterAuthUserId: input.betterAuthUserId, + betterAuthOrganizationId: input.betterAuthOrganizationId, + betterAuthTeamId: input.betterAuthTeamId, + }), normalizeRequiredText(input.profileId), normalizeText(input.projectId) ?? "*", normalizeText(input.teamId) ?? "*", ].join("::"); } +function buildSubscriptionOwnerKey(input: { + readonly betterAuthUserId?: string | null; + readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; +}): string { + const betterAuthTeamId = normalizeText(input.betterAuthTeamId); + if (betterAuthTeamId) { + return `team:${betterAuthTeamId}`; + } + const betterAuthOrganizationId = normalizeText( + input.betterAuthOrganizationId + ); + if (betterAuthOrganizationId) { + return `org:${betterAuthOrganizationId}`; + } + const betterAuthUserId = normalizeText(input.betterAuthUserId); + if (betterAuthUserId) { + return `user:${betterAuthUserId}`; + } + return "legacy"; +} + function buildListFilters(input: { readonly input: ListLinearAutosyncSubscriptionsInput; }): SQL[] { @@ -390,6 +471,115 @@ function matchesOwnership(input: { return false; } +function matchesSubscriptionScope(input: { + readonly record: LinearAutosyncSubscription; + readonly input: SubscriptionScopeInput; +}): boolean { + return ( + input.record.profileId === normalizeRequiredText(input.input.profileId) && + input.record.projectId === normalizeText(input.input.projectId) && + input.record.teamId === normalizeText(input.input.teamId) && + input.record.betterAuthUserId === + normalizeText(input.input.betterAuthUserId) && + input.record.betterAuthOrganizationId === + normalizeText(input.input.betterAuthOrganizationId) && + input.record.betterAuthTeamId === + normalizeText(input.input.betterAuthTeamId) + ); +} + +function dedupeSubscriptions(input: { + readonly records: readonly LinearAutosyncSubscription[]; +}): readonly LinearAutosyncSubscription[] { + const recordsByScope = new Map(); + for (const record of input.records) { + const scopeKey = buildSubscriptionScopeFingerprint({ record }); + const existing = recordsByScope.get(scopeKey); + if (!existing || existing.updatedAt.localeCompare(record.updatedAt) < 0) { + recordsByScope.set(scopeKey, record); + } + } + return [...recordsByScope.values()]; +} + +function buildSubscriptionScopeFingerprint(input: { + readonly record: LinearAutosyncSubscription; +}): string { + return [ + buildSubscriptionOwnerKey({ + betterAuthUserId: input.record.betterAuthUserId, + betterAuthOrganizationId: input.record.betterAuthOrganizationId, + betterAuthTeamId: input.record.betterAuthTeamId, + }), + input.record.profileId, + input.record.projectId ?? "*", + input.record.teamId ?? "*", + ].join("::"); +} + +async function listMatchingSubscriptionRows(input: { + readonly db: ReturnType; + readonly input: SubscriptionScopeInput; +}): Promise { + return await input.db + .select() + .from(linearSyncSubscriptions) + .where( + and( + eq( + linearSyncSubscriptions.profileId, + normalizeRequiredText(input.input.profileId) + ), + ...buildNullableMatchFilters({ + column: linearSyncSubscriptions.projectId, + value: normalizeText(input.input.projectId), + }), + ...buildNullableMatchFilters({ + column: linearSyncSubscriptions.teamId, + value: normalizeText(input.input.teamId), + }), + ...buildNullableMatchFilters({ + column: linearSyncSubscriptions.betterAuthUserId, + value: normalizeText(input.input.betterAuthUserId), + }), + ...buildNullableMatchFilters({ + column: linearSyncSubscriptions.betterAuthOrganizationId, + value: normalizeText(input.input.betterAuthOrganizationId), + }), + ...buildNullableMatchFilters({ + column: linearSyncSubscriptions.betterAuthTeamId, + value: normalizeText(input.input.betterAuthTeamId), + }) + ) + ) + .orderBy(desc(linearSyncSubscriptions.updatedAt)); +} + +async function deleteSubscriptionAliases(input: { + readonly db: ReturnType; + readonly keepId: string | null; + readonly input: SubscriptionScopeInput; +}): Promise { + const rows = await listMatchingSubscriptionRows(input); + for (const row of rows) { + if (input.keepId && row.id === input.keepId) { + continue; + } + await input.db + .delete(linearSyncSubscriptions) + .where(eq(linearSyncSubscriptions.id, row.id)); + } +} + +function buildNullableMatchFilters(input: { + readonly column: AnyColumn; + readonly value: string | null; +}): SQL[] { + return input.value === null + ? [sql`${input.column} IS NULL`] + : [eq(input.column, input.value)]; +} + function toAutosyncSubscription(input: { readonly row: typeof linearSyncSubscriptions.$inferSelect; }): LinearAutosyncSubscription { diff --git a/services/auth-broker/src/modules/linear-sync-store/plugin.ts b/services/auth-broker/src/modules/linear-sync-store/plugin.ts index d7bf09d67..16635403f 100644 --- a/services/auth-broker/src/modules/linear-sync-store/plugin.ts +++ b/services/auth-broker/src/modules/linear-sync-store/plugin.ts @@ -29,6 +29,12 @@ const applyDeliveryParamsSchema = t.Object({ deliveryId: t.String(), }); +const applyDeliveryBodySchema = t.Optional( + t.Object({ + claimedBy: t.Optional(t.String()), + }) +); + /** * Routes for pending Linear webhook deliveries consumed by local clients. */ @@ -102,7 +108,7 @@ export function createLinearSyncStorePlugin({ ) .post( "/v1/auth/linear/deliveries/:deliveryId/apply", - async ({ params, request, set }) => { + async ({ body, params, request, set }) => { const session = await resolveBetterAuthSession({ runtime: betterAuthRuntime, request, @@ -136,6 +142,11 @@ export function createLinearSyncStorePlugin({ } const delivery = await syncStore.markWebhookDeliveryApplied({ deliveryId: params.deliveryId, + claimedBy: + typeof body?.claimedBy === "string" && + body.claimedBy.trim().length > 0 + ? body.claimedBy.trim() + : null, }); if (!delivery) { set.status = 404; @@ -150,6 +161,7 @@ export function createLinearSyncStorePlugin({ } as const; }, { + body: applyDeliveryBodySchema, params: applyDeliveryParamsSchema, } ); diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 8ff6b1d89..c68006433 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -910,6 +910,36 @@ describe("auth broker github flow routes", () => { expect(payload.error).toBe("better_auth_session_required"); }); + test("linear autosync subscriptions are owner-scoped even when route keys match", async () => { + const autosyncStore = new InMemoryLinearAutosyncStore(); + + await autosyncStore.upsertSubscription({ + profileId: "default", + projectId: "project-a", + teamId: "linear-team-a", + mode: "manual", + betterAuthUserId: "user-a", + }); + await autosyncStore.upsertSubscription({ + profileId: "default", + projectId: "project-a", + teamId: "linear-team-a", + mode: "manual", + betterAuthUserId: "user-b", + }); + + const subscriptions = await autosyncStore.listSubscriptions({ + profileId: "default", + projectId: "project-a", + teamId: "linear-team-a", + }); + + expect(subscriptions).toHaveLength(2); + expect( + subscriptions.map((subscription) => subscription.betterAuthUserId).sort() + ).toEqual(["user-a", "user-b"]); + }); + test("linear autosync subscriptions can be created, listed, and removed for the active Better Auth team", async () => { const autosyncStore: LinearAutosyncStore = new InMemoryLinearAutosyncStore(); @@ -1026,6 +1056,64 @@ describe("auth broker github flow routes", () => { expect(removed.subscription.profileId).toBe("default"); }); + test("profile-scoped management tokens default autosync listing to their own profile", async () => { + await withManagementTokenSecret("test-better-auth-secret", async () => { + const autosyncStore = new InMemoryLinearAutosyncStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + linearAutosyncStore: autosyncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + + await autosyncStore.upsertSubscription({ + profileId: "work", + projectId: "project-a", + teamId: "team-a", + mode: "auto_apply", + status: "active", + betterAuthUserId: "user-a", + }); + await autosyncStore.upsertSubscription({ + profileId: "other-profile", + projectId: "project-b", + teamId: "team-b", + mode: "auto_apply", + status: "active", + betterAuthUserId: "user-a", + }); + + const scopedToken = issueBrokerManagementToken({ + userId: "user-a", + profileId: "work", + }); + expect(scopedToken).not.toBeNull(); + if (!scopedToken) { + return; + } + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/subscriptions", { + headers: { + authorization: `Bearer ${scopedToken.token}`, + }, + }) + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly subscriptions: ReadonlyArray<{ + readonly profileId: string; + }>; + }; + expect(payload.ok).toBe(true); + expect( + payload.subscriptions.map((subscription) => subscription.profileId) + ).toEqual(["work"]); + }); + }); + test("linear webhook keeps matching autosync deliveries pending until sync work runs", async () => { const secret = "linear-hook-secret"; const syncStore = new InMemoryLinearSyncStore(); diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index 03823795f..f0bdecdee 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { hostname } from "node:os"; import { secrets } from "bun"; @@ -1339,6 +1340,109 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 0; }, }, + { + name: "run-autosync", + summary: + "Consume subscribed pending Linear deliveries for the current project and apply them after local sync", + scope: "project", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + if (!ctx.project) { + ctx.logger.error({ message: "No project found. Run inside a repo." }); + return 1; + } + + const parsed = parseRunAutosyncArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const result = await runProjectLinearAutosync({ + binding: resolveProjectLinearBinding({ + controlPlaneConfig: ctx.controlPlaneConfig, + }), + profileId: parsed.value.profileId, + projectId: parsed.value.projectId, + teamId: parsed.value.teamId, + limit: parsed.value.limit, + syncToggles: resolveSyncToggles({ + controlPlaneConfig: ctx.controlPlaneConfig, + labelsOverride: parsed.value.syncLabels, + }), + deps: { + createRuntime: async ({ profileId }) => + await createSyncRuntime({ + ctx, + profileId, + }), + listSubscriptions: async ({ profileId, projectId, teamId }) => + await listLinearAutosyncSubscriptions({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + projectId, + teamId, + }), + listDeliveries: async ({ + profileId, + status, + projectId, + teamId, + limit, + }) => + await listLinearDeliveries({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + status, + projectId, + teamId, + limit, + }), + syncIssue: async ({ runtime, delivery, syncToggles }) => + await syncLinearDeliveryToTicket({ + runtime: runtime as LinearToHackRuntime, + delivery, + syncToggles, + }), + syncProject: async ({ runtime, projectIds, limit, syncToggles }) => + await syncProjectFromLinearProjectsToTickets({ + runtime: runtime as SyncRuntime, + projectIds, + limit, + syncToggles, + }), + applyDelivery: async ({ profileId, deliveryId, claimedBy }) => + await applyLinearDelivery({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId, + deliveryId, + claimedBy, + }), + claimedBy: buildLinearAutosyncClaimedBy(), + }, + }); + if (!result.ok) { + ctx.logger.error({ message: result.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear autosync run", + entries: [ + ["subscribed_routes", String(result.subscribedRoutes)], + ["processed_deliveries", String(result.processedDeliveries)], + ["applied_deliveries", String(result.appliedDeliveries)], + ["failed_deliveries", String(result.failedDeliveries)], + ["updated", String(result.updated)], + ], + }); + } + return 0; + }, + }, { name: "deliveries", summary: @@ -2171,6 +2275,15 @@ type DeliveriesArgs = { json: boolean; }; +type RunAutosyncArgs = { + profileId?: string; + projectId?: string; + teamId?: string; + limit?: number; + syncLabels?: boolean; + json: boolean; +}; + type ApplyDeliveryArgs = { profileId?: string; deliveryId: string; @@ -2253,7 +2366,7 @@ type LinearToHackRuntime = { >; readonly linear: Pick< LinearSyncClient, - "getIssueByIdentifier" | "listIssueComments" + "getIssueById" | "getIssueByIdentifier" | "listIssueComments" >; readonly profileId: string; }; @@ -2562,6 +2675,12 @@ type SyncTicketToLinearSuccess = { type LinearDeliverySummary = { readonly id: string; readonly status: string; + readonly profileId?: string; + readonly projectId?: string; + readonly teamId?: string; + readonly issueId?: string; + readonly issueIdentifier?: string; + readonly claimedBy?: string; readonly eventType?: string; readonly action?: string; readonly receivedAt?: string; @@ -3105,6 +3224,35 @@ type BrokerApplyDeliveryPayload = { readonly status: string; }; +type LinearAutosyncRunDeliveryOutcome = { + readonly deliveryId: string; + readonly profileId: string; + readonly projectId?: string; + readonly teamId?: string; + readonly issueId?: string; + readonly issueIdentifier?: string; + readonly mode: "issue" | "project"; + readonly status: "applied" | "skipped" | "failed"; + readonly ticketId?: string; + readonly reason?: string; +}; + +type LinearAutosyncRunSuccess = { + readonly ok: true; + readonly subscribedRoutes: number; + readonly processedDeliveries: number; + readonly appliedDeliveries: number; + readonly skippedDeliveries: number; + readonly failedDeliveries: number; + readonly created: number; + readonly updated: number; + readonly commentsPulled: number; + readonly conflictsRecorded: number; + readonly checkpointsRecorded: number; + readonly projectIds: readonly string[]; + readonly deliveries: readonly LinearAutosyncRunDeliveryOutcome[]; +}; + type LinearAutosyncSubscriptionSummary = { readonly id: string; readonly profileId: string; @@ -3125,23 +3273,108 @@ type BrokerAutosyncSubscriptionMutationPayload = { readonly subscription: LinearAutosyncSubscriptionSummary; }; -async function syncIssueFromLinearToTicket(input: { +type ProjectLinearAutosyncDeps = { + readonly createRuntime: (input: { + readonly profileId?: string; + }) => Promise< + | { readonly ok: true; readonly value: TRuntime } + | { readonly ok: false; readonly error: string } + >; + readonly listSubscriptions: (input: { + readonly profileId: string; + readonly projectId?: string; + readonly teamId?: string; + }) => Promise< + | { + readonly ok: true; + readonly data: BrokerListAutosyncSubscriptionsPayload; + } + | { readonly ok: false; readonly error: string } + >; + readonly listDeliveries: (input: { + readonly profileId: string; + readonly status?: LinearDeliveryStatus; + readonly projectId?: string; + readonly teamId?: string; + readonly limit?: number; + }) => Promise< + | { readonly ok: true; readonly data: BrokerListDeliveriesPayload } + | { readonly ok: false; readonly error: string } + >; + readonly syncIssue: (input: { + readonly runtime: TRuntime; + readonly delivery: LinearDeliverySummary; + readonly syncToggles: SyncToggles; + }) => Promise< + SyncTicketFromLinearSuccess | { readonly ok: false; readonly error: string } + >; + readonly syncProject: (input: { + readonly runtime: TRuntime; + readonly projectIds: readonly string[]; + readonly limit?: number; + readonly syncToggles: SyncToggles; + }) => Promise< + | { + readonly ok: true; + readonly projectIds: readonly string[]; + readonly processed: number; + readonly created: number; + readonly updated: number; + readonly commentsPulled: number; + readonly conflictsRecorded: number; + readonly checkpointsRecorded: number; + } + | { readonly ok: false; readonly error: string } + >; + readonly applyDelivery: (input: { + readonly profileId: string; + readonly deliveryId: string; + readonly claimedBy?: string; + }) => Promise< + | { readonly ok: true; readonly data: BrokerApplyDeliveryPayload } + | { readonly ok: false; readonly error: string } + >; + readonly claimedBy?: string; +}; + +async function syncLinearDeliveryToTicket(input: { readonly runtime: LinearToHackRuntime; - readonly issueIdentifier: string; + readonly delivery: Pick; readonly syncToggles: SyncToggles; }): Promise< SyncTicketFromLinearSuccess | { readonly ok: false; readonly error: string } > { - const issueResult = await input.runtime.linear.getIssueByIdentifier({ - identifier: input.issueIdentifier, - }); + const issueIdentifier = readOptionalString(input.delivery.issueIdentifier); + const issueId = readOptionalString(input.delivery.issueId); + let issueResult: Awaited< + ReturnType + > | null = null; + if (issueIdentifier) { + issueResult = await input.runtime.linear.getIssueByIdentifier({ + identifier: issueIdentifier, + }); + } else if (issueId) { + issueResult = await input.runtime.linear.getIssueById({ + issueId, + }); + } + + if (!issueResult) { + return { + ok: false, + error: + "Linear delivery does not include an issue identifier or issue id.", + }; + } if (!issueResult.ok) { return { ok: false, error: issueResult.error }; } if (!issueResult.data) { return { ok: false, - error: `Linear issue not found: ${input.issueIdentifier}`, + error: issueIdentifier + ? `Linear issue not found: ${issueIdentifier}` + : `Linear issue not found: ${issueId}`, }; } @@ -3155,6 +3388,22 @@ async function syncIssueFromLinearToTicket(input: { }); } +async function syncIssueFromLinearToTicket(input: { + readonly runtime: LinearToHackRuntime; + readonly issueIdentifier: string; + readonly syncToggles: SyncToggles; +}): Promise< + SyncTicketFromLinearSuccess | { readonly ok: false; readonly error: string } +> { + return await syncLinearDeliveryToTicket({ + runtime: input.runtime, + delivery: { + issueIdentifier: input.issueIdentifier, + }, + syncToggles: input.syncToggles, + }); +} + async function syncTicketToLinearIssue(input: { readonly runtime: HackToLinearRuntime; readonly ticketId: string; @@ -3565,6 +3814,315 @@ async function syncProjectFromLinearProjectsToTickets(input: { }; } +function buildLinearAutosyncClaimedBy(): string { + const user = + readOptionalString(process.env.USER) ?? + readOptionalString(process.env.LOGNAME) ?? + "hack"; + const host = readOptionalString(hostname()); + return host ? `${user}@${host}` : user; +} + +function hasActiveAutosyncSubscription(input: { + readonly subscriptions: readonly LinearAutosyncSubscriptionSummary[]; + readonly projectId: string; + readonly teamId?: string; +}): boolean { + const normalizedTeamId = readOptionalString(input.teamId) ?? undefined; + return input.subscriptions.some( + (subscription) => + subscription.mode === "auto_apply" && + subscription.status === "active" && + subscription.projectId === input.projectId && + (subscription.teamId ?? undefined) === normalizedTeamId + ); +} + +async function runProjectLinearAutosync(input: { + readonly binding: ResolvedLinearProjectBinding; + readonly profileId?: string; + readonly projectId?: string; + readonly teamId?: string; + readonly limit?: number; + readonly syncToggles: SyncToggles; + readonly deps: ProjectLinearAutosyncDeps; +}): Promise< + LinearAutosyncRunSuccess | { readonly ok: false; readonly error: string } +> { + const targets = resolveProjectPullTargets({ + binding: input.binding, + explicitProjectId: input.projectId, + }); + if (targets.length === 0) { + return { + ok: false, + error: + "Missing project id. Pass --project-id, bind a default project, or add additional linked projects first.", + }; + } + + let subscribedRoutes = 0; + let processedDeliveries = 0; + let appliedDeliveries = 0; + const skippedDeliveries = 0; + let failedDeliveries = 0; + let created = 0; + let updated = 0; + let commentsPulled = 0; + let conflictsRecorded = 0; + let checkpointsRecorded = 0; + const syncedProjectIds = new Set(); + const deliveries: LinearAutosyncRunDeliveryOutcome[] = []; + + for (const target of targets) { + const profileId = + readOptionalString(input.profileId) ?? + readOptionalString(target.profileId) ?? + readOptionalString(input.binding.profileId); + if (!profileId) { + return { + ok: false, + error: `No Linear profile configured for project route ${target.projectId}.`, + }; + } + + const effectiveTeamId = + readOptionalString(input.teamId) ?? readOptionalString(target.teamId); + const subscriptions = await input.deps.listSubscriptions({ + profileId, + projectId: target.projectId, + ...(effectiveTeamId ? { teamId: effectiveTeamId } : {}), + }); + if (!subscriptions.ok) { + return { ok: false, error: subscriptions.error }; + } + if ( + !hasActiveAutosyncSubscription({ + subscriptions: subscriptions.data.subscriptions, + projectId: target.projectId, + ...(effectiveTeamId ? { teamId: effectiveTeamId } : {}), + }) + ) { + continue; + } + subscribedRoutes += 1; + + const listedDeliveries = await input.deps.listDeliveries({ + profileId, + status: "pending", + projectId: target.projectId, + ...(effectiveTeamId ? { teamId: effectiveTeamId } : {}), + ...(input.limit ? { limit: input.limit } : {}), + }); + if (!listedDeliveries.ok) { + return { ok: false, error: listedDeliveries.error }; + } + if (listedDeliveries.data.deliveries.length === 0) { + continue; + } + + const runtime = await input.deps.createRuntime({ profileId }); + if (!runtime.ok) { + return { ok: false, error: runtime.error }; + } + + const issueDeliveryGroups = new Map(); + const projectDeliveries: LinearDeliverySummary[] = []; + for (const delivery of listedDeliveries.data.deliveries) { + const issueIdentifier = readOptionalString(delivery.issueIdentifier); + const issueId = readOptionalString(delivery.issueId); + if (issueIdentifier || issueId) { + const key = issueIdentifier + ? `identifier:${issueIdentifier}` + : `id:${issueId}`; + const existing = issueDeliveryGroups.get(key) ?? []; + existing.push(delivery); + issueDeliveryGroups.set(key, existing); + continue; + } + projectDeliveries.push(delivery); + } + + for (const groupedDeliveries of issueDeliveryGroups.values()) { + processedDeliveries += groupedDeliveries.length; + const delivery = groupedDeliveries[0]; + if (!delivery) { + continue; + } + const synced = await input.deps.syncIssue({ + runtime: runtime.value, + delivery, + syncToggles: input.syncToggles, + }); + if (!synced.ok) { + failedDeliveries += groupedDeliveries.length; + deliveries.push( + ...groupedDeliveries.map((groupedDelivery) => ({ + deliveryId: groupedDelivery.id, + profileId, + ...(groupedDelivery.projectId + ? { projectId: groupedDelivery.projectId } + : {}), + ...(groupedDelivery.teamId + ? { teamId: groupedDelivery.teamId } + : {}), + ...(groupedDelivery.issueId + ? { issueId: groupedDelivery.issueId } + : {}), + ...(groupedDelivery.issueIdentifier + ? { issueIdentifier: groupedDelivery.issueIdentifier } + : {}), + mode: "issue" as const, + status: "failed" as const, + reason: synced.error, + })) + ); + continue; + } + + if (synced.operation === "created") { + created += 1; + } else { + updated += 1; + } + commentsPulled += synced.commentsPulled; + conflictsRecorded += synced.conflictsRecorded; + checkpointsRecorded += synced.checkpointRecorded ? 1 : 0; + syncedProjectIds.add(target.projectId); + + for (const groupedDelivery of groupedDeliveries) { + const applied = await input.deps.applyDelivery({ + profileId, + deliveryId: groupedDelivery.id, + ...(input.deps.claimedBy ? { claimedBy: input.deps.claimedBy } : {}), + }); + if (!applied.ok) { + failedDeliveries += 1; + deliveries.push({ + deliveryId: groupedDelivery.id, + profileId, + ...(groupedDelivery.projectId + ? { projectId: groupedDelivery.projectId } + : {}), + ...(groupedDelivery.teamId + ? { teamId: groupedDelivery.teamId } + : {}), + ...(groupedDelivery.issueId + ? { issueId: groupedDelivery.issueId } + : {}), + ...(groupedDelivery.issueIdentifier + ? { issueIdentifier: groupedDelivery.issueIdentifier } + : {}), + mode: "issue", + status: "failed", + ticketId: synced.ticketId, + reason: applied.error, + }); + continue; + } + appliedDeliveries += 1; + deliveries.push({ + deliveryId: groupedDelivery.id, + profileId, + ...(groupedDelivery.projectId + ? { projectId: groupedDelivery.projectId } + : {}), + ...(groupedDelivery.teamId ? { teamId: groupedDelivery.teamId } : {}), + ...(groupedDelivery.issueId + ? { issueId: groupedDelivery.issueId } + : {}), + ...(groupedDelivery.issueIdentifier + ? { issueIdentifier: groupedDelivery.issueIdentifier } + : {}), + mode: "issue", + status: "applied", + ticketId: synced.ticketId, + }); + } + } + + if (projectDeliveries.length > 0) { + processedDeliveries += projectDeliveries.length; + const synced = await input.deps.syncProject({ + runtime: runtime.value, + projectIds: [target.projectId], + ...(input.limit ? { limit: input.limit } : {}), + syncToggles: input.syncToggles, + }); + if (synced.ok) { + created += synced.created; + updated += synced.updated; + commentsPulled += synced.commentsPulled; + conflictsRecorded += synced.conflictsRecorded; + checkpointsRecorded += synced.checkpointsRecorded; + for (const projectId of synced.projectIds) { + syncedProjectIds.add(projectId); + } + for (const delivery of projectDeliveries) { + const applied = await input.deps.applyDelivery({ + profileId, + deliveryId: delivery.id, + ...(input.deps.claimedBy + ? { claimedBy: input.deps.claimedBy } + : {}), + }); + if (!applied.ok) { + failedDeliveries += 1; + deliveries.push({ + deliveryId: delivery.id, + profileId, + ...(delivery.projectId ? { projectId: delivery.projectId } : {}), + ...(delivery.teamId ? { teamId: delivery.teamId } : {}), + mode: "project", + status: "failed", + reason: applied.error, + }); + continue; + } + appliedDeliveries += 1; + deliveries.push({ + deliveryId: delivery.id, + profileId, + ...(delivery.projectId ? { projectId: delivery.projectId } : {}), + ...(delivery.teamId ? { teamId: delivery.teamId } : {}), + mode: "project", + status: "applied", + }); + } + } else { + failedDeliveries += projectDeliveries.length; + deliveries.push( + ...projectDeliveries.map((delivery) => ({ + deliveryId: delivery.id, + profileId, + ...(delivery.projectId ? { projectId: delivery.projectId } : {}), + ...(delivery.teamId ? { teamId: delivery.teamId } : {}), + mode: "project" as const, + status: "failed" as const, + reason: synced.error, + })) + ); + } + } + } + + return { + ok: true, + subscribedRoutes, + processedDeliveries, + appliedDeliveries, + skippedDeliveries, + failedDeliveries, + created, + updated, + commentsPulled, + conflictsRecorded, + checkpointsRecorded, + projectIds: [...syncedProjectIds], + deliveries, + }; +} + async function collectLinearProjectIssuesForSync(input: { readonly runtime: SyncRuntime; readonly projectIds: readonly string[]; @@ -5158,6 +5716,8 @@ async function listLinearDeliveries(input: { readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; readonly profileId?: string; readonly status?: LinearDeliveryStatus; + readonly projectId?: string; + readonly teamId?: string; readonly limit?: number; }): Promise< | { readonly ok: true; readonly data: BrokerListDeliveriesPayload } @@ -5181,6 +5741,12 @@ async function listLinearDeliveries(input: { if (input.status) { query.set("status", input.status); } + if (input.projectId) { + query.set("projectId", input.projectId); + } + if (input.teamId) { + query.set("teamId", input.teamId); + } if (normalizedLimit > 0) { query.set("limit", String(normalizedLimit)); } @@ -5227,6 +5793,7 @@ async function applyLinearDelivery(input: { readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; readonly profileId?: string; readonly deliveryId: string; + readonly claimedBy?: string; }): Promise< | { readonly ok: true; readonly data: BrokerApplyDeliveryPayload } | { readonly ok: false; readonly error: string } @@ -5258,6 +5825,13 @@ async function applyLinearDelivery(input: { "content-type": "application/json", ...brokerAuth.headers, }, + ...(input.claimedBy + ? { + body: JSON.stringify({ + claimedBy: input.claimedBy, + }), + } + : {}), }, }); if (!response.ok) { @@ -5531,6 +6105,24 @@ function parseLinearDeliverySummary(input: { return { id, status, + ...(readOptionalString(input.value.profileId) + ? { profileId: readOptionalString(input.value.profileId) } + : {}), + ...(readOptionalString(input.value.projectId) + ? { projectId: readOptionalString(input.value.projectId) } + : {}), + ...(readOptionalString(input.value.teamId) + ? { teamId: readOptionalString(input.value.teamId) } + : {}), + ...(readOptionalString(input.value.issueId) + ? { issueId: readOptionalString(input.value.issueId) } + : {}), + ...(readOptionalString(input.value.issueIdentifier) + ? { issueIdentifier: readOptionalString(input.value.issueIdentifier) } + : {}), + ...(readOptionalString(input.value.claimedBy) + ? { claimedBy: readOptionalString(input.value.claimedBy) } + : {}), ...(readOptionalString(input.value.eventType) ? { eventType: readOptionalString(input.value.eventType) } : {}), @@ -5557,11 +6149,17 @@ function parseLinearDeliveryApplyPayload(input: { if (!isRecord(input.payload)) { return null; } + const deliveryRecord = isRecord(input.payload.delivery) + ? input.payload.delivery + : input.payload; const deliveryId = readOptionalString(input.payload.deliveryId) ?? - readOptionalString(input.payload.id) ?? + readOptionalString(deliveryRecord.deliveryId) ?? + readOptionalString(deliveryRecord.id) ?? input.deliveryId; - const status = readOptionalString(input.payload.status); + const status = + readOptionalString(input.payload.status) ?? + readOptionalString(deliveryRecord.status); if (!status) { return null; } @@ -6833,6 +7431,56 @@ function parseSyncProjectArgs(input: { return { ok: true, value }; } +function parseRunAutosyncArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: RunAutosyncArgs } + | { readonly ok: false; readonly error: string } { + const value: RunAutosyncArgs = { + json: false, + }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + if (token === "--sync-labels") { + value.syncLabels = true; + continue; + } + if (token.startsWith("--limit=")) { + value.limit = Number.parseInt(token.slice("--limit=".length), 10); + continue; + } + if (token === "--limit") { + const next = input.args[i + 1]; + if (!next || next.startsWith("-")) { + return { ok: false, error: "--limit requires a value." }; + } + value.limit = Number.parseInt(next, 10); + i += 1; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "project-id": "projectId", + "team-id": "teamId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + function assignKeyValueFlag>(input: { readonly token: string; readonly args: readonly string[]; @@ -6890,6 +7538,7 @@ export const __testOnly = { parseAssigneeMappingsArgs, parseConnectArgs, parseDeliveriesArgs, + parseRunAutosyncArgs, parseProjectBindArgs, parseProjectLinkArgs, parseProjectsArgs, @@ -6909,6 +7558,7 @@ export const __testOnly = { selectTicketCommentsToPush, shouldFallbackConnectToOAuth, shouldUseBrokerOAuthFlow, + runProjectLinearAutosync, syncIssueFromLinearToTicket, syncTicketToLinearIssue, } as const; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d63db1e8f..6a2724c38 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1035,6 +1035,60 @@ function registerTools(opts: { readonly server: McpServer }): void { } ); + opts.server.registerTool( + "hack.linear.autosync.run", + { + title: "Run subscribed Linear autosync", + description: + "Consume pending broker-backed Linear deliveries for the current Hack project and apply them after local sync succeeds.", + inputSchema: { + ...projectSelectorInput, + ...linearCommonInput, + projectId: z + .string() + .describe("Optional Linear project id filter") + .optional(), + teamId: z + .string() + .describe("Optional Linear team id filter") + .optional(), + limit: z.number().describe("Max deliveries to process").optional(), + }, + outputSchema: toolOutputSchema, + }, + async (input) => { + const projectCwd = await resolveProjectCwd({ + selection: toProjectSelection(input), + cwd: process.cwd(), + requireProjectContext: true, + }); + if (!projectCwd.ok) { + return buildToolError({ message: projectCwd.message }); + } + + const args = [ + "linear", + "run-autosync", + "--json", + ...(input.profile ? ["--profile", input.profile] : []), + ...(input.projectId ? ["--project-id", input.projectId] : []), + ...(input.teamId ? ["--team-id", input.teamId] : []), + ...(input.limit ? ["--limit", String(input.limit)] : []), + ]; + + const result = await runHackCommand({ + tool: "hack.linear.autosync.run", + args, + cwd: projectCwd.cwd, + }); + + return buildToolResult({ + result, + data: parseJson(result.stdout), + }); + } + ); + opts.server.registerTool( "hack.linear.deliveries.list", { diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index 6f5c9795c..940e0ad3b 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -159,6 +159,35 @@ test("parseSyncProjectArgs parses owner filter and limits", () => { }); }); +test("parseRunAutosyncArgs parses profile, route filters, and limit", () => { + const parsed = __testOnly.parseRunAutosyncArgs({ + args: [ + "--profile", + "work", + "--project-id", + "proj-1", + "--team-id", + "team-1", + "--limit", + "5", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + projectId: "proj-1", + teamId: "team-1", + limit: 5, + json: true, + }); +}); + test("parseProjectLinkArgs parses additional project routing flags", () => { const parsed = __testOnly.parseProjectLinkArgs({ args: [ @@ -893,6 +922,7 @@ test("syncIssueFromLinearToTicket preserves hack authority, appends comments, an }, }, linear: { + getIssueById: async () => ({ ok: true as const, data: null }), getIssueByIdentifier: async () => ({ ok: true as const, data: { @@ -1170,3 +1200,151 @@ test("syncTicketToLinearIssue pushes missing local comments and records a checkp expect(checkpoints).toEqual(["hack_to_linear"]); expect(updatedIssues[0]?.assigneeId).toBe("user-1"); }); + +test("runProjectLinearAutosync syncs issue and project deliveries, then applies them", async () => { + const appliedDeliveryIds: string[] = []; + const issueSyncCalls: Array<{ issueIdentifier?: string; issueId?: string }> = + []; + const projectSyncCalls: string[][] = []; + + const result = await __testOnly.runProjectLinearAutosync({ + binding: { + profileId: "work", + projectId: "proj-default", + projectName: "Default", + teamId: "team-default", + additionalProjects: [ + { + profileId: "ops", + projectId: "proj-ops", + projectName: "Ops", + teamId: "team-ops", + }, + ], + }, + syncToggles: { + labels: false, + statuses: true, + dependencies: true, + projects: true, + }, + limit: 10, + deps: { + createRuntime: async ({ profileId }) => ({ + ok: true as const, + value: { + profileId: profileId ?? "work", + }, + }), + listSubscriptions: async ({ profileId, projectId }) => ({ + ok: true as const, + data: { + profileId, + subscriptions: + profileId === "work" && projectId === "proj-default" + ? [ + { + id: "sub-default", + profileId, + projectId, + teamId: "team-default", + mode: "auto_apply" as const, + status: "active" as const, + }, + ] + : [], + }, + }), + listDeliveries: async ({ profileId, projectId }) => ({ + ok: true as const, + data: { + profileId, + status: "pending", + limit: 10, + deliveries: + profileId === "work" && projectId === "proj-default" + ? [ + { + id: "delivery-issue", + status: "pending", + profileId, + projectId, + teamId: "team-default", + issueIdentifier: "ENG-101", + }, + { + id: "delivery-project", + status: "pending", + profileId, + projectId, + teamId: "team-default", + }, + ] + : [], + }, + }), + syncIssue: async ({ delivery }) => { + issueSyncCalls.push({ + issueIdentifier: delivery.issueIdentifier, + issueId: delivery.issueId, + }); + return { + ok: true as const, + operation: "updated" as const, + ticketId: "T-00101", + issueIdentifier: delivery.issueIdentifier ?? "ENG-101", + commentsPulled: 1, + conflictsRecorded: 0, + checkpointRecorded: true, + }; + }, + syncProject: async ({ projectIds }) => { + projectSyncCalls.push([...projectIds]); + return { + ok: true as const, + projectIds, + processed: 3, + created: 1, + updated: 2, + commentsPulled: 4, + conflictsRecorded: 0, + checkpointsRecorded: 3, + }; + }, + applyDelivery: async ({ deliveryId }) => { + appliedDeliveryIds.push(deliveryId); + return { + ok: true as const, + data: { + deliveryId, + status: "applied", + }, + }; + }, + claimedBy: "local-test-runner", + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(issueSyncCalls).toEqual([ + { + issueIdentifier: "ENG-101", + issueId: undefined, + }, + ]); + expect(projectSyncCalls).toEqual([["proj-default"]]); + expect(appliedDeliveryIds).toEqual(["delivery-issue", "delivery-project"]); + expect(result.subscribedRoutes).toBe(1); + expect(result.processedDeliveries).toBe(2); + expect(result.appliedDeliveries).toBe(2); + expect(result.failedDeliveries).toBe(0); + expect(result.skippedDeliveries).toBe(0); + expect(result.created).toBe(1); + expect(result.updated).toBe(3); + expect(result.commentsPulled).toBe(5); + expect(result.checkpointsRecorded).toBe(4); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8e6139f4e..3895e761e 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -44,6 +44,7 @@ testIntegration( expect(names).toContain("hack.linear.project.link"); expect(names).toContain("hack.linear.project.unlink"); expect(names).toContain("hack.linear.sync.project"); + expect(names).toContain("hack.linear.autosync.run"); expect(names).toContain("hack.linear.deliveries.list"); expect(names).toContain("hack.linear.deliveries.apply"); expect(names).toContain("hack.linear.subscriptions.list"); @@ -174,6 +175,45 @@ testIntegration( } ); +testIntegration( + "hack.linear.autosync.run forwards project and team filters", + { timeout: 20_000 }, + async () => { + const mcp = await startMcpClient(); + const result = await mcp.callTool({ + name: "hack.linear.autosync.run", + arguments: { + projectId: "proj-1", + teamId: "team-1", + limit: 5, + }, + }); + + const structured = result.structuredContent as { + ok: boolean; + data?: unknown; + }; + + expect(result.isError).toBeUndefined(); + expect(structured.ok).toBe(true); + expect(structured.data).toEqual({ + ok: true, + subscribedRoutes: 1, + processedDeliveries: 5, + appliedDeliveries: 2, + skippedDeliveries: 0, + failedDeliveries: 0, + created: 1, + updated: 1, + commentsPulled: 0, + conflictsRecorded: 0, + checkpointsRecorded: 2, + projectIds: ["proj-1"], + teamId: "team-1", + }); + } +); + testIntegration( "hack.linear.deliveries.list forwards status and limit flags", { timeout: 20_000 }, @@ -519,6 +559,16 @@ function buildHackStubScript(): string { ' console.log(JSON.stringify({ ok: true, profileId: "default", projectId: "proj-default", projectName: "Default Project", teamId: "team-default", additionalProjects: [], additionalProjectChanged: true, removedProjectId }))', " process.exit(0)", " }", + ' if (sub === "run-autosync") {', + ' const projectIdIndex = args.indexOf("--project-id")', + ' const teamIdIndex = args.indexOf("--team-id")', + ' const limitIndex = args.indexOf("--limit")', + ' const projectId = projectIdIndex === -1 ? "proj-default" : args[projectIdIndex + 1]', + " const teamId = teamIdIndex === -1 ? null : args[teamIdIndex + 1]", + " const limit = limitIndex === -1 ? 2 : Number(args[limitIndex + 1])", + " console.log(JSON.stringify({ ok: true, subscribedRoutes: 1, processedDeliveries: limit, appliedDeliveries: 2, skippedDeliveries: 0, failedDeliveries: 0, created: 1, updated: 1, commentsPulled: 0, conflictsRecorded: 0, checkpointsRecorded: 2, projectIds: [projectId], teamId }))", + " process.exit(0)", + " }", ' if (sub === "deliveries") {', ' const profileIndex = args.indexOf("--profile")', ' const statusIndex = args.indexOf("--status")', From da2526ccefcc001c896ac41899891444a21883ea Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 08:21:11 -0500 Subject: [PATCH 07/54] Polish desktop Linear routing and ticket UX --- .../DashboardFeature/CommandPalette.swift | 13 +- .../DashboardFeature/ProjectDetailView.swift | 765 ++++++++---------- .../DashboardFeature/TicketsView.swift | 231 +++--- ...-03-06-linear-desktop-ux-cleanup-design.md | 111 +++ ...26-03-06-linear-desktop-ux-cleanup-plan.md | 132 +++ 5 files changed, 679 insertions(+), 573 deletions(-) create mode 100644 docs/plans/2026-03-06-linear-desktop-ux-cleanup-design.md create mode 100644 docs/plans/2026-03-06-linear-desktop-ux-cleanup-plan.md diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift index 6f3f54b89..2d0b90964 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift @@ -8,6 +8,7 @@ extension Notification.Name { public static let hackRefreshRequested = Notification.Name("hack.refresh.requested") public static let hackProjectNavigationRequested = Notification.Name("hack.projectNavigation.requested") public static let hackTicketReviewQueueRequested = Notification.Name("hack.ticketReviewQueue.requested") + public static let hackProjectRoutingRequested = Notification.Name("hack.projectRouting.requested") } enum ProjectNavigationRequest { @@ -20,6 +21,10 @@ enum TicketReviewQueueRequest { static let projectIdKey = "projectId" } +enum ProjectRoutingRequest { + static let projectIdKey = "projectId" +} + struct CommandPaletteAction: Identifiable { let id = UUID() let title: String @@ -348,14 +353,12 @@ struct CommandPaletteView: View { } private func openProjectRouting(_ project: ProjectSummary) { - openProjectOverview(project) + model.selectedItem = .project(project.id) NotificationCenter.default.post( - name: .hackProjectNavigationRequested, + name: .hackProjectRoutingRequested, object: nil, userInfo: [ - ProjectNavigationRequest.projectIdKey: project.id, - ProjectNavigationRequest.tabKey: ProjectTab.overview.rawValue, - ProjectNavigationRequest.sidebarKey: "remoteExecution", + ProjectRoutingRequest.projectIdKey: project.id ] ) } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 9b748b70f..d142cae35 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -54,7 +54,7 @@ private enum ProjectSidebarItem: String, CaseIterable, Identifiable { var title: String { switch self { case .remoteExecution: - return "Project routing" + return "Project settings" case .services: return "Services" case .lifecycle: @@ -71,7 +71,7 @@ private enum ProjectSidebarItem: String, CaseIterable, Identifiable { var icon: String { switch self { case .remoteExecution: - return "point.3.connected.trianglepath.dotted" + return "slider.horizontal.3" case .services: return "shippingbox" case .lifecycle: @@ -86,49 +86,6 @@ private enum ProjectSidebarItem: String, CaseIterable, Identifiable { } } -private enum ProjectLinearSyncAction: String, Identifiable { - case pullFromLinear - case pushHackToLinear - - var id: String { rawValue } - - var title: String { - switch self { - case .pullFromLinear: - return "Pull from Linear?" - case .pushHackToLinear: - return "Push Hack tickets?" - } - } - - var buttonLabel: String { - switch self { - case .pullFromLinear: - return "Pull Linear" - case .pushHackToLinear: - return "Push Hack" - } - } - - var message: String { - switch self { - case .pullFromLinear: - return "Linear-origin tickets stay authoritative for title, body, status, and project binding. Comments remain append-only. Assignees, labels, and dependencies merge best-effort when mappings are clear." - case .pushHackToLinear: - return "Hack-origin tickets stay authoritative for title, body, status, and project binding. Comments remain append-only. Assignees, labels, and dependencies merge best-effort when mappings are clear." - } - } - - var direction: String { - switch self { - case .pullFromLinear: - return "linear" - case .pushHackToLinear: - return "hack" - } - } -} - struct ProjectDetailView: View { @Environment(DashboardModel.self) private var model @Environment(\.openURL) private var openURL @@ -182,9 +139,9 @@ struct ProjectDetailView: View { @State private var selectedLinearProjectId = "" @State private var selectedAdditionalLinearProjectId = "" @State private var linearProjectMessage = "" - @State private var pendingLinearSyncAction: ProjectLinearSyncAction? = nil @State private var projectSystemGitIdentity: GitSystemIdentity? = nil @State private var executionTargetReloadTask: Task? = nil + @State private var showLinearRoutingSheet = false var body: some View { @Bindable var model = model @@ -239,6 +196,16 @@ struct ProjectDetailView: View { } ensureSidebarSelection() } + .onReceive(NotificationCenter.default.publisher(for: .hackProjectRoutingRequested)) { notification in + guard + let userInfo = notification.userInfo, + let requestedProjectId = userInfo[ProjectRoutingRequest.projectIdKey] as? String, + requestedProjectId == project.id + else { + return + } + showLinearRoutingSheet = true + } .onDisappear { executionTargetReloadTask?.cancel() executionTargetReloadTask = nil @@ -246,15 +213,8 @@ struct ProjectDetailView: View { .sheet(isPresented: $showAddBranchSheet) { addBranchSheet } - .alert(item: $pendingLinearSyncAction) { action in - Alert( - title: Text(action.title), - message: Text(action.message), - primaryButton: .default(Text(action.buttonLabel)) { - Task { await syncBoundLinearProject(from: action.direction) } - }, - secondaryButton: .cancel() - ) + .sheet(isPresented: $showLinearRoutingSheet) { + linearRoutingSheet } } @@ -556,7 +516,7 @@ struct ProjectDetailView: View { private func sidebarSectionHeader(_ title: String) -> some View { Text(title) - .font(.mono(.caption, weight: .semibold)) + .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) } @@ -568,16 +528,16 @@ struct ProjectDetailView: View { } label: { HStack(spacing: 8) { Image(systemName: item.icon) - .font(.mono(.caption, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(selected ? Color.accentColor : Color.secondary) .frame(width: 14, alignment: .center) Text(item.title) - .font(.mono(.subheadline, weight: .medium)) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(selected ? .primary : .secondary) Spacer(minLength: 6) if let count = sidebarItemCountLabel(item) { Text(count) - .font(.mono(.caption2, weight: .semibold)) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundStyle(selected ? .primary : .tertiary) } } @@ -586,7 +546,7 @@ struct ProjectDetailView: View { .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(selected ? Color.accentColor.opacity(0.18) : hoveredSidebarItem == item ? Color.white.opacity(0.06) : .clear) + .fill(selected ? Color.accentColor.opacity(0.14) : hoveredSidebarItem == item ? Color.primary.opacity(colorScheme == .dark ? 0.06 : 0.04) : .clear) ) } .buttonStyle(.plain) @@ -603,21 +563,17 @@ struct ProjectDetailView: View { Button(action: action) { HStack(spacing: 8) { Image(systemName: icon) - .font(.mono(.caption, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) .frame(width: 14, alignment: .center) Text(title) - .font(.mono(.subheadline, weight: .medium)) + .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) Spacer(minLength: 6) } .padding(.horizontal, 10) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(Color.white.opacity(0.04)) - ) } .buttonStyle(.plain) } @@ -729,55 +685,28 @@ struct ProjectDetailView: View { } private var remoteExecutionSection: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 10) { - Image(systemName: "point.3.connected.trianglepath.dotted") + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "slider.horizontal.3") .foregroundStyle(.secondary) - Text("Project routing") - .font(.mono(.headline, weight: .semibold)) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + Text("Project settings") + .font(.system(size: 20, weight: .semibold)) + Text("Keep runtime and identity settings here. Linear routing now lives in a focused sheet so ticket work stays in Tickets.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + } Spacer() if executionTargetLoading || executionTargetSaving { ProgressView() .controlSize(.small) } - Button { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "github"] - ) - } label: { - Label("GitHub", systemImage: "person.crop.circle.badge.checkmark") - } - .buttonStyle(PressableIconButtonStyle()) - Button { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) - } label: { - Label("Linear", systemImage: "point.3.connected.trianglepath.dotted") - } - .buttonStyle(PressableIconButtonStyle()) } - Text("Choose runtime placement, remote Git identity, and the default Linear account/project used for ticket sync in this repo.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - InlineCallout( - tone: linearRoutingContractTone, - title: "Linear sync contract", - message: linearRoutingContractMessage, - actions: linearRoutingContractActions - ) - - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 14) { HStack(alignment: .center, spacing: 12) { - Text("Execution mode") - .font(.mono(.subheadline, weight: .semibold)) - .frame(width: 120, alignment: .leading) + projectSettingsLabel("Execution mode") Picker("Execution mode", selection: $executionMode) { ForEach(RemoteExecutionMode.allCases) { mode in @@ -794,15 +723,11 @@ struct ProjectDetailView: View { } Spacer() - Text(executionMode.summary) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + projectSettingsFootnote(executionMode.summary) } HStack(alignment: .center, spacing: 12) { - Text("Default node") - .font(.mono(.subheadline, weight: .semibold)) - .frame(width: 120, alignment: .leading) + projectSettingsLabel("Default node") Picker("Default node", selection: $executionTargetNodeId) { Text("Local").tag("") @@ -821,15 +746,11 @@ struct ProjectDetailView: View { .disabled(executionMode == .local) Spacer() - Text(selectedNodeSummary) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + projectSettingsFootnote(selectedNodeSummary) } HStack(alignment: .center, spacing: 12) { - Text("Git creds") - .font(.mono(.subheadline, weight: .semibold)) - .frame(width: 120, alignment: .leading) + projectSettingsLabel("Git creds") Picker("Git creds", selection: $githubProjectProfile) { Text("Local").tag("") @@ -847,190 +768,324 @@ struct ProjectDetailView: View { } Spacer() - Text(selectedGitSummary) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + projectSettingsFootnote(selectedGitSummary) } + } + .padding(16) + .background(projectSettingsCardBackground) - HStack(alignment: .center, spacing: 12) { - Text("Linear acct") - .font(.mono(.subheadline, weight: .semibold)) - .frame(width: 120, alignment: .leading) - - Picker("Linear account", selection: $linearProjectProfile) { - Text("Inherited").tag("") - ForEach(linearProfileOptions, id: \.self) { profile in - Text(linearProfileLabel(profileId: profile)).tag(profile) - } - } - .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) - .onChange(of: linearProjectProfile) { _, _ in - guard !executionTargetLoading else { - return - } - Task { await persistLinearProfileOverride() } - } + linearRoutingSummaryCard - Spacer() - Text(selectedLinearProfileSummary) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + if let projectSystemGitIdentity { + let systemAccountLabel = projectSystemGitIdentity.githubLogin.map { "@\($0)" } ?? "unavailable" + Text("Local system Git: \(systemAccountLabel)\(projectSystemGitIdentity.gitEmail.map { " • \($0)" } ?? "")") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + + if !remoteConfigMessage.isEmpty { + InlineCallout( + tone: remoteConfigTone, + title: remoteConfigTone == .warn ? "Settings issue" : "Settings updated", + message: remoteConfigMessage, + actions: remoteConfigActions + ) + } + } + } + + private var linearRoutingSummaryCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Linear routing") + .font(.system(size: 15, weight: .semibold)) + Text(linearRoutingSummaryLine) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + } + Spacer() + Button(linearResolvedStatus?.tokenResolved == true ? "Edit routing" : "Connect Linear") { + showLinearRoutingSheet = true } + .adaptiveToolbarButtonProminent() + } - HStack(alignment: .center, spacing: 12) { - Text("Linear proj") - .font(.mono(.subheadline, weight: .semibold)) - .frame(width: 120, alignment: .leading) - - Picker("Linear project", selection: $selectedLinearProjectId) { - Text("Unbound").tag("") - ForEach(linearProjectOptions) { linearProject in - Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) - } - } - .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) - .disabled(linearProjectOptions.isEmpty) - .onChange(of: selectedLinearProjectId) { _, newValue in - guard !executionTargetLoading else { - return - } - guard newValue != linearBoundProjectId else { - return - } - Task { await persistLinearProjectBindingSelection() } - } + HStack(spacing: 8) { + if linearResolvedStatus?.tokenResolved == true { + StatusPill(text: "Connected", tone: .good) + } else { + StatusPill(text: "Needs connection", tone: .warn) + } + if !linearBoundProjectName.isEmpty { + StatusPill(text: "Default \(linearBoundProjectName)", tone: .neutral) + } else if !linearBoundProjectId.isEmpty { + StatusPill(text: "Default bound", tone: .neutral) + } + if !linearAdditionalProjects.isEmpty { + StatusPill( + text: "\(linearAdditionalProjects.count) linked project\(linearAdditionalProjects.count == 1 ? "" : "s")", + tone: .neutral + ) + } + if hasAnyLinearAutosyncRoutes { + StatusPill( + text: "\(linearAutosyncRouteKeys.count) autosync route\(linearAutosyncRouteKeys.count == 1 ? "" : "s")", + tone: .good + ) + } + Spacer(minLength: 0) + } - if !linearBoundProjectId.isEmpty { - Button("Clear") { - Task { await clearLinearProjectBinding() } - } - .adaptiveToolbarButton() - } + if !linearProjectMessage.isEmpty { + Text(linearProjectMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(remoteConfigTone == .warn ? Color.orange : .secondary) + } + } + .padding(16) + .background(projectSettingsCardBackground) + } - Spacer() - Text(selectedLinearProjectSummary) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + private var linearRoutingSheet: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("Linear routing") + .font(.system(size: 22, weight: .semibold)) + Text("Configure the account and Linear projects this repo syncs with. Pull, push, autosync execution, and review stay in Tickets.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) } - HStack(alignment: .top, spacing: 12) { - Text("Linear scope") - .font(.mono(.subheadline, weight: .semibold)) - .frame(width: 120, alignment: .leading) - - VStack(alignment: .leading, spacing: 8) { - if allLinearRouteTargets.isEmpty { - Text("No Linear route bound yet. Pick a default project first, then optionally add extra projects for pull review and autosync.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(allLinearRouteTargets) { target in - linearScopeRouteRow( - target: target, - isDefault: target.projectId == linearBoundProjectId - ) + routingSheetSection( + title: "Account", + description: "Choose the Linear account used for this repo. If no account is connected yet, start from global Linear settings." + ) { + if linearResolvedStatus?.tokenResolved == true { + HStack(alignment: .center, spacing: 12) { + Picker("Linear account", selection: $linearProjectProfile) { + Text("Inherited").tag("") + ForEach(linearProfileOptions, id: \.self) { profile in + Text(linearProfileLabel(profileId: profile)).tag(profile) } } + .pickerStyle(.menu) + .frame(maxWidth: 320, alignment: .leading) + .onChange(of: linearProjectProfile) { _, _ in + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } + } + + StatusPill(text: "Connected", tone: .good) + Spacer() + Button("Linear settings") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + } + .adaptiveToolbarButton() } - HStack(spacing: 8) { - Picker("Add linked project", selection: $selectedAdditionalLinearProjectId) { - Text("Default only").tag("") - ForEach(availableAdditionalLinearProjects) { linearProject in - Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) - } + Text(selectedLinearProfileSummary) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + Text("Connect a Linear account before binding routes for this repo.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + Button("Connect Linear") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) } - .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) - .disabled(availableAdditionalLinearProjects.isEmpty) + .adaptiveToolbarButtonProminent() + } + } + } + + routingSheetSection( + title: "Default project", + description: "This is the primary Linear project for project-level pull, push, and routing." + ) { + HStack(alignment: .center, spacing: 12) { + Picker("Linear project", selection: $selectedLinearProjectId) { + Text("Unbound").tag("") + ForEach(linearProjectOptions) { linearProject in + Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 360, alignment: .leading) + .disabled(linearProjectOptions.isEmpty) + .onChange(of: selectedLinearProjectId) { _, newValue in + guard !executionTargetLoading else { + return + } + guard newValue != linearBoundProjectId else { + return + } + Task { await persistLinearProjectBindingSelection() } + } - Button("Add") { - Task { await persistAdditionalLinearProjectSelection() } + if !linearBoundProjectId.isEmpty { + Button("Clear") { + Task { await clearLinearProjectBinding() } } .adaptiveToolbarButton() - .disabled( - selectedAdditionalLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - ) } + + Spacer() + projectSettingsFootnote(selectedLinearProjectSummary) } - Spacer() - Text(additionalLinearProjectsSummary) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + if let defaultTarget = defaultLinearRouteTarget { + linearScopeRouteRow(target: defaultTarget, isDefault: true) + } } - HStack(alignment: .center, spacing: 12) { - Text("Ticket sync") - .font(.mono(.subheadline, weight: .semibold)) - .frame(width: 120, alignment: .leading) + routingSheetSection( + title: "Additional synced projects", + description: "Link extra Linear projects when this repo needs review or autosync coverage beyond the default route." + ) { + if linearAdditionalProjects.isEmpty { + Text("No additional linked projects.") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(linearAdditionalProjects) { target in + linearScopeRouteRow(target: target, isDefault: false) + } + } + } HStack(spacing: 8) { - Button { - pendingLinearSyncAction = .pullFromLinear - } label: { - Label("Pull Linear", systemImage: "arrow.down.left") + Picker("Add linked project", selection: $selectedAdditionalLinearProjectId) { + Text("Choose project").tag("") + ForEach(availableAdditionalLinearProjects) { linearProject in + Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + } } - .adaptiveToolbarButtonProminent() - .disabled(!canSyncFromLinear) + .pickerStyle(.menu) + .frame(maxWidth: 360, alignment: .leading) + .disabled(availableAdditionalLinearProjects.isEmpty) - Button { - pendingLinearSyncAction = .pushHackToLinear - } label: { - Label("Push Hack", systemImage: "arrow.up.right") + Button("Add") { + Task { await persistAdditionalLinearProjectSelection() } } .adaptiveToolbarButton() - .disabled(!canSyncToLinear) + .disabled( + selectedAdditionalLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + ) - if hasAnyLinearAutosyncRoutes { - Button { - Task { await runBoundLinearAutosync() } - } label: { - Label("Run all autosync routes", systemImage: "bolt.badge.clock") - } - .adaptiveToolbarButton() - .disabled(linearResolvedStatus?.tokenResolved != true) - } + Spacer() + projectSettingsFootnote(additionalLinearProjectsSummary) } + } + HStack { Spacer() - Text(ticketSyncSummary) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + Button("Done") { + showLinearRoutingSheet = false + } + .adaptiveToolbarButtonProminent() } } + .padding(24) + } + .frame(minWidth: 620, minHeight: 560) + .background(Color.clear) + } - if let projectSystemGitIdentity { - let systemAccountLabel = projectSystemGitIdentity.githubLogin.map { "@\($0)" } ?? "unavailable" - Text("Local system Git: \(systemAccountLabel)\(projectSystemGitIdentity.gitEmail.map { " • \($0)" } ?? "")") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } + private var defaultLinearRouteTarget: LinearProjectBindingTarget? { + guard !linearBoundProjectId.isEmpty else { + return nil + } + return LinearProjectBindingTarget( + profileId: resolvedLinearRoutingProfileId, + projectId: linearBoundProjectId, + projectName: linearBoundProjectName.isEmpty ? nil : linearBoundProjectName, + teamId: linearBoundTeamId.isEmpty ? nil : linearBoundTeamId + ) + } - if let accountName = linearResolvedAccountName, !accountName.isEmpty { - let teamSuffix = linearBoundTeamId.isEmpty ? "" : " • team \(linearBoundTeamId)" - let additionalSuffix = linearAdditionalProjects.isEmpty - ? "" - : " • +\(linearAdditionalProjects.count) linked" - Text("Linear route: \(accountName)\(linearBoundProjectName.isEmpty ? "" : " • \(linearBoundProjectName)")\(teamSuffix)\(additionalSuffix)") - .font(.mono(.caption2)) + private var resolvedLinearRoutingProfileId: String? { + let resolved = linearResolvedProfile.trimmingCharacters(in: .whitespacesAndNewlines) + if !resolved.isEmpty { + return resolved + } + let explicit = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) + if !explicit.isEmpty { + return explicit + } + let inherited = linearDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) + return inherited.isEmpty ? nil : inherited + } + + private var linearRoutingSummaryLine: String { + if linearResolvedStatus?.tokenResolved != true { + return "No Linear account connected for this repo yet." + } + if !hasAnyLinearProjectRouting { + return "Account connected, but this repo does not have a default Linear project yet." + } + if linearAdditionalProjects.isEmpty { + let defaultLabel = linearBoundProjectName.isEmpty ? resolvedLinearProjectId : linearBoundProjectName + return "\(selectedLinearProfileSummary) • default project \(defaultLabel)" + } + let defaultLabel = linearBoundProjectName.isEmpty ? resolvedLinearProjectId : linearBoundProjectName + return "\(selectedLinearProfileSummary) • default project \(defaultLabel) • \(linearAdditionalProjects.count) linked" + } + + private var projectSettingsCardBackground: some View { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.primary.opacity(colorScheme == .dark ? 0.045 : 0.03)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.primary.opacity(colorScheme == .dark ? 0.1 : 0.08), lineWidth: 1) + ) + } + + private func projectSettingsLabel(_ title: String) -> some View { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .frame(width: 120, alignment: .leading) + } + + private func projectSettingsFootnote(_ text: String) -> some View { + Text(text) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.tertiary) + } + + private func routingSheetSection( + title: String, + description: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + Text(description) + .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } - if !remoteConfigMessage.isEmpty { - InlineCallout( - tone: remoteConfigTone, - title: remoteConfigTone == .warn ? "Routing issue" : "Routing updated", - message: remoteConfigMessage, - actions: remoteConfigActions - ) - } + content() } + .padding(18) + .background(projectSettingsCardBackground) } private var servicesSection: some View { @@ -1571,21 +1626,6 @@ struct ProjectDetailView: View { .disabled(isLinearAutosyncBusy(for: normalizedTarget)) Menu { - if isLinearAutosyncEnabled(for: normalizedTarget) { - Button("Run this autosync route") { - Task { await runSpecificLinearAutosync(normalizedTarget) } - } - .disabled(linearResolvedStatus?.tokenResolved != true) - Divider() - } - Button("Pull now") { - Task { await syncSpecificLinearProject(normalizedTarget, from: "linear") } - } - .disabled(linearResolvedStatus?.tokenResolved != true) - Button("Push now") { - Task { await syncSpecificLinearProject(normalizedTarget, from: "hack") } - } - .disabled(linearResolvedStatus?.tokenResolved != true) if !isDefault { Button("Make default") { Task { await makeAdditionalLinearProjectDefault(normalizedTarget) } @@ -1729,14 +1769,6 @@ struct ProjectDetailView: View { return linearAccountLabel(profileId: trimmed) } - private var canSyncFromLinear: Bool { - hasAnyLinearProjectRouting && linearResolvedStatus?.tokenResolved == true - } - - private var canSyncToLinear: Bool { - linearResolvedStatus?.tokenResolved == true - } - private var resolvedLinearProjectId: String { let selected = selectedLinearProjectId.trimmingCharacters(in: .whitespacesAndNewlines) if !selected.isEmpty { @@ -1759,23 +1791,6 @@ struct ProjectDetailView: View { return "\(linearAdditionalProjects.count) linked" } - private var ticketSyncSummary: String { - if linearResolvedStatus?.tokenResolved != true { - return "Connect Linear first" - } - if !hasAnyLinearProjectRouting { - return "Pick a Linear project" - } - if linearAdditionalProjects.isEmpty { - if linearBoundProjectName.isEmpty { - return "\(resolvedLinearProjectId) • origin decides authority" - } - return "\(linearBoundProjectName) • origin decides authority" - } - let defaultLabel = linearBoundProjectName.isEmpty ? resolvedLinearProjectId : linearBoundProjectName - return "\(defaultLabel) + \(linearAdditionalProjects.count) linked • origin decides authority" - } - private var remoteConfigMessage: String { if !executionTargetMessage.isEmpty { return executionTargetMessage @@ -1826,59 +1841,6 @@ struct ProjectDetailView: View { ] } - private var linearRoutingContractTone: StatusTone { - if linearResolvedStatus?.tokenResolved != true || !hasAnyLinearProjectRouting { - return .warn - } - return .neutral - } - - private var linearRoutingContractMessage: String { - let routeSummary: String - if let accountName = linearResolvedAccountName, !accountName.isEmpty { - if linearBoundProjectName.isEmpty && linearAdditionalProjects.isEmpty { - routeSummary = "Current route uses \(accountName)." - } else if linearAdditionalProjects.isEmpty { - routeSummary = "Current route uses \(accountName) and \(linearBoundProjectName)." - } else { - let defaultProjectLabel = linearBoundProjectName.isEmpty ? "no default project" : linearBoundProjectName - routeSummary = - "Current route uses \(accountName), default project \(defaultProjectLabel), and \(linearAdditionalProjects.count) linked project\(linearAdditionalProjects.count == 1 ? "" : "s")." - } - } else { - routeSummary = "Linear is not fully routed for this project yet." - } - return "\(routeSummary) Origin decides authority for title, body, status, and project binding. Comments copy append-only. Assignees, labels, and dependencies merge best-effort and should be reviewed when mappings are ambiguous." - } - - private var linearRoutingContractActions: [InlineCalloutAction] { - var actions: [InlineCalloutAction] = [ - InlineCalloutAction(label: "Linear settings", systemImage: "point.3.connected.trianglepath.dotted") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) - } - ] - if project.supportsTickets { - actions.append( - InlineCalloutAction(label: "Open tickets", systemImage: "ticket") { - NotificationCenter.default.post( - name: .hackProjectNavigationRequested, - object: nil, - userInfo: [ - ProjectNavigationRequest.projectIdKey: project.id, - ProjectNavigationRequest.tabKey: ProjectTab.tickets.rawValue, - ProjectNavigationRequest.sidebarKey: ProjectSidebarItem.tickets.rawValue, - ] - ) - } - ) - } - return actions - } - private func githubAccountLogin(profileId: String) -> String? { let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -2543,99 +2505,6 @@ struct ProjectDetailView: View { queueExecutionTargetReload() } - private func syncBoundLinearProject(from direction: String) async { - let ownerMode = direction == "hack" ? "hack" : nil - let result = await model.syncLinearProject( - for: project, - from: direction, - ownerMode: ownerMode, - projectId: nil, - teamId: nil, - limit: nil, - syncLabels: nil - ) - guard let result, result.ok else { - linearProjectMessage = model.errorMessage ?? "Ticket sync failed." - return - } - - if direction == "linear" { - let routedProjects = result.projectIds?.count ?? (linearAdditionalProjects.isEmpty ? 1 : linearAdditionalProjects.count + 1) - linearProjectMessage = - "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from \(routedProjects) routed project\(routedProjects == 1 ? "" : "s") • created \(result.created) • updated \(result.updated). Linear stays authoritative for Linear-origin title, body, status, and project binding." - } else { - linearProjectMessage = - "Pushed \(result.processed) Hack ticket\(result.processed == 1 ? "" : "s") • created \(result.created) • updated \(result.updated). Hack stays authoritative for Hack-origin title, body, status, and project binding." - } - executionTargetMessage = "" - githubProfileMessage = "" - } - - private func runBoundLinearAutosync() async { - let result = await model.runLinearAutosync(for: project) - guard let result, result.ok else { - linearProjectMessage = model.errorMessage ?? "Linear autosync failed." - return - } - - linearProjectMessage = - "Processed \(result.processedDeliveries) pending delivery\(result.processedDeliveries == 1 ? "" : "ies") across \(result.subscribedRoutes) subscribed route\(result.subscribedRoutes == 1 ? "" : "s") • applied \(result.appliedDeliveries) • failed \(result.failedDeliveries) • created \(result.created) • updated \(result.updated)." - executionTargetMessage = "" - githubProfileMessage = "" - } - - private func syncSpecificLinearProject( - _ target: LinearProjectBindingTarget, - from direction: String - ) async { - let normalizedTarget = normalizedLinearRouteTarget(target) - let ownerMode = direction == "hack" ? "hack" : nil - let result = await model.syncLinearProject( - for: project, - from: direction, - profileId: normalizedTarget.profileId, - ownerMode: ownerMode, - projectId: normalizedTarget.projectId, - teamId: normalizedTarget.teamId, - limit: nil, - syncLabels: nil - ) - guard let result, result.ok else { - linearProjectMessage = model.errorMessage ?? "Ticket sync failed." - return - } - - let routeLabel = linearProjectBindingTargetLabel(normalizedTarget) - if direction == "linear" { - linearProjectMessage = - "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from \(routeLabel) • created \(result.created) • updated \(result.updated)." - } else { - linearProjectMessage = - "Pushed \(result.processed) Hack ticket\(result.processed == 1 ? "" : "s") to \(routeLabel) • created \(result.created) • updated \(result.updated)." - } - } - - private func runSpecificLinearAutosync( - _ target: LinearProjectBindingTarget - ) async { - let normalizedTarget = normalizedLinearRouteTarget(target) - let result = await model.runLinearAutosync( - for: project, - profileId: normalizedTarget.profileId, - projectId: normalizedTarget.projectId, - teamId: normalizedTarget.teamId, - limit: nil - ) - guard let result, result.ok else { - linearProjectMessage = model.errorMessage ?? "Linear autosync failed." - return - } - - let routeLabel = linearProjectBindingTargetLabel(normalizedTarget) - linearProjectMessage = - "Processed \(result.processedDeliveries) pending delivery\(result.processedDeliveries == 1 ? "" : "ies") for \(routeLabel) • applied \(result.appliedDeliveries) • failed \(result.failedDeliveries) • created \(result.created) • updated \(result.updated)." - } - private struct LifecycleSummaryCounts { let startupHookCount: Int let shutdownHookCount: Int diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index 66e3606df..f172664ba 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -118,27 +118,62 @@ struct TicketsView: View { } private var header: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center, spacing: 12) { statusFilterMenu originFilterMenu searchField - if !linearRouteStatusLabel.isEmpty { - linearRouteStatusButton - } - if reviewQueueCount > 0 { - reviewQueueButton - } Spacer(minLength: 12) if let activeSyncAction { HStack(spacing: 8) { ProgressView() .controlSize(.small) Text(activeSyncAction.progressLabel) - .font(.mono(.caption)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) } } + if reviewQueueCount > 0 { + reviewQueueButton + } + newTicketButton + } + + HStack(alignment: .center, spacing: 10) { + linearSyncStatusRow + Spacer(minLength: 12) + + Button { + requestBulkSyncConfirmation(.pullLinear) + } label: { + Label("Pull", systemImage: "arrow.down.left") + } + .adaptiveToolbarButtonProminent() + .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) + + Button { + requestBulkSyncConfirmation(.pushHack) + } label: { + Label("Push", systemImage: "arrow.up.right") + } + .adaptiveToolbarButton() + .disabled(!canSyncProjectToLinear || isAnySyncInFlight) + + if hasAnyLinearAutosyncRoutes { + Button { + Task { await runProjectLinearAutosync() } + } label: { + Label("Run autosync", systemImage: "bolt.badge.clock") + } + .adaptiveToolbarButton() + .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) + } + + Button("Routing") { + openProjectRouting() + } + .adaptiveToolbarButton() + Menu { Button("Refresh") { Task { await refreshTickets() } @@ -146,21 +181,6 @@ struct TicketsView: View { Button("Sync ticket ref store") { Task { await syncTickets() } } - Divider() - Button("Pull Linear into tickets") { - requestBulkSyncConfirmation(.pullLinear) - } - .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) - Button("Push Hack tickets to Linear") { - requestBulkSyncConfirmation(.pushHack) - } - .disabled(!canSyncProjectToLinear || isAnySyncInFlight) - if hasAnyLinearAutosyncRoutes { - Button("Run project autosync routes") { - Task { await runProjectLinearAutosync() } - } - .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) - } if let selectedTicket { Divider() Button("Push selected ticket to Linear") { @@ -190,17 +210,13 @@ struct TicketsView: View { openProjectRouting() } } label: { - ticketHeaderBadge( - title: "Sync", - systemImage: "arrow.triangle.branch", - tone: .secondary - ) + Image(systemName: "ellipsis.circle") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) } - .buttonStyle(.plain) - newTicketButton + .buttonStyle(PressableIconButtonStyle()) } - linearSyncPolicyCallout loadNoticeCallout } } @@ -252,25 +268,6 @@ struct TicketsView: View { return remaining.first } - private var linearRouteStatusLabel: String { - if linearRouteStatus?.tokenResolved != true { - return "Linear not connected" - } - if !linearRouteProjectName.isEmpty && linearAdditionalProjects.isEmpty { - return linearRouteProjectName - } - if !linearRouteProjectName.isEmpty { - return "\(linearRouteProjectName) + \(linearAdditionalProjects.count)" - } - if !linearRouteProjectId.isEmpty { - return linearRouteProjectId - } - if !linearRouteProfile.isEmpty { - return "Profile \(linearRouteProfile)" - } - return "" - } - private var canSyncProjectFromLinear: Bool { linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting } @@ -294,18 +291,6 @@ struct TicketsView: View { ) } - @ViewBuilder - private var linearSyncPolicyCallout: some View { - if linearSyncPolicyTone == .warn { - InlineCallout( - tone: linearSyncPolicyTone, - title: "Linear sync policy", - message: linearSyncPolicyMessage, - actions: linearSyncPolicyActions - ) - } - } - @ViewBuilder private var loadNoticeCallout: some View { if let loadNotice { @@ -318,56 +303,80 @@ struct TicketsView: View { } } - private var linearSyncPolicyTone: StatusTone { - if linearRouteStatus?.tokenResolved != true || !hasAnyLinearProjectRouting { - return .warn + private var hasAnyLinearProjectRouting: Bool { + if !linearRouteProjectId.isEmpty { + return true } - return .neutral + return !linearAdditionalProjects.isEmpty } - private var linearSyncPolicyMessage: String { - if linearRouteStatus?.tokenResolved != true { - return "Connect a Linear account to pull issues and refresh linked tickets. Hack or Linear ownership decides which side wins when fields diverge." - } - if !hasAnyLinearProjectRouting { - return "This project needs a Linear route before bulk pull can target the right project. Comments stay append-only, and mergeable fields may still need review." - } - if !linearAdditionalProjects.isEmpty { - return "Bulk pull includes the default route plus \(linearAdditionalProjects.count) linked Linear project\(linearAdditionalProjects.count == 1 ? "" : "s"). Comments stay append-only, and mergeable fields may still need review." + private var hasAnyLinearAutosyncRoutes: Bool { + !linearAutosyncRouteKeys.isEmpty + } + + private var linearSyncStatusRow: some View { + HStack(spacing: 8) { + Image(systemName: linearRouteStatus?.tokenResolved == true ? "point.3.connected.trianglepath.dotted" : "exclamationmark.triangle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(linearRouteStatus?.tokenResolved == true ? Color.secondary : Color.orange) + + Text(linearSyncStatusSummary) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + + if hasAnyLinearAutosyncRoutes { + StatusPill( + text: "\(linearAutosyncRouteKeys.count) autosync", + tone: .good + ) + } + + if reviewQueueCount > 0 { + StatusPill( + text: reviewQueueCount == 1 ? "1 review" : "\(reviewQueueCount) reviews", + tone: .warn + ) + } } - return "Pull Linear when Linear owns the ticket. Push Hack when Hack owns the ticket and you want to publish it outward." + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.06) : Color.white.opacity(0.86)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting + ? Color.black.opacity(colorScheme == .dark ? 0.0 : 0.06) + : Color.orange.opacity(colorScheme == .dark ? 0.32 : 0.22), + lineWidth: 1 + ) + ) + ) } - private var linearSyncPolicyActions: [InlineCalloutAction] { + private var linearSyncStatusSummary: String { if linearRouteStatus?.tokenResolved != true { - return [ - InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { - openLinearSettings() - }, - InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { - openProjectRouting() - }, - ] + return "Connect Linear to route this repo's tickets." } if !hasAnyLinearProjectRouting { - return [ - InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { - openProjectRouting() - } - ] + return "Choose a default Linear project for this repo." } - return [] - } - - private var hasAnyLinearProjectRouting: Bool { - if !linearRouteProjectId.isEmpty { - return true + if linearAdditionalProjects.isEmpty { + let defaultLabel = linearRouteProjectName.isEmpty ? linearRouteProjectId : linearRouteProjectName + return "\(linearProfileDisplayName) · \(defaultLabel)" } - return !linearAdditionalProjects.isEmpty + let defaultLabel = linearRouteProjectName.isEmpty ? linearRouteProjectId : linearRouteProjectName + return "\(linearProfileDisplayName) · \(defaultLabel) + \(linearAdditionalProjects.count)" } - private var hasAnyLinearAutosyncRoutes: Bool { - !linearAutosyncRouteKeys.isEmpty + private var linearProfileDisplayName: String { + if !linearRouteProfile.isEmpty { + return linearRouteProfile + } + return "Inherited profile" } private var allLinearRouteTargets: [LinearProjectBindingTarget] { @@ -482,21 +491,6 @@ struct TicketsView: View { return [] } - private var linearRouteStatusButton: some View { - Button { - openProjectRouting() - } label: { - ticketHeaderBadge( - title: linearRouteStatusLabel, - systemImage: linearRouteStatus?.tokenResolved == true - ? "point.3.connected.trianglepath.dotted" - : "exclamationmark.triangle", - tone: linearRouteStatus?.tokenResolved == true ? .secondary : .orange - ) - } - .buttonStyle(.plain) - } - private var reviewQueueButton: some View { Button { activateReviewQueue() @@ -788,7 +782,7 @@ struct TicketsView: View { .adaptiveToolbarButtonProminent() .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) - Button("Project routing") { + Button("Routing") { openProjectRouting() } .adaptiveToolbarButton() @@ -2630,14 +2624,11 @@ struct TicketsView: View { private func openProjectRouting() { model.selectedItem = .project(project.id) - model.selectedProjectTab = .overview NotificationCenter.default.post( - name: .hackProjectNavigationRequested, + name: .hackProjectRoutingRequested, object: nil, userInfo: [ - ProjectNavigationRequest.projectIdKey: project.id, - ProjectNavigationRequest.tabKey: ProjectTab.overview.rawValue, - ProjectNavigationRequest.sidebarKey: "remoteExecution", + ProjectRoutingRequest.projectIdKey: project.id ] ) } diff --git a/docs/plans/2026-03-06-linear-desktop-ux-cleanup-design.md b/docs/plans/2026-03-06-linear-desktop-ux-cleanup-design.md new file mode 100644 index 000000000..6d64378e4 --- /dev/null +++ b/docs/plans/2026-03-06-linear-desktop-ux-cleanup-design.md @@ -0,0 +1,111 @@ +# Linear Desktop UX Cleanup Design + +## Context + +The current Hack Desktop Linear experience is functional but too loud. The tickets surface carries a large sync policy banner, project routing behaves like an operational dashboard instead of a configuration surface, and the project sidebar visually treats drawer utilities as if they were active navigation states. The result is a UI that feels busier and more confusing than the sync model actually is. + +## Goals + +- Make `Tickets` the primary operational surface for Linear sync and review. +- Convert `Project routing` into a focused configuration sheet. +- Remove oversized guidance chrome and replace it with compact inline status. +- Ensure the project sidebar highlights only the visible pane; drawers remain utilities. +- Tighten typography, spacing, and grouping so project pages feel calm and usable. + +## Non-Goals + +- No changes to sync authority rules, routing semantics, or broker behavior. +- No redesign of global settings in this pass. +- No new sync engine features. + +## Interaction Model + +### Tickets-first operation + +`Tickets` becomes the place where day-to-day sync work happens: +- project-level pull, push, autosync, and review entry points live in the tickets header +- selected-ticket sync actions stay in the detail footer +- review and conflict guidance appears close to the selected ticket rather than in a page-wide banner + +### Routing as setup, not operations + +`Project routing` becomes a small focused sheet opened from `Tickets`. + +The sheet has only three functional groups: +- Account +- Default Linear project +- Additional synced projects + +Autosync remains configurable per route, but bulk operational buttons move out of routing. + +### Sidebar rules + +The project sidebar only reflects the currently visible content pane. Terminal and logs drawers are not navigation destinations and should not inherit active selection treatment when they are closed. + +## UI Changes + +### Tickets + +- Replace the large Linear policy callout with a slim status row near the sync controls. +- The status row summarizes: + - connection state + - routed/default project summary + - autosync enabled count + - review-needed count when non-zero +- Keep operational actions compact and scope-aware: + - `Pull` + - `Push` + - `Run autosync` + - `Review` + - `Routing` +- Keep the split-pane feel, but reduce visual noise in the detail pane: + - warnings only for actual review/conflict state + - calmer default linked-ticket presentation + - fewer metadata pills +- Keep the empty state minimal: `New ticket`, `Pull`, `Routing`. + +### Routing Sheet + +- Present routing as a focused sheet/overlay from `Tickets`. +- Default project is visually primary. +- Additional projects are lightweight rows with remove/default actions. +- Autosync is shown as a secondary toggle on bound routes. +- No pull/push/autosync execution buttons in the routing sheet. +- If no account is connected, collapse to a short explanation plus `Connect Linear`. + +### Project Detail + +- Remove routing-heavy operational chrome from the project page. +- Keep only a compact entry point into routing from the project surface. +- Reduce stacked cards/banners and rely on inline footnotes or one warning callout only when required. + +### Sidebar/Typography/Spacing + +- Only visible panes get active treatment. +- Drawer actions render as utilities, not selected navigation. +- Reduce use of monospace as section-title chrome. +- Use tighter grouping for related controls and larger separation between unrelated sections. +- Avoid nested boxed surfaces when the parent container already provides structure. + +## Data / State Impact + +This pass should reuse existing state where possible: +- current route/account/project status from `TicketsView` +- existing project routing state from `ProjectDetailView` +- existing review counts, authority, and sync state from ticket models + +No new persistence model is required. + +## Risks + +- Moving routing into a sheet can break existing project-page navigation affordances if the open/close state is not wired cleanly. +- Compacting status too aggressively can hide important review state; the replacement row must still surface actionable warnings. +- Sidebar cleanup must preserve keyboard/navigation behavior even as visual selection logic changes. + +## Validation + +- Build the macOS package successfully. +- Verify ticket-level sync actions remain reachable. +- Verify routing sheet still supports account binding, default project selection, additional projects, and autosync toggles. +- Verify sidebar active state only follows visible content panes. +- Perform a live visual sanity check against the running Hack Desktop app if feasible. diff --git a/docs/plans/2026-03-06-linear-desktop-ux-cleanup-plan.md b/docs/plans/2026-03-06-linear-desktop-ux-cleanup-plan.md new file mode 100644 index 000000000..a3c214ff2 --- /dev/null +++ b/docs/plans/2026-03-06-linear-desktop-ux-cleanup-plan.md @@ -0,0 +1,132 @@ +# Linear Desktop UX Cleanup Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make Hack Desktop's Linear flow calmer and more usable by shifting operations into Tickets, shrinking routing into focused configuration, and fixing misleading sidebar active states. + +**Architecture:** Reuse existing routing and sync state, but move the presentation boundary: Tickets becomes the operational split-pane, Project routing becomes a focused sheet opened from Tickets, and the project sidebar becomes strictly pane-selection UI. Keep logic changes narrow and avoid touching the sync engine or broker contract. + +**Tech Stack:** SwiftUI, shared desktop models, HackCLI macOS client, existing Linear routing/sync commands. + +--- + +### Task 1: Capture failing UX expectations in the macOS ticket surface + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift` +- Test/Verify: `apps/macos` Swift package build + +**Step 1: Identify the current policy banner and operational header composition** + +Inspect the current `linearSyncPolicyCallout`, header badges, empty state actions, and ticket detail footer. + +**Step 2: Define the compact replacement surface** + +Specify the exact row contents needed for a compact sync status row and confirm the existing state fields can drive it. + +**Step 3: Build the compact status row and remove the large banner** + +Implement a slim inline sync status row near the Tickets sync controls. + +**Step 4: Verify the package still builds** + +Run: `swift build --package-path apps/macos` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +git commit -m "Refine desktop ticket sync chrome" +``` + +### Task 2: Move project routing to a focused sheet + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift` +- Modify: `apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift` +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` + +**Step 1: Write the failing integration expectation** + +Define the desired interaction: `Tickets` opens a routing sheet that supports account binding, default project, additional projects, and autosync toggles without exposing bulk sync actions. + +**Step 2: Extract or embed the focused routing sheet UI** + +Create the focused routing presentation using existing project routing state and actions. + +**Step 3: Remove operational sync controls from the project routing surface** + +Delete pull/push/autosync execution controls from routing and keep only configuration controls. + +**Step 4: Rebuild the macOS package** + +Run: `swift build --package-path apps/macos` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +git commit -m "Move desktop Linear routing into a focused sheet" +``` + +### Task 3: Fix sidebar active-state behavior and project detail chrome + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift` + +**Step 1: Identify the drawer/visible-pane selection coupling** + +Trace how `selectedSidebarItem`, drawer actions, and navigation requests interact. + +**Step 2: Implement the minimal selection rule change** + +Ensure only visible panes get active treatment and drawer utilities stay visually inactive unless actually open. + +**Step 3: Tighten section spacing and typography in the project surface** + +Reduce stacked callouts and simplify section labels where the view is currently overly dense. + +**Step 4: Rebuild the macOS package** + +Run: `swift build --package-path apps/macos` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +git commit -m "Clean up desktop project sidebar and routing layout" +``` + +### Task 4: Full verification and visual sanity check + +**Files:** +- Verify: repo root gates + +**Step 1: Run repo verification gates** + +Run: +```bash +bun run build +bun run typecheck +bun run test +bun run check +hack ps +swift build --package-path apps/macos +``` +Expected: PASS + +**Step 2: Run a live desktop sanity check** + +Use the running Hack Desktop app or a fresh launch to verify the compact Tickets surface, routing sheet, and sidebar active-state behavior. + +**Step 3: Commit the final integrated cleanup** + +```bash +git add docs/plans/2026-03-06-linear-desktop-ux-cleanup-design.md docs/plans/2026-03-06-linear-desktop-ux-cleanup-plan.md apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +git commit -m "Polish desktop Linear routing and ticket UX" +``` From 04ab89c9784fe556769771934f47095ee3929e8b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 10:47:41 -0500 Subject: [PATCH 08/54] Add Hack account auth and provider UX design docs --- ...ack-account-auth-and-provider-ux-design.md | 356 ++++++++++++++++ ...-hack-account-auth-and-provider-ux-plan.md | 398 ++++++++++++++++++ 2 files changed, 754 insertions(+) create mode 100644 docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md create mode 100644 docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md diff --git a/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md new file mode 100644 index 000000000..5d5515f18 --- /dev/null +++ b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md @@ -0,0 +1,356 @@ +# Hack Account Auth and Provider UX Design + +## Context + +Hack now has real broker-backed remote capabilities: + +- Better Auth session/org/team ownership in `auth-broker` +- broker-owned Linear connections, subscriptions, and deliveries +- desktop and CLI provider connection flows +- growing need for shared remote state beyond local keychain-only auth + +The current user experience is still provider-first rather than account-first. +That creates two problems: + +1. The product does not clearly distinguish `Hack account/session` from `connected provider account`. +2. The desktop/project settings forms still use raw system controls and duplicated labels, which makes the configuration surfaces feel unfinished and harder to use. + +This design introduces a first-class, optional Hack account layer while keeping local-only Hack usage available without sign-in. + +## Goals + +- Keep Hack usable without authentication for local-only workflows. +- Add a first-class Hack account/session model in web, CLI, and macOS app. +- Make broker-owned/shared remote features clearly require Hack sign-in. +- Separate Hack login methods from provider integrations. +- Improve macOS configuration UX so forms are compact, styled, and easier to scan. +- Establish the next design boundary for remote encrypted project/env portability. + +## Non-Goals + +- Do not make local project/runtime/ticket usage require sign-in. +- Do not build a full account web product or admin console. +- Do not implement remote secret portability in this slice. +- Do not collapse provider integration identity into Hack login identity. + +## Product Boundary + +### Local-only features remain unauthenticated + +No Hack account sign-in should be required for: + +- local project/runtime management +- local sessions +- local git/system identity +- local git-backed tickets +- provider tokens already stored locally and used purely locally + +### Shared remote features require Hack auth + +Hack sign-in should be required for: + +- listing and managing broker-owned Linear connections +- broker-owned autosync subscriptions and deliveries +- future shared provider management by org/team +- future remote encrypted project/env storage +- any state that is stored remotely and owned by a Hack user/org/team + +This gives the product a clear rule: + +- local-only = no login required +- shared/remote = login required + +## Identity Model + +There are three different concepts and the UI must keep them separate. + +### 1. Hack account + +A Hack account is the primary identity used by Better Auth. +It has: + +- a user record +- optional active organization +- optional active team +- one or more linked login methods + +### 2. Login methods + +Login methods are how a user signs into Hack itself. +Examples: + +- GitHub social login +- Google social login +- email/password if we keep it enabled + +These should link into the same Hack user when they represent the same person. + +### 3. Provider integrations + +Provider integrations are separate owned resources under the Hack account. +Examples: + +- Linear profiles +- GitHub integration profiles / app installations + +A user may sign into Hack with GitHub and also connect one or more GitHub integration profiles. Those are not the same thing and must not be presented as the same thing. + +## Account Linking Rules + +Hack login methods should link under a strict verified-email policy. + +### Automatic account merge + +Allow automatic linking only when: + +- the provider returns an email +- the provider marks that email as verified +- the normalized email matches an existing Hack user + +### Do not auto-link when + +- the provider email is missing +- the provider email is unverified +- the emails differ + +### Safety rule + +Do not enable permissive same-user linking across mismatched or unverified emails. +This is an auth boundary, not a convenience feature. + +## Auth Providers + +### Current state + +Current broker code already supports: + +- Better Auth with email/password +- GitHub social login in Better Auth +- organization/team plugin + +Current broker code does not yet support: + +- Google social login in Better Auth + +### Design decision + +The auth shell should render whichever Better Auth social providers are configured. + +That means: + +- if only GitHub is configured, show GitHub sign-in +- if Google is added, show Google sign-in too +- do not hardcode a product promise around Google before the broker exposes it + +## Web Surface + +We need a minimal hosted auth shell, not a full web application. + +### Required pages + +#### `/auth` + +Landing page for Hack account sign-in. + +It should show: + +- current signed-in state if present +- available sign-in methods +- short explanation that Hack account unlocks shared remote features + +#### `/auth/account` + +Profile/session page. + +It should show: + +- name +- email +- active org +- active team +- linked login methods +- sign out action + +#### org/team selection UI + +Only if needed. + +If a user belongs to multiple orgs or teams, the account page should allow choosing the active context used by broker-owned resources. + +### Scope constraints + +This web shell is intentionally small. +It is not a dashboard, not a billing panel, and not a management console. + +## CLI Surface + +Add a first-class `hack auth` command group. + +### Commands + +- `hack auth status` +- `hack auth login` +- `hack auth logout` +- `hack auth whoami` + +### Behavior + +#### `hack auth login` + +- opens the auth shell in browser +- completes local session/bootstrap +- stores enough local state to identify the signed-in Hack user/session for broker-backed workflows + +#### `hack auth status` + +Returns: + +- signed-in vs signed-out +- user identity +- active org +- active team +- whether shared remote features are available + +#### `hack auth logout` + +- clears local Hack auth session/bootstrap state +- does not delete local provider profiles by default + +#### `hack auth whoami` + +- compact output for scripts and troubleshooting + +### Broker-required command behavior + +When a command requires broker-owned access and the user is not signed in: + +- fail clearly +- explain why auth is required +- tell the user the next step: `hack auth login` +- where possible, resume the original flow after login + +## macOS Surface + +### Settings structure + +Add a top-level `Hack account` section above provider settings. + +It should show: + +- signed-in state +- display name/email +- active org +- active team +- linked login methods +- actions: + - `Sign in` + - `Manage account` + - `Sign out` + +### Provider settings + +Keep provider settings, but subordinate them to Hack account state. + +#### Linear integration + +Should say clearly: + +- connected under current Hack account +- or local-only/provider token exists but shared broker features require Hack sign-in + +#### GitHub integration + +Should clearly separate: + +- `Sign in to Hack with GitHub` +- `Connect GitHub integration` + +### Project/settings UX cleanup + +Project settings should stop using duplicated labels and raw row-based menus. + +#### Form style + +Use stacked field groups: + +- small label above control +- styled select/input below +- helper text only when needed + +Apply the same field style to: + +- project settings +- Linear routing sheet +- assignee mapping form + +#### Routing and operations split + +- project settings remain configuration-only +- operational sync actions stay in `Tickets` + +## Provider Flow Model + +Provider flows should work differently depending on Hack auth state. + +### Signed in to Hack + +- provider connect flow runs under the Hack session +- broker-owned resources are visible and manageable +- connected provider accounts are clearly attributed to the signed-in Hack account/org/team + +### Not signed in to Hack + +- local-only flows can still proceed when safe +- broker-owned/shared features should prompt sign-in first +- desktop UI should explain the distinction instead of silently failing or looking disconnected + +## Broker and Session Model + +The broker already has the right ownership enforcement direction: + +- Better Auth sessions own broker-managed resources +- Linear routes are access-controlled by user/org/team ownership +- CLI already persists a management token for protected broker routes after provider OAuth + +The missing product layer is session visibility and a clean login path in CLI/macOS. + +## Remote Secret Portability Direction + +This is not part of the immediate implementation, but the auth model should leave room for it. + +### Future direction + +We should explore remote encrypted project/env storage with: + +- per-project encryption context or project keys +- remote portability across macOS/Linux/nodes +- less dependence on single-machine keychain custody +- clear ownership under Hack account/org/team + +### Constraint + +Do not fold this into the immediate auth/UX slice. +Track it as follow-on architecture work. + +## Implementation Priorities + +### Immediate + +1. First-class Hack account/session surface in web/CLI/macOS +2. Project settings and provider UX cleanup in macOS +3. Better command gating and messaging for auth-required broker features +4. Verified-email login method linking rules + +### Follow-on + +1. Add Google social login to Better Auth if env and callback setup exist +2. Shared linked-login management UI +3. Remote encrypted project/env storage and portability design + +## Success Criteria + +- Users can understand the difference between Hack sign-in and provider connections. +- Local-only Hack workflows remain available without auth. +- Broker-backed features fail clearly and guide the user into sign-in. +- macOS settings/project forms feel compact, styled, and consistent. +- GitHub and Google can both act as Hack login methods without fragmenting a user into duplicate accounts when verified email matches. diff --git a/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md new file mode 100644 index 000000000..afbf634e4 --- /dev/null +++ b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md @@ -0,0 +1,398 @@ +# Hack Account Auth and Provider UX Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an optional first-class Hack account/session layer across auth-broker, CLI, and macOS, while cleaning up provider/project UX and keeping local-only Hack usage unauthenticated. + +**Architecture:** Extend `services/auth-broker` from raw Better Auth API transport into a minimal auth-shell-backed identity surface, then add explicit `hack auth` CLI commands and a new macOS Hack-account settings card above provider integrations. Keep provider integrations and Hack login methods separate, use verified-email-only account linking, and improve desktop project/provider forms without moving operational ticket sync out of `Tickets`. + +**Tech Stack:** Bun, Elysia, Better Auth, Drizzle/Neon, TypeScript, SwiftUI, Hack CLI ticket/config/auth flows. + +--- + +### Task 1: Document and tighten broker auth requirements + +**Files:** +- Modify: `services/auth-broker/README.md` +- Modify: `docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write the failing test** + +Add a broker test that asserts the provider catalog and status routes distinguish Better Auth session mode from provider integrations clearly enough for desktop/CLI consumers. + +**Step 2: Run test to verify it fails** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: FAIL once new auth-surface assertions are added. + +**Step 3: Write minimal implementation** + +Update broker metadata/docs so there is one explicit source of truth for: +- Better Auth session base path +- optional-login local-only policy +- protected broker-owned routes +- provider connection routes + +**Step 4: Run test to verify it passes** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/README.md services/auth-broker/tests/index.test.ts docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md +git commit -m "docs: clarify broker auth boundaries" +``` + +### Task 2: Add Better Auth provider-driven auth shell routing + +**Files:** +- Create: `services/auth-broker/src/modules/auth-shell/plugin.ts` +- Create: `services/auth-broker/src/modules/auth-shell/service.ts` +- Modify: `services/auth-broker/src/app.ts` +- Modify: `services/auth-broker/src/better-auth.ts` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write the failing test** + +Add broker tests for: +- `GET /auth` +- `GET /auth/account` +- provider list rendering from configured Better Auth social providers +- signed-out vs signed-in account page behavior + +**Step 2: Run test to verify it fails** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: FAIL with missing route assertions. + +**Step 3: Write minimal implementation** + +Add a small auth-shell plugin that: +- renders a minimal sign-in page +- renders a minimal account/session page +- reads Better Auth session state from request headers/cookies +- lists configured social providers without hardcoding Google + +**Step 4: Run test to verify it passes** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/src/modules/auth-shell/plugin.ts services/auth-broker/src/modules/auth-shell/service.ts services/auth-broker/src/app.ts services/auth-broker/src/better-auth.ts services/auth-broker/tests/index.test.ts +git commit -m "feat: add minimal auth shell to broker" +``` + +### Task 3: Add verified-email account linking policy + +**Files:** +- Modify: `services/auth-broker/src/better-auth.ts` +- Modify: `services/auth-broker/src/better-auth-link.ts` +- Test: `services/auth-broker/tests/better-auth-link.test.ts` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write the failing test** + +Add tests for: +- verified GitHub email linking to existing Better Auth user +- refusal to auto-link on missing email +- refusal to auto-link on unverified/mismatched email +- provider-driven linking configuration shape in Better Auth runtime + +**Step 2: Run test to verify it fails** + +Run: `bun test --cwd services/auth-broker tests/better-auth-link.test.ts tests/index.test.ts` +Expected: FAIL after adding new expectations. + +**Step 3: Write minimal implementation** + +Configure Better Auth account linking with strict verified-email behavior and keep provider-link resolution code aligned with that rule. + +**Step 4: Run test to verify it passes** + +Run: `bun test --cwd services/auth-broker tests/better-auth-link.test.ts tests/index.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/src/better-auth.ts services/auth-broker/src/better-auth-link.ts services/auth-broker/tests/better-auth-link.test.ts services/auth-broker/tests/index.test.ts +git commit -m "feat: tighten auth account linking policy" +``` + +### Task 4: Add CLI `hack auth` command surface + +**Files:** +- Modify: `src/commands/*.ts` (exact auth command registration files after inspection) +- Modify: `src/control-plane/sdk/config.ts` if persistent auth bootstrap state is needed +- Test: `tests/*auth*.test.ts` (add new focused auth command tests) +- Docs: `docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md` + +**Step 1: Write the failing test** + +Add CLI tests for: +- `hack auth status` +- `hack auth login` +- `hack auth logout` +- `hack auth whoami` +- auth-required broker commands returning actionable guidance + +**Step 2: Run test to verify it fails** + +Run: `bun test tests/*auth*.test.ts` +Expected: FAIL because the command group does not exist yet. + +**Step 3: Write minimal implementation** + +Implement the `hack auth` command group with browser-open login flow, session status output, sign-out, and compact whoami output. + +**Step 4: Run test to verify it passes** + +Run: `bun test tests/*auth*.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src tests +git commit -m "feat: add hack auth command group" +``` + +### Task 5: Make broker-protected provider commands guide sign-in + +**Files:** +- Modify: `src/control-plane/extensions/linear/commands.ts` +- Modify: `src/control-plane/extensions/github/commands.ts` if broker-owned GitHub management is added in the same flow +- Test: `tests/linear-commands.test.ts` +- Test: `tests/github-commands.test.ts` + +**Step 1: Write the failing test** + +Add tests for unauthenticated broker-protected operations returning explicit auth guidance instead of opaque transport errors. + +**Step 2: Run test to verify it fails** + +Run: `bun test tests/linear-commands.test.ts tests/github-commands.test.ts` +Expected: FAIL after new expectations are added. + +**Step 3: Write minimal implementation** + +Map `better_auth_session_required` and related broker responses to user guidance such as: +- why auth is required +- `hack auth login` +- whether local-only fallback is still available + +**Step 4: Run test to verify it passes** + +Run: `bun test tests/linear-commands.test.ts tests/github-commands.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/control-plane/extensions tests +git commit -m "feat: improve auth-required broker command guidance" +``` + +### Task 6: Add macOS Hack account card above provider integrations + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift` +- Modify: `apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift` +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` +- Test: `apps/macos/Packages/...` focused model/view tests as needed + +**Step 1: Write the failing test** + +Add model tests for a Hack account state object that includes: +- signed-in flag +- user identity +- active org +- active team +- available remote features + +**Step 2: Run test to verify it fails** + +Run: `swift test --package-path apps/macos --filter HackAccount` +Expected: FAIL because the shared model and client methods do not exist yet. + +**Step 3: Write minimal implementation** + +Add a new top-level Hack account settings card and wire it to CLI/broker auth status methods. + +**Step 4: Run test to verify it passes** + +Run: `swift test --package-path apps/macos --filter HackAccount` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/macos +git commit -m "feat: add Hack account settings surface" +``` + +### Task 7: Clean up project/provider form UX in macOS + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` +- Modify: shared local view helpers if needed +- Test: `swift build --package-path apps/macos` + +**Step 1: Write the failing test** + +Where practical, add view-model or helper coverage for field group rendering decisions. If no meaningful unit test exists, record the visual acceptance targets in the plan comments and treat build + manual validation as the first executable check. + +**Step 2: Run test/build to verify current gap** + +Run: `swift build --package-path apps/macos` +Expected: PASS, but visual acceptance still fails against the design because forms are duplicated/unstyled. + +**Step 3: Write minimal implementation** + +Refactor project/provider forms to use: +- stacked field groups +- dedicated visible labels +- cleaner menu/input styling +- helper copy below controls +- no duplicate label text in the same row + +**Step 4: Run validation** + +Run: +- `swift build --package-path apps/macos` +- manual validation in the running desktop app +Expected: build PASS and visual acceptance improved. + +**Step 5: Commit** + +```bash +git add apps/macos +git commit -m "feat: polish desktop auth and settings forms" +``` + +### Task 8: Add Google social login support if env is present + +**Files:** +- Modify: `services/auth-broker/src/better-auth.ts` +- Modify: `services/auth-broker/README.md` +- Test: `services/auth-broker/tests/index.test.ts` +- Test: `services/auth-broker/tests/config.test.ts` + +**Step 1: Write the failing test** + +Add tests that assert Google is exposed only when the corresponding env is configured. + +**Step 2: Run test to verify it fails** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts tests/config.test.ts` +Expected: FAIL after new expectations are added. + +**Step 3: Write minimal implementation** + +Add Google Better Auth provider wiring behind env-driven configuration. + +**Step 4: Run test to verify it passes** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts tests/config.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/src/better-auth.ts services/auth-broker/README.md services/auth-broker/tests/index.test.ts services/auth-broker/tests/config.test.ts +git commit -m "feat: add optional Google auth provider" +``` + +### Task 9: Update docs and operational guidance + +**Files:** +- Modify: `services/auth-broker/README.md` +- Modify: `docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md` +- Create/Modify: auth usage docs discovered during implementation + +**Step 1: Write the failing doc check** + +List the doc gaps explicitly in the PR/task notes before editing: +- login vs provider integration +- local-only vs remote/shared auth requirements +- provider-driven auth shell behavior +- Google optionality + +**Step 2: Update docs minimally** + +Document: +- Hack account/session model +- broker-required feature guidance +- provider integration ownership model +- sign-in methods + +**Step 3: Run docs-adjacent verification** + +Run targeted tests/builds already introduced for auth broker, CLI, and macOS. +Expected: PASS + +**Step 4: Commit** + +```bash +git add services/auth-broker/README.md docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md +git commit -m "docs: add Hack account auth guidance" +``` + +### Task 10: Full verification gate + +**Files:** +- No new files + +**Step 1: Run required repo gates** + +Run: + +```bash +bun run build +bun run typecheck +bun run test +bun run check +hack ps +swift build --package-path apps/macos +``` + +Expected: PASS + +**Step 2: Run focused runtime checks** + +Run: + +```bash +curl -sS https://auth.hack.broker/health | jq . +curl -sS https://auth.hack.broker/v1/auth/better-auth/status | jq . +curl -sS https://auth.hack.broker/v1/auth/providers | jq . +``` + +Expected: +- health returns `ok: true` +- Better Auth status returns `enabled: true` +- provider catalog includes `better-auth` and configured social/provider entries + +**Step 3: Manual validation** + +Validate in Hack Desktop: +- Hack account card renders correctly +- signed-out state explains local-only vs shared features +- sign-in flow opens browser +- provider settings remain usable +- project settings fields are visually compact and no longer duplicate labels + +**Step 4: Commit final integration pass** + +```bash +git add . +git commit -m "feat: add Hack account auth surface and provider UX cleanup" +``` From e9dec4ffcd0fa439fbc7db9a9076baf6d2df5b75 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 11:08:25 -0500 Subject: [PATCH 09/54] Plan remote encrypted env portability next slice --- ...ack-account-auth-and-provider-ux-design.md | 4 + ...-encrypted-project-env-portability-plan.md | 399 ++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md diff --git a/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md index 5d5515f18..0acbfde3c 100644 --- a/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md +++ b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md @@ -332,6 +332,10 @@ We should explore remote encrypted project/env storage with: Do not fold this into the immediate auth/UX slice. Track it as follow-on architecture work. +### Follow-on plan + +- `docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md` + ## Implementation Priorities ### Immediate diff --git a/docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md b/docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md new file mode 100644 index 000000000..1e08bfe47 --- /dev/null +++ b/docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md @@ -0,0 +1,399 @@ +# Remote Encrypted Project Env Portability Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an opt-in, Hack-account-owned remote encrypted project env bundle flow that makes project envs portable across machines without changing the current local-only env workflow. + +**Architecture:** Keep `.hack/.env` and local secret storage as the default path. Add a broker-owned remote bundle registry that stores ciphertext, envelope metadata, ownership scope, and audit/version metadata, while leaving plaintext decrypt/apply in authenticated clients for the first implementation slice. Defer automatic remote-node hydration, multi-recipient sharing, and recovery/rotation UX to follow-on phases. + +**Tech Stack:** Bun, Elysia auth-broker, Better Auth user/org/team ownership, Drizzle/Postgres broker tables, Hack CLI env commands, macOS Hack Desktop follow-on UX. + +--- + +## Scope Boundary + +### In scope for the next implementation slice + +- opt-in remote encrypted env bundles per Hack project +- ownership under Hack user/org/team scope +- ciphertext + metadata storage in `auth-broker` +- CLI publish, list, fetch, and apply flows +- explicit project binding between local Hack project and remote env bundle +- audit-friendly version metadata and last-writer identity + +### Explicitly out of scope for the current implementation pass + +- any implementation work in the current provider/auth slice +- automatic remote-node hydration +- automatic broker-side plaintext decryption and fan-out +- secrets sharing UX between multiple humans or teams +- key recovery, emergency escrow, or full rotation UX +- provider-token portability or keychain migration + +## Design Summary + +### Product rule + +- local-only env stays local by default +- remote portability is opt-in per project +- remote env state is owned by a Hack account scope, not by a provider profile +- broker stores encrypted payloads and metadata, not editable plaintext + +### Ownership model + +Every remote env bundle must be owned by exactly one Hack scope: + +- user-owned +- organization-owned +- team-owned + +The ownership scope must follow the active Better Auth context already used for Linear connections and subscriptions. A local project may bind to one default remote env bundle and may later support explicit additional named bundles, but the next slice should ship only a single default bundle binding per project. + +### Storage model + +Persist one logical bundle per project binding, with append-only versions. + +Recommended fields: + +- `id` +- `owner_type` and `owner_id` +- `project_binding_key` +- `project_name` +- `project_path_hint` nullable +- `environment_name` default `default` +- `ciphertext_blob` +- `ciphertext_digest` +- `encryption_scheme_version` +- `wrapped_data_key` +- `data_key_wrap_method` +- `created_by_user_id` +- `created_at` +- `supersedes_version_id` nullable +- `status` active | superseded | revoked + +The broker should treat versions as immutable records. “Updating” a portable env means writing a new version row and marking the older one superseded. + +### Encryption model + +Use envelope encryption. + +1. Client generates a random data-encryption key per bundle version. +2. Client encrypts the normalized env payload locally. +3. Client sends ciphertext plus a wrapped copy of the data key. +4. Broker stores ciphertext, wrapped key, and metadata. + +For the first slice, choose one wrap path and keep it simple: + +- preferred: broker-managed account-scope wrapping key material, exposed only through authenticated broker routes +- acceptable fallback: locally derived wrap key anchored to existing Hack secret storage plus explicit export/import support + +The critical design constraint is that the broker remains the durable registry for the encrypted payload and ownership metadata. The first slice should not require a single machine-local keychain to remain the only recovery path. + +### Apply model + +The first implementation slice should use client-mediated apply: + +- authenticated client fetches the encrypted bundle +- authenticated client unwraps or requests unwrap +- client writes the resolved env material to the local project env destination +- local runtime reload remains an explicit operator action + +Do not add background env sync to nodes in this slice. + +### Project binding model + +Each local Hack project needs a stable portable-env binding record. + +Required binding fields: + +- `hack_project_id` +- `hack_project_name` +- `owner_scope` +- `remote_env_bundle_id` or `project_binding_key` +- `last_applied_version_id` +- `last_applied_at` + +Binding should be set explicitly, not inferred from directory names alone. + +### Conflict model + +Remote env portability should not attempt field-level merges. + +Rules: + +- payload versions are immutable snapshots +- publish creates a new remote version +- apply pulls a specific remote version into the local project +- if local material has changed since the last apply/publish checkpoint, warn and require confirmation + +This keeps the first slice operationally predictable and avoids partial secret merges. + +### Audit model + +The registry should be auditable from day one. + +Track: + +- owner scope +- actor user id +- version lineage +- ciphertext digest +- created/appplied timestamps +- client host label or device label when available + +## Trust Boundaries + +### Local machine + +Trusted to: + +- read plaintext env material +- create normalized payloads +- decrypt/apply payloads +- cache temporary plaintext during an explicit operator action + +### Auth broker + +Trusted to: + +- enforce ownership and authorization +- store encrypted payloads and version metadata +- issue short-lived access to wrapped material +- record audit history + +Broker should not become a general plaintext env editor in the first slice. + +### Remote nodes + +Not trusted in the first slice with persistent remote-secret authority. + +Remote-node hydration is a follow-on phase once ownership, audit, and revoke semantics are proven with manual client-mediated flows. + +## Implementation Phases + +### Phase 0: design and ownership contract + +Completed by this plan ticket. + +Deliverables: + +- ownership model +- storage model +- encryption boundary +- phased rollout plan + +### Phase 1: default remote bundle registry and CLI flow + +This is the next actionable implementation slice. + +Deliverables: + +- broker schema for remote env bundles and project bindings +- broker routes to create/list/get/supersede bundle versions +- CLI commands to publish/fetch/apply/list bindings +- explicit auth gating through Better Auth ownership +- local project command to bind/unbind a default remote env bundle + +### Phase 2: remote node hydration + +Follow-on. + +Deliverables: + +- short-lived hydration grants +- explicit node-targeted apply flow +- audit trail for which node received which version +- revoke/expiry semantics + +### Phase 3: rotation, recovery, and sharing UX + +Follow-on. + +Deliverables: + +- key rotation flow +- recovery/re-wrap flow +- multi-user/team collaboration UX +- macOS account/project settings UI for bundle state + +## Task Plan For Phase 1 + +### Task 1: Add broker schema for portable env bundles + +**Files:** +- Modify: `packages/db/src/schema/core.ts` +- Modify: `services/auth-broker/src/db/schema.ts` +- Create: `services/auth-broker/src/modules/project-envs/service.ts` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write failing broker storage tests** + +Cover: + +- owner-scoped bundle create/list +- immutable version supersede behavior +- project binding read/write +- cross-owner access rejection + +**Step 2: Run focused broker tests and confirm failure** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: missing schema/service failures for portable env routes or storage. + +**Step 3: Implement schema and service storage** + +Add bundle version rows plus a small binding table or equivalent binding fields. + +**Step 4: Re-run focused broker tests** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: PASS for new portable env storage coverage. + +**Step 5: Commit** + +```bash +git add packages/db/src/schema/core.ts services/auth-broker/src/db/schema.ts services/auth-broker/src/modules/project-envs/service.ts services/auth-broker/tests/index.test.ts +git commit -m "Add project env bundle storage" +``` + +### Task 2: Add broker routes with Better Auth ownership enforcement + +**Files:** +- Create: `services/auth-broker/src/modules/project-envs/plugin.ts` +- Modify: `services/auth-broker/src/app.ts` +- Modify: `services/auth-broker/src/modules/better-auth/session.ts` +- Test: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write failing route tests** + +Cover: + +- signed-out rejection +- user/org/team-owned access +- management-token access for the bound profile/project context +- immutable version creation and binding updates + +**Step 2: Run focused broker tests and confirm failure** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: route-not-found or auth failures. + +**Step 3: Implement minimal broker routes** + +Routes should support: + +- list bundles +- publish new version +- get version metadata +- fetch encrypted payload +- bind/unbind default project bundle + +**Step 4: Re-run focused broker tests** + +Run: `bun test --cwd services/auth-broker tests/index.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add services/auth-broker/src/modules/project-envs/plugin.ts services/auth-broker/src/app.ts services/auth-broker/src/modules/better-auth/session.ts services/auth-broker/tests/index.test.ts +git commit -m "Add broker-owned project env routes" +``` + +### Task 3: Add CLI publish/fetch/apply commands + +**Files:** +- Modify: `src/commands/env.ts` +- Modify: `src/mcp/server.ts` +- Test: `tests/env-backend-command.test.ts` +- Test: `tests/mcp.test.ts` + +**Step 1: Write failing CLI tests** + +Cover: + +- `hack env remote publish` +- `hack env remote pull` +- `hack env remote apply` +- `hack env remote bind` +- MCP forwarding for bind/list/apply flows + +**Step 2: Run focused CLI tests and confirm failure** + +Run: `bun test tests/env-backend-command.test.ts tests/mcp.test.ts` +Expected: unknown command or missing route failures. + +**Step 3: Implement the minimal CLI surface** + +Prefer explicit, operator-driven verbs. Avoid hidden sync. + +**Step 4: Re-run focused CLI tests** + +Run: `bun test tests/env-backend-command.test.ts tests/mcp.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/commands/env.ts src/mcp/server.ts tests/env-backend-command.test.ts tests/mcp.test.ts +git commit -m "Add remote project env CLI commands" +``` + +### Task 4: Add project binding visibility in desktop follow-on UX + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` +- Modify: `apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift` +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` +- Test: `apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/` + +**Step 1: Write failing model/UI tests where practical** + +Cover: + +- current binding summary +- last applied version metadata +- auth-required state +- explicit publish/apply actions with confirmation + +**Step 2: Run focused macOS tests and confirm failure** + +Run: `swift test --package-path apps/macos` +Expected: missing model or command wiring coverage. + +**Step 3: Implement a minimal visibility-first UI** + +Do not add inline plaintext editing. Show binding state and explicit operator actions only. + +**Step 4: Re-run focused macOS tests/build** + +Run: `swift test --package-path apps/macos && swift build --package-path apps/macos` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests +git commit -m "Show portable env binding state in desktop" +``` + +## Open Questions + +- Should Phase 1 use a broker-managed wrapping key or a client-exported recovery key package? +- Do we want exactly one portable bundle per project at first, or one default plus named bundles like `staging` and `prod`? +- Should bundle ownership be project-bound only, or can an org bind the same bundle to multiple projects intentionally? +- How should local dirty-state detection work before publish/apply: digest of normalized env payload, file mtime, or both? +- What is the shortest acceptable audit retention window? + +## Recommended Execution Order + +1. Ship Phase 1 CLI + broker path first. +2. Keep remote-node hydration out until ownership/audit semantics are proven. +3. Add desktop UX only after the command and route contract are stable. +4. File follow-on tickets for hydration and recovery instead of stretching Phase 1. + +## Links + +- `docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md` +- `T-00194` From 6b50d7dfd8db293f26a5d20d38949619a9b8069b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 11:10:19 -0500 Subject: [PATCH 10/54] Add Better Auth session shell routes --- services/auth-broker/.env.example | 4 + services/auth-broker/README.md | 72 +- services/auth-broker/src/app.ts | 23 + services/auth-broker/src/better-auth.ts | 60 +- services/auth-broker/src/flow-store.ts | 69 +- .../src/modules/better-auth/shell-plugin.ts | 1118 +++++++++++++++++ .../src/modules/providers/plugin.ts | 10 + services/auth-broker/src/types.ts | 6 +- services/auth-broker/tests/index.test.ts | 234 ++++ .../auth-broker/tests/session-auth.test.ts | 224 ++++ 10 files changed, 1769 insertions(+), 51 deletions(-) create mode 100644 services/auth-broker/src/modules/better-auth/shell-plugin.ts create mode 100644 services/auth-broker/tests/session-auth.test.ts diff --git a/services/auth-broker/.env.example b/services/auth-broker/.env.example index 781ba4c7b..6a8d78a6a 100644 --- a/services/auth-broker/.env.example +++ b/services/auth-broker/.env.example @@ -39,6 +39,10 @@ FLOW_STORE_PATH=.data/oauth-flows.json DATABASE_URL= NEON_PROJECT_ID= BETTER_AUTH_SECRET= +BETTER_AUTH_URL=https://auth.hack.broker +BETTER_AUTH_TRUSTED_ORIGINS=http://127.0.0.1:8080 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= # Optional: auto-provision Better Auth users from GitHub email during callback. BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS=false # Optional: auto-provision Better Auth users from Linear email during callback. diff --git a/services/auth-broker/README.md b/services/auth-broker/README.md index d4cda5590..6416209ea 100644 --- a/services/auth-broker/README.md +++ b/services/auth-broker/README.md @@ -8,6 +8,8 @@ Minimal Bun + Elysia auth broker for browser-based provider callbacks. 2. Handle browser callback redirects. 3. Expose short-lived polling endpoints so local clients can claim tokens. 4. Host Better Auth endpoints (`/api/auth/*`) for first-party session/org/team auth. +5. Expose a minimal Hack auth shell (`/auth`, `/auth/account`) and a + management-token bootstrap flow for non-browser clients. This keeps provider auth UX one-click while storing long-term credentials on the client host. @@ -29,8 +31,9 @@ stay decoupled: 2. `src/modules/core/plugin.ts` 3. `src/modules/providers/plugin.ts` 4. `src/modules/better-auth/plugin.ts` -5. `src/modules/github-oauth/plugin.ts` -6. `src/modules/linear-agent/plugin.ts` +5. `src/modules/better-auth/shell-plugin.ts` +6. `src/modules/github-oauth/plugin.ts` +7. `src/modules/linear-agent/plugin.ts` GitHub OAuth routes are internally composed from focused plugins so callback, provider, and polling route plumbing can evolve independently: @@ -69,7 +72,12 @@ return `405` for non-`GET` requests. 8. `POST /linear/webhooks` (Linear agent + webhook ingest) 9. `POST /v1/integrations/linear/webhook` (legacy alias) 10. `GET /v1/auth/better-auth/status` -11. `ALL /api/auth/*` (proxied to Better Auth handler) +11. `GET /v1/auth/session/start` +12. `GET /v1/auth/session/flows/:flowId` +13. `GET /v1/auth/me` +14. `GET /auth` +15. `GET /auth/account` +16. `ALL /api/auth/*` (proxied to Better Auth handler) When `requireInstallation=1` is used, flow polling can defer token claim until an installation is visible for the flow, enabling one-pass authorize+install UX. @@ -77,6 +85,34 @@ an installation is visible for the flow, enabling one-pass authorize+install UX. Callback success pages now include an `Open Hack app` deep link (`hack://...`) so desktop users can return focus to the app immediately after browser auth. +## Hack auth session flow + +The broker now exposes a lightweight Better Auth bootstrap flow that mirrors the +existing OAuth start/poll/claim pattern used by GitHub and Linear: + +1. `GET /v1/auth/session/start` +2. Open the returned `authorizeUrl` in a browser +3. Browser completes Better Auth sign-in on `/auth` and `/auth/account` +4. Local client polls `GET /v1/auth/session/flows/:flowId` +5. Local client claims `managementToken` with `?claim=1` + +The claimed token is a signed broker management token. It can be stored by the +CLI/macOS app and sent as `Authorization: Bearer ` to protected broker +routes when a browser cookie session is not present. + +`GET /v1/auth/me` resolves either: + +1. the current Better Auth browser session, or +2. a valid broker management token + +The Better Auth shell surfaces provider-driven sign-in and account-link actions +from the providers configured in broker env. The runtime also enables strict +account-linking groundwork through Better Auth's own linking policy: + +1. verified provider email required for implicit linking +2. different provider emails are not allowed by default +3. no trusted providers are pre-whitelisted + ## Environment 1. `GITHUB_CLIENT_ID` (required) @@ -96,20 +132,22 @@ so desktop users can return focus to the app immediately after browser auth. 15. `BETTER_AUTH_SECRET` (required to enable Better Auth runtime) 16. `BETTER_AUTH_URL` (optional base URL override) 17. `BETTER_AUTH_TRUSTED_ORIGINS` (optional comma-separated origins) -18. `BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from GitHub email if no match exists) -19. `BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from Linear email if no match exists) -20. `HACK_LINEAR_CLIENT_ID` (recommended Linear OAuth client id) -21. `HACK_LINEAR_SECRET` (optional Linear OAuth client secret; PKCE can run without it) -22. `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional app token for agent/system automations) -23. `HACK_LINEAR_WEBHOOK_SECRET` (recommended Linear webhook signing secret) -24. `HACK_LINEAR_SCOPES` (optional; default: `read,write,app:mentionable,app:assignable`) -25. `HACK_LINEAR_OAUTH_ACTOR` (optional; default: `app` for Linear agent/app installs) -26. `HACK_LINEAR_REDIRECT_URI` (default: `${AUTH_BROKER_PUBLIC_BASE_URL}/linear/callback`) -27. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) -28. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) -29. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) -30. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) -31. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) +18. `GOOGLE_CLIENT_ID` (optional; enables Google social sign-in in Better Auth shell) +19. `GOOGLE_CLIENT_SECRET` (optional; enables Google social sign-in in Better Auth shell) +20. `BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from GitHub email if no match exists) +21. `BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from Linear email if no match exists) +22. `HACK_LINEAR_CLIENT_ID` (recommended Linear OAuth client id) +23. `HACK_LINEAR_SECRET` (optional Linear OAuth client secret; PKCE can run without it) +24. `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional app token for agent/system automations) +25. `HACK_LINEAR_WEBHOOK_SECRET` (recommended Linear webhook signing secret) +26. `HACK_LINEAR_SCOPES` (optional; default: `read,write,app:mentionable,app:assignable`) +27. `HACK_LINEAR_OAUTH_ACTOR` (optional; default: `app` for Linear agent/app installs) +28. `HACK_LINEAR_REDIRECT_URI` (default: `${AUTH_BROKER_PUBLIC_BASE_URL}/linear/callback`) +29. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) +30. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) +31. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) +32. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) +33. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) When running broker from repo root (`bun run auth:dev`), Linear env aliases also fallback to root `.env.local` / `.env` if process env values are unset. diff --git a/services/auth-broker/src/app.ts b/services/auth-broker/src/app.ts index 2976b617c..b2292d049 100644 --- a/services/auth-broker/src/app.ts +++ b/services/auth-broker/src/app.ts @@ -7,6 +7,7 @@ import { import type { BrokerConfig } from "./config.ts"; import { FlowStore } from "./flow-store.ts"; import { createBetterAuthPlugin } from "./modules/better-auth/plugin.ts"; +import { createBetterAuthShellPlugin } from "./modules/better-auth/shell-plugin.ts"; import { createCoreRoutesPlugin } from "./modules/core/plugin.ts"; import { createGitHubOAuthPlugin } from "./modules/github-oauth/plugin.ts"; import { createLinearAgentPlugin } from "./modules/linear-agent/plugin.ts"; @@ -97,6 +98,13 @@ export function createAuthBrokerApp({ runtime: betterAuthRuntime, }) ) + .use( + createBetterAuthShellPlugin({ + config, + runtime: betterAuthRuntime, + flowStore, + }) + ) .use( createGitHubOAuthPlugin({ config, @@ -206,6 +214,18 @@ function isReadOnlyRoutePath(input: { readonly path: string }): boolean { if (input.path === "/v1/auth/better-auth/status") { return true; } + if (input.path === "/auth") { + return true; + } + if (input.path === "/auth/account") { + return true; + } + if (input.path === "/v1/auth/session/start") { + return true; + } + if (input.path === "/v1/auth/me") { + return true; + } if (input.path === "/linear/callback") { return true; } @@ -233,6 +253,9 @@ function isReadOnlyRoutePath(input: { readonly path: string }): boolean { if (input.path.startsWith("/v1/auth/github/")) { return true; } + if (input.path.startsWith("/v1/auth/session/flows/")) { + return true; + } if (input.path.startsWith("/v1/auth/linear/")) { return true; } diff --git a/services/auth-broker/src/better-auth.ts b/services/auth-broker/src/better-auth.ts index 1865a8457..0449e382a 100644 --- a/services/auth-broker/src/better-auth.ts +++ b/services/auth-broker/src/better-auth.ts @@ -8,11 +8,24 @@ import { z } from "zod"; type BetterAuthInstance = ReturnType; type AuthBrokerDbClient = ReturnType; +export type BetterAuthSocialProvider = { + readonly id: string; + readonly label: string; +}; + +export type BetterAuthAccountLinkingPolicy = { + readonly requireVerifiedEmail: boolean; + readonly allowDifferentEmails: boolean; + readonly trustedProviders: readonly string[]; +}; + type BetterAuthRuntime = { readonly enabled: boolean; readonly reason?: string; readonly auth?: BetterAuthInstance; readonly db?: AuthBrokerDbClient; + readonly socialProviders?: readonly BetterAuthSocialProvider[]; + readonly accountLinkingPolicy?: BetterAuthAccountLinkingPolicy; }; const betterAuthEnvSchema = z.object({ @@ -22,6 +35,8 @@ const betterAuthEnvSchema = z.object({ BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(), GITHUB_CLIENT_ID: z.string().min(1).optional(), GITHUB_CLIENT_SECRET: z.string().min(1).optional(), + GOOGLE_CLIENT_ID: z.string().min(1).optional(), + GOOGLE_CLIENT_SECRET: z.string().min(1).optional(), }); /** @@ -39,6 +54,8 @@ export function createBetterAuthRuntimeFromEnv(): BetterAuthRuntime { BETTER_AUTH_TRUSTED_ORIGINS: process.env.BETTER_AUTH_TRUSTED_ORIGINS, GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, }); if (!env.DATABASE_URL) { @@ -58,6 +75,18 @@ export function createBetterAuthRuntimeFromEnv(): BetterAuthRuntime { const githubEnabled = Boolean( env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ); + const googleEnabled = Boolean( + env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET + ); + const socialProviders = [ + ...(githubEnabled ? [{ id: "github", label: "GitHub" }] : []), + ...(googleEnabled ? [{ id: "google", label: "Google" }] : []), + ] as const satisfies readonly BetterAuthSocialProvider[]; + const accountLinkingPolicy = { + requireVerifiedEmail: true, + allowDifferentEmails: false, + trustedProviders: [], + } as const satisfies BetterAuthAccountLinkingPolicy; const db = createAuthBrokerDbClient({ databaseUrl: env.DATABASE_URL }); const auth = betterAuth({ @@ -70,13 +99,32 @@ export function createBetterAuthRuntimeFromEnv(): BetterAuthRuntime { emailAndPassword: { enabled: true, }, - ...(githubEnabled + account: { + accountLinking: { + enabled: true, + allowDifferentEmails: accountLinkingPolicy.allowDifferentEmails, + trustedProviders: [...accountLinkingPolicy.trustedProviders], + }, + }, + ...(socialProviders.length > 0 ? { socialProviders: { - github: { - clientId: env.GITHUB_CLIENT_ID ?? "", - clientSecret: env.GITHUB_CLIENT_SECRET ?? "", - }, + ...(githubEnabled + ? { + github: { + clientId: env.GITHUB_CLIENT_ID ?? "", + clientSecret: env.GITHUB_CLIENT_SECRET ?? "", + }, + } + : {}), + ...(googleEnabled + ? { + google: { + clientId: env.GOOGLE_CLIENT_ID ?? "", + clientSecret: env.GOOGLE_CLIENT_SECRET ?? "", + }, + } + : {}), }, } : {}), @@ -99,6 +147,8 @@ export function createBetterAuthRuntimeFromEnv(): BetterAuthRuntime { enabled: true, auth, db, + socialProviders, + accountLinkingPolicy, }; } diff --git a/services/auth-broker/src/flow-store.ts b/services/auth-broker/src/flow-store.ts index 05b005ce5..5d75b29a5 100644 --- a/services/auth-broker/src/flow-store.ts +++ b/services/auth-broker/src/flow-store.ts @@ -77,7 +77,7 @@ export class FlowStore { markComplete(opts: { readonly flowId: string; readonly account: OAuthFlow["account"]; - readonly token: string; + readonly token?: string; readonly tokenExpiresAt?: string; readonly refreshToken?: string; readonly refreshTokenExpiresAt?: string; @@ -156,7 +156,11 @@ export class FlowStore { } const base = toPublicStatus(flow); - if (!opts.claimToken || flow.status !== "complete" || !flow.token) { + if ( + !opts.claimToken || + flow.status !== "complete" || + !((flow.token && flow.token.length > 0) || flow.managementToken) + ) { return { ok: true, status: base }; } @@ -173,8 +177,8 @@ export class FlowStore { ...base, status: "claimed", claimedAt, - token: flow.token, - tokenExpiresAt: flow.tokenExpiresAt, + ...(flow.token ? { token: flow.token } : {}), + ...(flow.tokenExpiresAt ? { tokenExpiresAt: flow.tokenExpiresAt } : {}), ...(flow.refreshToken ? { refreshToken: flow.refreshToken } : {}), ...(flow.refreshTokenExpiresAt ? { refreshTokenExpiresAt: flow.refreshTokenExpiresAt } @@ -278,30 +282,7 @@ function toPublicStatus(flow: OAuthFlow): OAuthFlowPublicStatus { expiresAt: new Date(flow.expiresAtMs).toISOString(), ...(flow.completedAt ? { completedAt: flow.completedAt } : {}), ...(flow.claimedAt ? { claimedAt: flow.claimedAt } : {}), - ...(flow.account?.accountHandle - ? { accountHandle: flow.account.accountHandle } - : {}), - ...(flow.account?.login ? { accountLogin: flow.account.login } : {}), - ...(flow.account?.accountName - ? { accountName: flow.account.accountName } - : {}), - ...(flow.account?.accountId ? { accountId: flow.account.accountId } : {}), - ...(flow.account?.accountEmail - ? { accountEmail: flow.account.accountEmail } - : {}), - ...(flow.account?.organizationId - ? { organizationId: flow.account.organizationId } - : {}), - ...(flow.account?.organizationName - ? { organizationName: flow.account.organizationName } - : {}), - ...(flow.account?.teamIds ? { teamIds: flow.account.teamIds } : {}), - ...(flow.account?.betterAuthUserId - ? { betterAuthUserId: flow.account.betterAuthUserId } - : {}), - ...(flow.account?.betterAuthLinkState - ? { betterAuthLinkState: flow.account.betterAuthLinkState } - : {}), + ...toPublicAccountFields(flow), ...(flow.installationId ? { installationId: flow.installationId } : {}), ...(flow.account?.installationIds ? { installationIds: flow.account.installationIds } @@ -317,6 +298,38 @@ function toPublicStatus(flow: OAuthFlow): OAuthFlowPublicStatus { }; } +function toPublicAccountFields( + flow: OAuthFlow +): Partial { + const account = flow.account; + if (!account) { + return {}; + } + return { + ...(account.accountHandle ? { accountHandle: account.accountHandle } : {}), + ...(account.login ? { accountLogin: account.login } : {}), + ...(account.accountName ? { accountName: account.accountName } : {}), + ...(account.accountId ? { accountId: account.accountId } : {}), + ...(account.accountEmail ? { accountEmail: account.accountEmail } : {}), + ...(typeof account.accountEmailVerified === "boolean" + ? { accountEmailVerified: account.accountEmailVerified } + : {}), + ...(account.organizationId + ? { organizationId: account.organizationId } + : {}), + ...(account.organizationName + ? { organizationName: account.organizationName } + : {}), + ...(account.teamIds ? { teamIds: account.teamIds } : {}), + ...(account.betterAuthUserId + ? { betterAuthUserId: account.betterAuthUserId } + : {}), + ...(account.betterAuthLinkState + ? { betterAuthLinkState: account.betterAuthLinkState } + : {}), + }; +} + function normalizeStorePath(value: string | undefined): string | null { const trimmed = value?.trim() ?? ""; if (!trimmed) { diff --git a/services/auth-broker/src/modules/better-auth/shell-plugin.ts b/services/auth-broker/src/modules/better-auth/shell-plugin.ts new file mode 100644 index 000000000..7136a595b --- /dev/null +++ b/services/auth-broker/src/modules/better-auth/shell-plugin.ts @@ -0,0 +1,1118 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { Elysia, t } from "elysia"; + +import type { + BetterAuthAccountLinkingPolicy, + BetterAuthRuntime, + BetterAuthSocialProvider, +} from "../../better-auth.ts"; +import type { BrokerConfig } from "../../config.ts"; +import { type FlowStore, hashDeviceCode } from "../../flow-store.ts"; +import { issueBrokerManagementToken } from "./management-token.ts"; +import { resolveBetterAuthSession } from "./session.ts"; + +const SESSION_FLOW_PROFILE_ID = "session"; +const SAFE_RETURN_PROTOCOLS = new Set(["hack:"]); + +const BetterAuthShellModel = { + startQuery: t.Object({ + provider: t.Optional(t.String()), + redirect: t.Optional(t.String()), + }), + flowStatusParams: t.Object({ + flowId: t.String(), + }), + flowStatusQuery: t.Object({ + deviceCode: t.String(), + claim: t.Optional(t.String()), + }), + authPageQuery: t.Object({ + flowId: t.Optional(t.String()), + deviceCode: t.Optional(t.String()), + provider: t.Optional(t.String()), + redirect: t.Optional(t.String()), + }), + accountPageQuery: t.Object({ + flowId: t.Optional(t.String()), + deviceCode: t.Optional(t.String()), + redirect: t.Optional(t.String()), + }), +} as const; + +type CreateBetterAuthShellPluginOptions = { + readonly config: BrokerConfig; + readonly runtime: BetterAuthRuntime; + readonly flowStore: FlowStore; +}; + +type BrowserSessionUser = { + readonly id: string; + readonly email: string | null; + readonly emailVerified: boolean; + readonly name: string | null; + readonly organizationId: string | null; + readonly teamId: string | null; +}; + +type SessionFlowLifecycle = + | { + readonly state: "none"; + } + | { + readonly state: "sign_in_required"; + readonly flowId: string; + readonly deviceCode: string; + readonly returnUrl: string | null; + } + | { + readonly state: "ready"; + readonly flowId: string; + readonly deviceCode: string; + readonly returnUrl: string | null; + } + | { + readonly state: "claimed"; + readonly flowId: string; + readonly deviceCode: string; + readonly returnUrl: string | null; + } + | { + readonly state: "invalid"; + readonly title: string; + readonly message: string; + readonly flowId?: string; + readonly deviceCode?: string; + readonly returnUrl: string | null; + }; + +/** + * Better Auth shell routes for browser sign-in, account linking, and + * management-token bootstrap flows used by local clients. + */ +export function createBetterAuthShellPlugin({ + config, + runtime, + flowStore, +}: CreateBetterAuthShellPluginOptions) { + return new Elysia({ + name: "hack-auth-broker.better-auth.shell", + }) + .get( + "/v1/auth/session/start", + ({ query, set }) => { + if (!(runtime.enabled && runtime.auth)) { + set.status = 503; + return { + ok: false, + error: runtime.reason ?? "Better Auth is not configured.", + } as const; + } + + const socialProviders = getSocialProviders({ runtime }); + if (socialProviders.length === 0) { + set.status = 412; + return { + ok: false, + error: "better_auth_social_providers_not_configured", + } as const; + } + + const requestedProviderId = normalizeProviderId({ + value: query.provider, + providers: socialProviders, + }); + if (query.provider && !requestedProviderId) { + set.status = 400; + return { + ok: false, + error: "provider_not_supported", + socialProviders, + } as const; + } + + const nowMs = Date.now(); + const flowId = randomUUID(); + const deviceCode = makeToken(); + const expiresAtMs = nowMs + config.flowTtlMs; + const returnUrl = normalizeSafeReturnUrl({ + value: query.redirect, + publicBaseUrl: config.publicBaseUrl, + }); + const authorizeUrl = buildAuthShellUrl({ + publicBaseUrl: config.publicBaseUrl, + flowId, + deviceCode, + ...(requestedProviderId ? { providerId: requestedProviderId } : {}), + ...(returnUrl ? { returnUrl } : {}), + }); + + flowStore.createFlow({ + id: flowId, + provider: "session", + state: makeToken(), + profileId: SESSION_FLOW_PROFILE_ID, + setDefault: false, + deviceCodeHash: hashDeviceCode(deviceCode), + authorizeUrl, + createdAtMs: nowMs, + expiresAtMs, + redirectUri: `${config.publicBaseUrl}/auth/account`, + status: "pending", + }); + + return { + ok: true, + flow: { + flowId, + authorizeUrl, + deviceCode, + pollUrl: `${config.publicBaseUrl}/v1/auth/session/flows/${flowId}`, + expiresAt: new Date(expiresAtMs).toISOString(), + socialProviders, + ...(requestedProviderId ? { provider: requestedProviderId } : {}), + }, + } as const; + }, + { + query: BetterAuthShellModel.startQuery, + } + ) + .get( + "/v1/auth/session/flows/:flowId", + ({ params, query, set }) => { + const flow = flowStore.getById(params.flowId); + if (!(flow && flow.provider === "session")) { + set.status = 404; + return { + ok: false, + error: "flow_not_found", + } as const; + } + const status = flowStore.getStatus({ + flowId: params.flowId, + deviceCode: query.deviceCode, + claimToken: isTruthy(query.claim), + requireInstallation: false, + }); + if (!status.ok) { + set.status = status.statusCode; + return { + ok: false, + error: status.error, + } as const; + } + return { + ok: true, + status: status.status, + } as const; + }, + { + params: BetterAuthShellModel.flowStatusParams, + query: BetterAuthShellModel.flowStatusQuery, + } + ) + .get("/v1/auth/me", async ({ request, set }) => { + if (!(runtime.enabled && runtime.auth)) { + set.status = 503; + return { + ok: false, + error: runtime.reason ?? "Better Auth is not configured.", + } as const; + } + const resolvedSession = await resolveBetterAuthSession({ + runtime, + request, + }); + return { + ok: true, + authenticated: Boolean(resolvedSession.session), + accessControlMode: resolvedSession.accessControlMode, + session: resolvedSession.session, + socialProviders: getSocialProviders({ runtime }), + accountLinkingPolicy: getAccountLinkingPolicy({ runtime }), + shellPath: "/auth", + accountPath: "/auth/account", + } as const; + }) + .get( + "/auth", + async ({ query, request }) => { + const socialProviders = getSocialProviders({ runtime }); + const returnUrl = normalizeSafeReturnUrl({ + value: query.redirect, + publicBaseUrl: config.publicBaseUrl, + }); + if (!(runtime.enabled && runtime.auth)) { + return renderHtmlPage({ + title: "Hack auth unavailable", + body: renderStateCard({ + eyebrow: "Auth unavailable", + title: "Hack auth is not configured", + body: + runtime.reason ?? + "The auth broker is missing Better Auth configuration.", + tone: "muted", + }), + }); + } + + const preferredProviderId = normalizeProviderId({ + value: query.provider, + providers: socialProviders, + }); + const resolvedSession = await resolveBetterAuthSession({ + runtime, + request, + }); + if (resolvedSession.session) { + return Response.redirect( + buildAccountPageUrl({ + publicBaseUrl: config.publicBaseUrl, + flowId: query.flowId, + deviceCode: query.deviceCode, + returnUrl, + }), + 302 + ); + } + + const callbackUrl = buildAccountPageUrl({ + publicBaseUrl: config.publicBaseUrl, + flowId: query.flowId, + deviceCode: query.deviceCode, + returnUrl, + }); + const autoProviderId = + preferredProviderId ?? + (socialProviders.length === 1 + ? (socialProviders[0]?.id ?? null) + : null); + return renderHtmlPage({ + title: "Hack auth", + body: [ + renderStateCard({ + eyebrow: "Sign in", + title: "Connect Hack auth", + body: "Use a configured provider to open a first-party Hack session in this browser.", + tone: "neutral", + }), + socialProviders.length > 0 + ? renderProviderActionGrid({ + providers: socialProviders, + callbackUrl, + mode: "sign-in", + }) + : renderStateCard({ + eyebrow: "No providers", + title: "No social login providers are configured", + body: "Configure GitHub or Google credentials for Better Auth, then reload this page.", + tone: "muted", + }), + renderFlowHint({ + flowId: query.flowId, + deviceCode: query.deviceCode, + }), + ].join(""), + script: renderProviderActionScript({ + callbackUrl, + autoProviderId, + mode: "sign-in", + }), + }); + }, + { + query: BetterAuthShellModel.authPageQuery, + } + ) + .get( + "/auth/account", + async ({ query, request }) => { + const socialProviders = getSocialProviders({ runtime }); + const returnUrl = normalizeSafeReturnUrl({ + value: query.redirect, + publicBaseUrl: config.publicBaseUrl, + }); + if (!(runtime.enabled && runtime.auth)) { + return renderHtmlPage({ + title: "Hack account unavailable", + body: renderStateCard({ + eyebrow: "Auth unavailable", + title: "Hack auth is not configured", + body: + runtime.reason ?? + "The auth broker is missing Better Auth configuration.", + tone: "muted", + }), + }); + } + + const rawSession = await runtime.auth.api.getSession({ + headers: request.headers, + }); + const resolvedSession = await resolveBetterAuthSession({ + runtime, + request, + }); + const browserSession = toBrowserSessionUser({ session: rawSession }); + const lifecycle = maybeCompleteSessionFlow({ + config, + flowStore, + browserSession, + flowId: query.flowId, + deviceCode: query.deviceCode, + returnUrl, + }); + return renderHtmlPage({ + title: "Hack account", + body: renderAccountBody({ + session: resolvedSession.session, + socialProviders, + lifecycle, + accountLinkingPolicy: getAccountLinkingPolicy({ runtime }), + accountPageUrl: buildAccountPageUrl({ + publicBaseUrl: config.publicBaseUrl, + flowId: query.flowId, + deviceCode: query.deviceCode, + returnUrl, + }), + }), + script: resolvedSession.session + ? renderProviderActionScript({ + callbackUrl: buildAccountPageUrl({ + publicBaseUrl: config.publicBaseUrl, + flowId: query.flowId, + deviceCode: query.deviceCode, + returnUrl, + }), + autoProviderId: null, + mode: "link", + }) + : undefined, + }); + }, + { + query: BetterAuthShellModel.accountPageQuery, + } + ); +} + +function getSocialProviders(input: { + readonly runtime: BetterAuthRuntime; +}): readonly BetterAuthSocialProvider[] { + return input.runtime.socialProviders ?? []; +} + +function getAccountLinkingPolicy(input: { + readonly runtime: BetterAuthRuntime; +}): BetterAuthAccountLinkingPolicy { + return ( + input.runtime.accountLinkingPolicy ?? { + requireVerifiedEmail: true, + allowDifferentEmails: false, + trustedProviders: [], + } + ); +} + +function maybeCompleteSessionFlow(input: { + readonly config: BrokerConfig; + readonly flowStore: FlowStore; + readonly browserSession: BrowserSessionUser | null; + readonly flowId?: string; + readonly deviceCode?: string; + readonly returnUrl: string | null; +}): SessionFlowLifecycle { + const flowId = normalizeText(input.flowId); + const deviceCode = normalizeText(input.deviceCode); + if (!(flowId && deviceCode)) { + return { state: "none" }; + } + + const flow = input.flowStore.getById(flowId); + if (!(flow && flow.provider === "session")) { + return { + state: "invalid", + title: "Session not found", + message: "This Hack auth session is missing or already expired.", + flowId, + deviceCode, + returnUrl: input.returnUrl, + }; + } + + const statusResult = input.flowStore.getStatus({ + flowId, + deviceCode, + claimToken: false, + requireInstallation: false, + }); + if (!statusResult.ok) { + return { + state: "invalid", + title: "Session unavailable", + message: humanizeFlowError({ error: statusResult.error }), + flowId, + deviceCode, + returnUrl: input.returnUrl, + }; + } + + if (statusResult.status.status === "claimed") { + return { + state: "claimed", + flowId, + deviceCode, + returnUrl: input.returnUrl, + }; + } + if (statusResult.status.status === "complete") { + return { + state: "ready", + flowId, + deviceCode, + returnUrl: input.returnUrl, + }; + } + if (statusResult.status.status !== "pending") { + return { + state: "invalid", + title: "Session unavailable", + message: humanizeFlowError({ error: statusResult.status.error }), + flowId, + deviceCode, + returnUrl: input.returnUrl, + }; + } + if (!input.browserSession) { + return { + state: "sign_in_required", + flowId, + deviceCode, + returnUrl: input.returnUrl, + }; + } + + const managementToken = issueBrokerManagementToken({ + userId: input.browserSession.id, + organizationId: input.browserSession.organizationId, + teamId: input.browserSession.teamId, + }); + input.flowStore.markComplete({ + flowId, + account: { + accountId: input.browserSession.id, + accountName: input.browserSession.name ?? undefined, + accountEmail: input.browserSession.email ?? undefined, + accountEmailVerified: input.browserSession.emailVerified, + betterAuthUserId: input.browserSession.id, + }, + managementToken: managementToken?.token, + managementTokenExpiresAt: managementToken?.expiresAt, + }); + return { + state: "ready", + flowId, + deviceCode, + returnUrl: input.returnUrl, + }; +} + +function toBrowserSessionUser(input: { + readonly session: unknown; +}): BrowserSessionUser | null { + const record = readRecord(input.session); + const user = readRecord(record?.user); + if (!user) { + return null; + } + const userId = normalizeText(user.id); + if (!userId) { + return null; + } + const sessionRecord = readRecord(record?.session); + return { + id: userId, + email: normalizeText(user.email), + emailVerified: user.emailVerified === true, + name: normalizeText(user.name), + organizationId: + normalizeText(sessionRecord?.activeOrganizationId) ?? + normalizeText(record?.activeOrganizationId), + teamId: + normalizeText(sessionRecord?.activeTeamId) ?? + normalizeText(record?.activeTeamId), + }; +} + +function renderAccountBody(input: { + readonly session: Awaited< + ReturnType + >["session"]; + readonly socialProviders: readonly BetterAuthSocialProvider[]; + readonly lifecycle: SessionFlowLifecycle; + readonly accountLinkingPolicy: BetterAuthAccountLinkingPolicy; + readonly accountPageUrl: string; +}): string { + const sessionSummary = input.session + ? renderStateCard({ + eyebrow: "Signed in", + title: escapeHtml( + input.session.name ?? input.session.email ?? "Hack account" + ), + body: [ + input.session.email + ? `Email: ${escapeHtml(input.session.email)}` + : null, + `User ID: ${escapeHtml(input.session.userId)}`, + input.session.organizationId + ? `Organization: ${escapeHtml(input.session.organizationId)}` + : null, + input.session.teamId + ? `Team: ${escapeHtml(input.session.teamId)}` + : null, + ] + .filter(Boolean) + .join("
"), + tone: "neutral", + }) + : renderStateCard({ + eyebrow: "Not signed in", + title: "Complete sign-in to finish setup", + body: "Use the provider buttons below or return to the auth landing page to open a browser session first.", + tone: "warning", + }); + + const lifecycleCard = renderLifecycleCard({ + lifecycle: input.lifecycle, + accountPageUrl: input.accountPageUrl, + }); + const linkingCard = input.session + ? [ + renderStateCard({ + eyebrow: "Linked providers", + title: "Link another provider", + body: renderLinkingPolicy(input.accountLinkingPolicy), + tone: "muted", + }), + renderProviderActionGrid({ + providers: input.socialProviders, + callbackUrl: input.accountPageUrl, + mode: "link", + }), + ].join("") + : ""; + + return [sessionSummary, lifecycleCard, linkingCard].join(""); +} + +function renderLifecycleCard(input: { + readonly lifecycle: SessionFlowLifecycle; + readonly accountPageUrl: string; +}): string { + if (input.lifecycle.state === "none") { + return renderStateCard({ + eyebrow: "Ready", + title: "Hack session is active", + body: "You can return to the app now, or link another provider from this account page.", + tone: "neutral", + }); + } + if (input.lifecycle.state === "sign_in_required") { + const retryUrl = buildAuthShellUrlFromAccountPageUrl({ + accountPageUrl: input.accountPageUrl, + flowId: input.lifecycle.flowId, + deviceCode: input.lifecycle.deviceCode, + returnUrl: input.lifecycle.returnUrl, + }); + return renderStateCard({ + eyebrow: "Pending", + title: "Finish sign-in to complete this session flow", + body: `Return to the auth landing page and continue with a provider.`, + tone: "warning", + }); + } + if (input.lifecycle.state === "ready") { + return renderStateCard({ + eyebrow: "Ready", + title: "Hack auth is ready to claim", + body: renderCompletionBody({ + returnUrl: input.lifecycle.returnUrl, + }), + tone: "success", + }); + } + if (input.lifecycle.state === "claimed") { + return renderStateCard({ + eyebrow: "Claimed", + title: "This session flow was already claimed", + body: renderCompletionBody({ + returnUrl: input.lifecycle.returnUrl, + }), + tone: "muted", + }); + } + return renderStateCard({ + eyebrow: "Unavailable", + title: escapeHtml(input.lifecycle.title), + body: escapeHtml(input.lifecycle.message), + tone: "danger", + }); +} + +function renderCompletionBody(input: { + readonly returnUrl: string | null; +}): string { + if (!input.returnUrl) { + return "Return to Hack and finish the local setup flow."; + } + return `Return to Hack and finish the local setup flow. Open app.`; +} + +function renderProviderActionGrid(input: { + readonly providers: readonly BetterAuthSocialProvider[]; + readonly callbackUrl: string; + readonly mode: "sign-in" | "link"; +}): string { + return `
${input.providers + .map( + (provider) => + `` + ) + .join("")}
`; +} + +function renderFlowHint(input: { + readonly flowId?: string; + readonly deviceCode?: string; +}): string { + if (!(input.flowId && input.deviceCode)) { + return ""; + } + return renderStateCard({ + eyebrow: "Device flow", + title: "This browser session is linked to a local client", + body: `Flow: ${escapeHtml(input.flowId)}`, + tone: "muted", + }); +} + +function renderProviderActionScript(input: { + readonly callbackUrl: string; + readonly autoProviderId: string | null; + readonly mode: "sign-in" | "link"; +}): string { + return ` +const authButtons = [...document.querySelectorAll("[data-auth-provider]")]; +const authStatus = document.getElementById("auth-status"); +const authEndpoints = { + "sign-in": "/api/auth/sign-in/social", + "link": "/api/auth/link-social", +}; + +async function runAuthAction(button) { + const provider = button.getAttribute("data-auth-provider"); + const mode = button.getAttribute("data-auth-mode") || ${JSON.stringify( + input.mode + )}; + const callbackURL = + button.getAttribute("data-auth-callback-url") || ${JSON.stringify( + input.callbackUrl + )}; + if (!provider) { + return; + } + button.disabled = true; + if (authStatus) { + authStatus.textContent = mode === "link" ? "Linking provider..." : "Redirecting to provider..."; + } + try { + const response = await fetch(authEndpoints[mode] || authEndpoints["sign-in"], { + method: "POST", + credentials: "same-origin", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + provider, + callbackURL, + }), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new Error( + payload?.message || + payload?.error || + payload?.code || + "Auth request failed" + ); + } + const nextUrl = + typeof payload?.url === "string" && payload.url.length > 0 + ? payload.url + : callbackURL; + window.location.assign(nextUrl); + } catch (error) { + if (authStatus) { + authStatus.textContent = + error instanceof Error ? error.message : String(error); + } + button.disabled = false; + } +} + +for (const button of authButtons) { + button.addEventListener("click", () => void runAuthAction(button)); +} + +const autoProviderId = ${JSON.stringify(input.autoProviderId)}; +if (autoProviderId) { + const target = document.querySelector( + '[data-auth-provider="' + CSS.escape(autoProviderId) + '"]' + ); + if (target instanceof HTMLButtonElement) { + window.requestAnimationFrame(() => { + void runAuthAction(target); + }); + } +} +`; +} + +function renderLinkingPolicy(policy: BetterAuthAccountLinkingPolicy): string { + const parts = [ + policy.requireVerifiedEmail + ? "Verified provider email is required for implicit account linking." + : "Implicit account linking can use unverified provider email.", + policy.allowDifferentEmails + ? "Different provider emails are allowed." + : "Provider email must match the existing Hack account email.", + ]; + if (policy.trustedProviders.length > 0) { + parts.push( + `Trusted providers: ${policy.trustedProviders + .map((providerId) => escapeHtml(providerId)) + .join(", ")}.` + ); + } + return parts.join(" "); +} + +function renderStateCard(input: { + readonly eyebrow: string; + readonly title: string; + readonly body: string; + readonly tone: "neutral" | "muted" | "warning" | "success" | "danger"; +}): string { + return `
+

${escapeHtml(input.eyebrow)}

+

${input.title}

+

${input.body}

+
`; +} + +function renderHtmlPage(input: { + readonly title: string; + readonly body: string; + readonly script?: string; +}): Response { + return new Response( + ` + + + + + ${escapeHtml(input.title)} + + + +
+

Hack auth

+

First-party session setup for Hack CLI and Hack Desktop.

+ ${input.body} +

+
+ ${input.script ? `` : ""} + +`, + { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + }, + } + ); +} + +function buildAuthShellUrl(input: { + readonly publicBaseUrl: string; + readonly flowId?: string; + readonly deviceCode?: string; + readonly providerId?: string; + readonly returnUrl?: string; +}): string { + const url = new URL("/auth", resolvePageBaseUrl(input.publicBaseUrl)); + if (input.flowId) { + url.searchParams.set("flowId", input.flowId); + } + if (input.deviceCode) { + url.searchParams.set("deviceCode", input.deviceCode); + } + if (input.providerId) { + url.searchParams.set("provider", input.providerId); + } + if (input.returnUrl) { + url.searchParams.set("redirect", input.returnUrl); + } + return url.toString(); +} + +function buildAuthShellUrlFromAccountPageUrl(input: { + readonly accountPageUrl: string; + readonly flowId: string; + readonly deviceCode: string; + readonly returnUrl: string | null; +}): string { + const accountPageUrl = new URL(input.accountPageUrl); + return buildAuthShellUrl({ + publicBaseUrl: accountPageUrl.origin, + flowId: input.flowId, + deviceCode: input.deviceCode, + ...(input.returnUrl ? { returnUrl: input.returnUrl } : {}), + }); +} + +function buildAccountPageUrl(input: { + readonly publicBaseUrl: string; + readonly flowId?: string; + readonly deviceCode?: string; + readonly returnUrl?: string | null; +}): string { + const url = new URL("/auth/account", resolvePageBaseUrl(input.publicBaseUrl)); + if (input.flowId) { + url.searchParams.set("flowId", input.flowId); + } + if (input.deviceCode) { + url.searchParams.set("deviceCode", input.deviceCode); + } + if (input.returnUrl) { + url.searchParams.set("redirect", input.returnUrl); + } + return url.toString(); +} + +function normalizeProviderId(input: { + readonly value?: string; + readonly providers: readonly BetterAuthSocialProvider[]; +}): string | null { + const value = normalizeText(input.value); + if (!value) { + return null; + } + const match = input.providers.find((provider) => provider.id === value); + return match?.id ?? null; +} + +function normalizeSafeReturnUrl(input: { + readonly value?: string; + readonly publicBaseUrl: string; +}): string | null { + const value = normalizeText(input.value); + if (!value) { + return null; + } + try { + const candidate = new URL(value, input.publicBaseUrl); + if (SAFE_RETURN_PROTOCOLS.has(candidate.protocol)) { + return candidate.toString(); + } + const baseOrigin = new URL(input.publicBaseUrl).origin; + return candidate.origin === baseOrigin ? candidate.toString() : null; + } catch { + return null; + } +} + +function resolvePageBaseUrl(publicBaseUrl: string): string { + return publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`; +} + +function humanizeFlowError(input: { readonly error?: string }): string { + if (!input.error) { + return "This auth session is no longer available."; + } + if (input.error === "invalid_device_code") { + return "This auth session does not match the local client that started it."; + } + if (input.error === "flow_not_found") { + return "This auth session was not found or has already expired."; + } + if (input.error === "oauth_flow_expired") { + return "This auth session expired before it was completed."; + } + return input.error.replaceAll("_", " "); +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function normalizeText(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function readRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function isTruthy(value: string | undefined): boolean { + if (!value) { + return false; + } + const normalized = value.trim().toLowerCase(); + return ( + normalized === "1" || + normalized === "true" || + normalized === "yes" || + normalized === "on" + ); +} + +function makeToken(): string { + return randomBytes(32).toString("base64url"); +} diff --git a/services/auth-broker/src/modules/providers/plugin.ts b/services/auth-broker/src/modules/providers/plugin.ts index 09fa348b1..7937b9d6f 100644 --- a/services/auth-broker/src/modules/providers/plugin.ts +++ b/services/auth-broker/src/modules/providers/plugin.ts @@ -30,6 +30,16 @@ export function createProvidersPlugin({ enabled: betterAuthRuntime.enabled, mode: "session", basePath: "/api/auth", + shellPath: "/auth", + accountPath: "/auth/account", + sessionStartPath: "/v1/auth/session/start", + mePath: "/v1/auth/me", + socialProviders: betterAuthRuntime.socialProviders ?? [], + accountLinkingPolicy: betterAuthRuntime.accountLinkingPolicy ?? { + requireVerifiedEmail: true, + allowDifferentEmails: false, + trustedProviders: [], + }, }, { id: "github", diff --git a/services/auth-broker/src/types.ts b/services/auth-broker/src/types.ts index 5caaaaa6b..118f0460a 100644 --- a/services/auth-broker/src/types.ts +++ b/services/auth-broker/src/types.ts @@ -5,7 +5,7 @@ export type FlowStatus = | "expired" | "claimed"; -export type OAuthProvider = "github" | "linear"; +export type OAuthProvider = "github" | "linear" | "session"; export type OAuthFlowAccount = { readonly login?: string; @@ -13,6 +13,7 @@ export type OAuthFlowAccount = { readonly accountName?: string; readonly accountId?: string; readonly accountEmail?: string; + readonly accountEmailVerified?: boolean; readonly organizationId?: string; readonly organizationName?: string; readonly teamIds?: readonly string[]; @@ -20,6 +21,7 @@ export type OAuthFlowAccount = { readonly betterAuthUserId?: string; readonly betterAuthLinkState?: | "disabled" + | "email_not_verified" | "missing_email" | "linked_existing" | "created_new" @@ -74,12 +76,14 @@ export type OAuthFlowPublicStatus = { readonly accountName?: string; readonly accountId?: string; readonly accountEmail?: string; + readonly accountEmailVerified?: boolean; readonly organizationId?: string; readonly organizationName?: string; readonly teamIds?: readonly string[]; readonly betterAuthUserId?: string; readonly betterAuthLinkState?: | "disabled" + | "email_not_verified" | "missing_email" | "linked_existing" | "created_new" diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index c68006433..9d8f5be13 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -35,6 +35,21 @@ type StartFlowResponse = { }; }; +type SessionStartFlowResponse = { + readonly ok: true; + readonly flow: { + readonly flowId: string; + readonly deviceCode: string; + readonly pollUrl: string; + readonly authorizeUrl: string; + readonly socialProviders: ReadonlyArray<{ + readonly id: string; + readonly label: string; + }>; + readonly provider?: string; + }; +}; + function createBetterAuthDb( rows: readonly Record[] ): BetterAuthDb { @@ -51,6 +66,12 @@ function createBetterAuthRuntimeWithSession( return { enabled: true, ...(db ? { db } : {}), + socialProviders: [{ id: "github", label: "GitHub" }], + accountLinkingPolicy: { + requireVerifiedEmail: true, + allowDifferentEmails: false, + trustedProviders: [], + }, auth: { api: { getSession: async () => session, @@ -318,6 +339,219 @@ describe("auth broker github flow routes", () => { expect(payload.basePath).toBe("/api/auth"); }); + test("providers route includes better-auth shell metadata", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/providers") + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly providers: ReadonlyArray<{ + readonly id: string; + readonly shellPath?: string; + readonly accountPath?: string; + readonly sessionStartPath?: string; + readonly mePath?: string; + readonly socialProviders?: ReadonlyArray<{ + readonly id: string; + readonly label: string; + }>; + readonly accountLinkingPolicy?: { + readonly requireVerifiedEmail: boolean; + readonly allowDifferentEmails: boolean; + }; + }>; + }; + const betterAuthProvider = payload.providers.find( + (provider) => provider.id === "better-auth" + ); + expect(betterAuthProvider).toBeDefined(); + expect(betterAuthProvider?.shellPath).toBe("/auth"); + expect(betterAuthProvider?.accountPath).toBe("/auth/account"); + expect(betterAuthProvider?.sessionStartPath).toBe("/v1/auth/session/start"); + expect(betterAuthProvider?.mePath).toBe("/v1/auth/me"); + expect(betterAuthProvider?.socialProviders).toEqual([ + { + id: "github", + label: "GitHub", + }, + ]); + expect(betterAuthProvider?.accountLinkingPolicy).toMatchObject({ + requireVerifiedEmail: true, + allowDifferentEmails: false, + }); + }); + + test("session start route issues auth shell flow payload", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + + const response = await app.handle( + new Request( + "http://localhost/v1/auth/session/start?provider=github&redirect=hack://auth/complete" + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as SessionStartFlowResponse; + expect(payload.ok).toBe(true); + expect(payload.flow.authorizeUrl).toContain("/auth?"); + expect(payload.flow.authorizeUrl).toContain("provider=github"); + expect(payload.flow.authorizeUrl).toContain( + "redirect=hack%3A%2F%2Fauth%2Fcomplete" + ); + expect(payload.flow.socialProviders).toEqual([ + { + id: "github", + label: "GitHub", + }, + ]); + expect(payload.flow.provider).toBe("github"); + }); + + test("auth page renders provider-driven sign-in surface", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + + const response = await app.handle( + new Request("http://localhost/auth?provider=github") + ); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("Connect Hack auth"); + expect(html).toContain("Continue with GitHub"); + expect(html).toContain("/api/auth/sign-in/social"); + }); + + test("account page completes session flow and claimed token resolves /v1/auth/me", async () => { + await withManagementTokenSecret("broker-session-secret", async () => { + const flowStore = new FlowStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-auth", + userId: "better-auth-user", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "auth-token", + }, + user: { + id: "better-auth-user", + email: "user@example.com", + emailVerified: true, + name: "Hack User", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "org-auth", + teamId: "team-auth", + } + ) + ), + }); + + const startResponse = await app.handle( + new Request( + "http://localhost/v1/auth/session/start?provider=github&redirect=hack://desktop/open" + ) + ); + expect(startResponse.status).toBe(200); + const startPayload = + (await startResponse.json()) as SessionStartFlowResponse; + + const accountResponse = await app.handle( + new Request( + `http://localhost/auth/account?flowId=${encodeURIComponent( + startPayload.flow.flowId + )}&deviceCode=${encodeURIComponent( + startPayload.flow.deviceCode + )}&redirect=${encodeURIComponent("hack://desktop/open")}` + ) + ); + expect(accountResponse.status).toBe(200); + const accountHtml = await accountResponse.text(); + expect(accountHtml).toContain("Hack auth is ready to claim"); + expect(accountHtml).toContain("Open app"); + + const claimResponse = await app.handle( + new Request( + `http://localhost/v1/auth/session/flows/${encodeURIComponent( + startPayload.flow.flowId + )}?deviceCode=${encodeURIComponent( + startPayload.flow.deviceCode + )}&claim=1` + ) + ); + expect(claimResponse.status).toBe(200); + const claimPayload = (await claimResponse.json()) as { + readonly ok: true; + readonly status: { + readonly status: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; + readonly accountEmail?: string; + readonly betterAuthUserId?: string; + }; + }; + expect(claimPayload.ok).toBe(true); + expect(claimPayload.status.status).toBe("claimed"); + expect(claimPayload.status.managementToken).toBeDefined(); + expect(claimPayload.status.managementTokenExpiresAt).toBeDefined(); + expect(claimPayload.status.accountEmail).toBe("user@example.com"); + expect(claimPayload.status.betterAuthUserId).toBe("better-auth-user"); + + const managementToken = claimPayload.status.managementToken; + expect(managementToken).toBeDefined(); + if (!managementToken) { + return; + } + + const meResponse = await app.handle( + new Request("http://localhost/v1/auth/me", { + headers: { + authorization: `Bearer ${managementToken}`, + }, + }) + ); + expect(meResponse.status).toBe(200); + const mePayload = (await meResponse.json()) as { + readonly ok: true; + readonly authenticated: boolean; + readonly accessControlMode: string; + readonly session: { + readonly userId: string; + readonly organizationId: string | null; + readonly teamId: string | null; + readonly managementTokenProfileId: string | null; + } | null; + }; + expect(mePayload.ok).toBe(true); + expect(mePayload.authenticated).toBe(true); + expect(mePayload.accessControlMode).toBe("better_auth_team_owned"); + expect(mePayload.session?.userId).toBe("better-auth-user"); + expect(mePayload.session?.organizationId).toBe("org-auth"); + expect(mePayload.session?.teamId).toBe("team-auth"); + expect(mePayload.session?.managementTokenProfileId).toBeNull(); + }); + }); + test("shared middleware accepts optional inbound request id header", async () => { const app = createAuthBrokerApp({ config: createTestConfig(), diff --git a/services/auth-broker/tests/session-auth.test.ts b/services/auth-broker/tests/session-auth.test.ts new file mode 100644 index 000000000..0b1419882 --- /dev/null +++ b/services/auth-broker/tests/session-auth.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "bun:test"; + +import type { BetterAuthRuntime } from "../src/better-auth.ts"; +import { FlowStore } from "../src/flow-store.ts"; +import { createAuthBrokerApp } from "../src/index.ts"; + +type BetterAuthAuth = NonNullable; +type BetterAuthSession = Awaited< + ReturnType +>; + +type SessionStartFlowResponse = { + readonly ok: true; + readonly flow: { + readonly flowId: string; + readonly deviceCode: string; + readonly pollUrl: string; + readonly authorizeUrl: string; + readonly socialProviders: ReadonlyArray<{ + readonly id: string; + readonly label: string; + }>; + }; +}; + +function createTestConfig() { + return { + port: 0, + host: "127.0.0.1", + publicBaseUrl: "http://127.0.0.1:8080", + flowStorePath: ".data/test-oauth-flows.json", + githubClientId: "test-client-id", + githubClientSecret: "test-client-secret", + githubScopes: "repo,read:org", + githubAuthorizeUrl: "https://github.com/login/oauth/authorize", + githubTokenUrl: "https://github.com/login/oauth/access_token", + githubApiBaseUrl: "https://api.github.com", + githubRedirectUri: "http://127.0.0.1:8080/gh/callback", + betterAuthGitHubAutoProvisionUsers: false, + betterAuthLinearAutoProvisionUsers: false, + linearClientId: "linear-client-id", + linearClientSecret: "linear-client-secret", + linearActor: "app", + linearScopes: "read,write,app:mentionable,app:assignable", + linearAuthorizeUrl: "https://linear.app/oauth/authorize", + linearTokenUrl: "https://api.linear.app/oauth/token", + linearApiBaseUrl: "https://api.linear.app", + linearRedirectUri: "http://127.0.0.1:8080/linear/callback", + linearWebhookPath: "/linear/webhooks", + flowTtlMs: 60_000, + flowSweepIntervalMs: 60_000, + } as const; +} + +function createBetterAuthRuntimeWithSession( + session: BetterAuthSession, + input?: { + readonly socialProviders?: ReadonlyArray<{ + readonly id: string; + readonly label: string; + }>; + } +): BetterAuthRuntime { + return { + enabled: true, + socialProviders: input?.socialProviders ?? [ + { id: "github", label: "GitHub" }, + ], + accountLinkingPolicy: { + requireVerifiedEmail: true, + allowDifferentEmails: false, + trustedProviders: [], + }, + auth: { + api: { + getSession: async () => session, + }, + } as unknown as BetterAuthAuth["api"], + } as unknown as BetterAuthRuntime; +} + +async function withManagementTokenSecret( + value: string, + fn: () => Promise +): Promise { + const previousBetterAuthSecret = process.env.BETTER_AUTH_SECRET; + const previousAuthSecret = process.env.AUTH_SECRET; + process.env.BETTER_AUTH_SECRET = value; + process.env.AUTH_SECRET = value; + try { + return await fn(); + } finally { + if (typeof previousBetterAuthSecret === "string") { + process.env.BETTER_AUTH_SECRET = previousBetterAuthSecret; + } else { + process.env.BETTER_AUTH_SECRET = undefined; + } + if (typeof previousAuthSecret === "string") { + process.env.AUTH_SECRET = previousAuthSecret; + } else { + process.env.AUTH_SECRET = undefined; + } + } +} + +describe("broker Hack session auth flow", () => { + test("session start exposes configured social providers", async () => { + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(null, { + socialProviders: [ + { id: "github", label: "GitHub" }, + { id: "google", label: "Google" }, + ], + }), + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/session/start") + ); + expect(response.status).toBe(200); + + const payload = (await response.json()) as SessionStartFlowResponse; + expect(payload.ok).toBe(true); + expect(payload.flow.socialProviders).toEqual([ + { id: "github", label: "GitHub" }, + { id: "google", label: "Google" }, + ]); + expect(payload.flow.authorizeUrl).toContain("/auth?"); + }); + + test("auth shell completes a session flow for an authenticated session", async () => { + await withManagementTokenSecret("session-auth-test-secret", async () => { + const session = { + user: { + id: "user-123", + email: "hack@example.com", + emailVerified: true, + name: "Hack User", + }, + session: { + id: "sess-123", + userId: "user-123", + token: "session-token", + activeOrganizationId: "org-123", + activeTeamId: "team-123", + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + } as unknown as BetterAuthSession; + + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(session), + }); + + const startResponse = await app.handle( + new Request("http://localhost/v1/auth/session/start") + ); + const startPayload = ( + (await startResponse.json()) as SessionStartFlowResponse + ).flow; + + const completeResponse = await app.handle( + new Request(startPayload.authorizeUrl) + ); + expect(completeResponse.status).toBe(302); + const completeLocation = completeResponse.headers.get("location"); + expect(completeLocation).toBeTruthy(); + if (!completeLocation) { + return; + } + + const accountResponse = await app.handle(new Request(completeLocation)); + expect(accountResponse.status).toBe(200); + const completeHtml = await accountResponse.text(); + expect(completeHtml).toContain("Hack auth is ready to claim"); + + const pollResponse = await app.handle( + new Request( + `${startPayload.pollUrl}?deviceCode=${encodeURIComponent(startPayload.deviceCode)}&claim=1` + ) + ); + expect(pollResponse.status).toBe(200); + const pollPayload = (await pollResponse.json()) as { + readonly ok: true; + readonly status: { + readonly status: string; + readonly managementToken?: string; + }; + }; + expect(pollPayload.ok).toBe(true); + expect(pollPayload.status.status).toBe("claimed"); + expect(typeof pollPayload.status.managementToken).toBe("string"); + + const meResponse = await app.handle( + new Request("http://localhost/v1/auth/me", { + headers: { + authorization: `Bearer ${pollPayload.status.managementToken ?? ""}`, + }, + }) + ); + expect(meResponse.status).toBe(200); + const mePayload = (await meResponse.json()) as { + readonly ok: true; + readonly session: { + readonly userId: string; + readonly email: string | null; + readonly name: string | null; + readonly organizationId: string | null; + readonly teamId: string | null; + } | null; + }; + expect(mePayload.ok).toBe(true); + expect(mePayload.session?.userId).toBe("user-123"); + expect(mePayload.session?.email).toBe("hack@example.com"); + expect(mePayload.session?.organizationId).toBe("org-123"); + expect(mePayload.session?.teamId).toBe("team-123"); + }); + }); +}); From da9aa645ab7a6f3d7b7638716f1fa786b5f94d49 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 11:11:30 -0500 Subject: [PATCH 11/54] Refine macOS auth and settings UX --- .../DashboardFeature/DashboardModel.swift | 9 + .../DashboardFeature/ProjectDetailView.swift | 255 ++++++++++-------- .../SettingsOverlayView.swift | 217 +++++++++++++-- .../HackCLIService/HackCLIClient.swift | 50 ++++ .../Sources/HackDesktopModels/Models.swift | 34 +++ 5 files changed, 423 insertions(+), 142 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index c18379590..72bddc042 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -665,6 +665,15 @@ public final class DashboardModel { } } + public func inspectHackAccountSettingsState() async -> HackAccountSettingsState? { + do { + return try await client.inspectHackAccountSettingsState() + } catch { + errorMessage = error.localizedDescription + return nil + } + } + public func startGitHubOAuthFlow( profileId: String, setDefault: Bool diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index d142cae35..dacbc310c 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -704,15 +704,18 @@ struct ProjectDetailView: View { } } - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .center, spacing: 12) { - projectSettingsLabel("Execution mode") - + VStack(alignment: .leading, spacing: 16) { + projectSettingsField( + title: "Execution mode", + description: "Choose whether this repo defaults to local work or a routed remote node.", + footnote: executionMode.summary + ) { Picker("Execution mode", selection: $executionMode) { ForEach(RemoteExecutionMode.allCases) { mode in Text(mode.title).tag(mode) } } + .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: 360, alignment: .leading) .onChange(of: executionMode) { _, _ in @@ -721,20 +724,23 @@ struct ProjectDetailView: View { } Task { await persistExecutionModeSelection() } } - - Spacer() - projectSettingsFootnote(executionMode.summary) } - HStack(alignment: .center, spacing: 12) { - projectSettingsLabel("Default node") + Divider() + .opacity(0.12) + projectSettingsField( + title: "Default node", + description: "Applies when execution mode routes work off this Mac.", + footnote: selectedNodeSummary + ) { Picker("Default node", selection: $executionTargetNodeId) { Text("Local").tag("") ForEach(executionTargetNodes, id: \.id) { node in Text(node.name).tag(node.id) } } + .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: 360, alignment: .leading) .onChange(of: executionTargetNodeId) { _, _ in @@ -744,20 +750,23 @@ struct ProjectDetailView: View { Task { await persistSimpleDefaultNodeSelection() } } .disabled(executionMode == .local) - - Spacer() - projectSettingsFootnote(selectedNodeSummary) } - HStack(alignment: .center, spacing: 12) { - projectSettingsLabel("Git creds") + Divider() + .opacity(0.12) - Picker("Git creds", selection: $githubProjectProfile) { + projectSettingsField( + title: "GitHub profile", + description: "Pick the saved GitHub profile this repo should use for remote Git operations.", + footnote: selectedGitSummary + ) { + Picker("GitHub profile", selection: $githubProjectProfile) { Text("Local").tag("") ForEach(githubProfileOptions, id: \.self) { profile in Text(githubProfileLabel(profileId: profile)).tag(profile) } } + .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: 360, alignment: .leading) .onChange(of: githubProjectProfile) { _, _ in @@ -766,9 +775,6 @@ struct ProjectDetailView: View { } Task { await persistGitCredentialsSelection() } } - - Spacer() - projectSettingsFootnote(selectedGitSummary) } } .padding(16) @@ -853,50 +859,49 @@ struct ProjectDetailView: View { VStack(alignment: .leading, spacing: 6) { Text("Linear routing") .font(.system(size: 22, weight: .semibold)) - Text("Configure the account and Linear projects this repo syncs with. Pull, push, autosync execution, and review stay in Tickets.") + Text("Choose the saved Linear profile and project routes for this repo. Ticket review and one-off sync actions stay in Tickets.") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) } routingSheetSection( - title: "Account", - description: "Choose the Linear account used for this repo. If no account is connected yet, start from global Linear settings." + title: "Saved profile", + description: "Choose the saved Linear profile Hack should use for this repo. Profiles point to provider accounts connected in global Linear settings." ) { if linearResolvedStatus?.tokenResolved == true { - HStack(alignment: .center, spacing: 12) { - Picker("Linear account", selection: $linearProjectProfile) { - Text("Inherited").tag("") - ForEach(linearProfileOptions, id: \.self) { profile in - Text(linearProfileLabel(profileId: profile)).tag(profile) + projectSettingsControlGroup(footnote: selectedLinearProfileSummary) { + HStack(alignment: .center, spacing: 12) { + Picker("Saved profile", selection: $linearProjectProfile) { + Text("Inherited").tag("") + ForEach(linearProfileOptions, id: \.self) { profile in + Text(linearProfileLabel(profileId: profile)).tag(profile) + } } - } - .pickerStyle(.menu) - .frame(maxWidth: 320, alignment: .leading) - .onChange(of: linearProjectProfile) { _, _ in - guard !executionTargetLoading else { - return + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 320, alignment: .leading) + .onChange(of: linearProjectProfile) { _, _ in + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } } - Task { await persistLinearProfileOverride() } - } - StatusPill(text: "Connected", tone: .good) - Spacer() - Button("Linear settings") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) + StatusPill(text: "Connected", tone: .good) + Spacer() + Button("Open Linear settings") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + } + .adaptiveToolbarButton() } - .adaptiveToolbarButton() } - - Text(selectedLinearProfileSummary) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 10) { - Text("Connect a Linear account before binding routes for this repo.") + Text("Connect a Linear provider account before binding routes for this repo.") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) Button("Connect Linear") { @@ -912,38 +917,40 @@ struct ProjectDetailView: View { } routingSheetSection( - title: "Default project", - description: "This is the primary Linear project for project-level pull, push, and routing." + title: "Default project route", + description: "This is the primary Linear project route for project-level pull, push, and review." ) { - HStack(alignment: .center, spacing: 12) { - Picker("Linear project", selection: $selectedLinearProjectId) { - Text("Unbound").tag("") - ForEach(linearProjectOptions) { linearProject in - Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) - } - } - .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) - .disabled(linearProjectOptions.isEmpty) - .onChange(of: selectedLinearProjectId) { _, newValue in - guard !executionTargetLoading else { - return + projectSettingsControlGroup(footnote: selectedLinearProjectSummary) { + HStack(alignment: .center, spacing: 12) { + Picker("Default project route", selection: $selectedLinearProjectId) { + Text("Unbound").tag("") + ForEach(linearProjectOptions) { linearProject in + Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + } } - guard newValue != linearBoundProjectId else { - return + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 360, alignment: .leading) + .disabled(linearProjectOptions.isEmpty) + .onChange(of: selectedLinearProjectId) { _, newValue in + guard !executionTargetLoading else { + return + } + guard newValue != linearBoundProjectId else { + return + } + Task { await persistLinearProjectBindingSelection() } } - Task { await persistLinearProjectBindingSelection() } - } - if !linearBoundProjectId.isEmpty { - Button("Clear") { - Task { await clearLinearProjectBinding() } + if !linearBoundProjectId.isEmpty { + Button("Clear") { + Task { await clearLinearProjectBinding() } + } + .adaptiveToolbarButton() } - .adaptiveToolbarButton() - } - Spacer() - projectSettingsFootnote(selectedLinearProjectSummary) + Spacer() + } } if let defaultTarget = defaultLinearRouteTarget { @@ -952,8 +959,8 @@ struct ProjectDetailView: View { } routingSheetSection( - title: "Additional synced projects", - description: "Link extra Linear projects when this repo needs review or autosync coverage beyond the default route." + title: "Additional project routes", + description: "Link extra Linear projects when this repo needs review or autosync coverage beyond the default project route." ) { if linearAdditionalProjects.isEmpty { Text("No additional linked projects.") @@ -967,29 +974,31 @@ struct ProjectDetailView: View { } } - HStack(spacing: 8) { - Picker("Add linked project", selection: $selectedAdditionalLinearProjectId) { - Text("Choose project").tag("") - ForEach(availableAdditionalLinearProjects) { linearProject in - Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { + HStack(spacing: 8) { + Picker("Add linked project", selection: $selectedAdditionalLinearProjectId) { + Text("Choose project").tag("") + ForEach(availableAdditionalLinearProjects) { linearProject in + Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + } } - } - .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) - .disabled(availableAdditionalLinearProjects.isEmpty) + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 360, alignment: .leading) + .disabled(availableAdditionalLinearProjects.isEmpty) - Button("Add") { - Task { await persistAdditionalLinearProjectSelection() } - } - .adaptiveToolbarButton() - .disabled( - selectedAdditionalLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - ) + Button("Add") { + Task { await persistAdditionalLinearProjectSelection() } + } + .adaptiveToolbarButton() + .disabled( + selectedAdditionalLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + ) - Spacer() - projectSettingsFootnote(additionalLinearProjectsSummary) + Spacer() + } } } @@ -1034,10 +1043,10 @@ struct ProjectDetailView: View { private var linearRoutingSummaryLine: String { if linearResolvedStatus?.tokenResolved != true { - return "No Linear account connected for this repo yet." + return "No Linear provider account connected for this repo yet." } if !hasAnyLinearProjectRouting { - return "Account connected, but this repo does not have a default Linear project yet." + return "A Linear profile is connected, but this repo does not have a default project route yet." } if linearAdditionalProjects.isEmpty { let defaultLabel = linearBoundProjectName.isEmpty ? resolvedLinearProjectId : linearBoundProjectName @@ -1056,18 +1065,39 @@ struct ProjectDetailView: View { ) } - private func projectSettingsLabel(_ title: String) -> some View { - Text(title) - .font(.system(size: 13, weight: .semibold)) - .frame(width: 120, alignment: .leading) - } - private func projectSettingsFootnote(_ text: String) -> some View { Text(text) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.tertiary) } + private func projectSettingsField( + title: String, + description: String, + footnote: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + Text(description) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + content() + projectSettingsFootnote(footnote) + } + } + + private func projectSettingsControlGroup( + footnote: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + content() + projectSettingsFootnote(footnote) + } + } + private func routingSheetSection( title: String, description: String, @@ -1618,11 +1648,10 @@ struct ProjectDetailView: View { } ) ) { - Text("Auto") + Text("Autosync") .font(.mono(.caption2)) .foregroundStyle(.secondary) } - .labelsHidden() .disabled(isLinearAutosyncBusy(for: normalizedTarget)) Menu { @@ -2263,12 +2292,12 @@ struct ProjectDetailView: View { value: trimmed ) if !didSave { - githubProfileMessage = "Failed to save Git creds." + githubProfileMessage = "Failed to save GitHub profile." return } githubProfileMessage = trimmed.isEmpty - ? "Git creds set to Local." - : "Git creds set to \(githubProfileLabel(profileId: trimmed))." + ? "GitHub profile set to Local." + : "GitHub profile set to \(githubProfileLabel(profileId: trimmed))." executionTargetMessage = "" await model.refresh() queueExecutionTargetReload() @@ -2294,12 +2323,12 @@ struct ProjectDetailView: View { value: trimmed ) if !didSave { - linearProjectMessage = "Failed to save Linear account routing." + linearProjectMessage = "Failed to save Linear profile routing." return } linearProjectMessage = trimmed.isEmpty - ? "Linear account set to inherited." - : "Linear account set to \(linearProfileLabel(profileId: trimmed))." + ? "Linear profile set to inherited." + : "Linear profile set to \(linearProfileLabel(profileId: trimmed))." executionTargetMessage = "" githubProfileMessage = "" await model.refresh() @@ -2428,7 +2457,7 @@ struct ProjectDetailView: View { defer { togglingLinearAutosyncRouteKeys.remove(routeKey) } let normalizedTarget = normalizedLinearRouteTarget(target) guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { - linearProjectMessage = "Choose a Linear account before changing autosync." + linearProjectMessage = "Choose a Linear profile before changing autosync." return } if enabled { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index b84299a79..807d89781 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -421,6 +421,103 @@ struct SettingsSectionHeader: View { } } +private struct HackAccountSettingsCard: View { + let state: HackAccountSettingsState? + let isLoading: Bool + let providerName: String? + let onRefresh: () -> Void + + var body: some View { + GlassCard(title: "Hack account", systemImage: "person.crop.circle.badge.checkmark") { + HStack(alignment: .center, spacing: 8) { + if let state { + StatusPill( + text: state.authEnabled ? "Broker auth enabled" : "Broker auth unavailable", + tone: state.authEnabled ? .good : .warn + ) + StatusPill( + text: state.sessionAvailable ? "Session details available" : "Desktop session pending", + tone: state.sessionAvailable ? .good : .neutral + ) + } else { + StatusPill(text: "Broker status unavailable", tone: .warn) + } + Spacer() + Button("Refresh") { + onRefresh() + } + .adaptiveToolbarButton() + if isLoading { + ProgressView() + .controlSize(.small) + } + } + + Text(summaryMessage) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + + if let state { + if state.sessionAvailable { + if let userLabel = state.userDisplayName, !userLabel.isEmpty { + Text("hack user: \(userLabel)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + if let email = state.userEmail, !email.isEmpty { + Text("email: \(email)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if let organizationName = state.organizationName, !organizationName.isEmpty { + Text("organization: \(organizationName)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + if let teamName = state.teamName, !teamName.isEmpty { + Text("team: \(teamName)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } else { + Text( + "Desktop sign-in details are not exposed here yet. This card is reserved for first-party Hack identity, while provider pages below manage provider accounts and saved routing profiles." + ) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + Text("broker: \(state.brokerBaseURL)\(state.authBasePath)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + if let reason = state.authReason, !reason.isEmpty { + Text("auth status: \(reason)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } else { + Text( + "Hack Desktop could not read auth broker status yet. Provider account setup still depends on the broker being reachable." + ) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + } + + private var summaryMessage: String { + if let providerName, !providerName.isEmpty { + return + "\(providerName) accounts below are provider accounts connected through your Hack workspace. Saved profiles are local routing labels Hack uses for defaults and project overrides; they are not separate Hack users." + } + return + "Use this card to verify Hack auth is available before connecting external providers. GitHub and Linear pages manage provider accounts and saved routing profiles, not your first-party Hack identity." + } +} + private enum AppearanceThemeOption: String, CaseIterable, Identifiable { case system case light @@ -2679,6 +2776,8 @@ private struct LinearExtensionSettingsView: View { @State private var isSavingAssigneeMapping = false @State private var message = "" @State private var lastDiagnosticsRefreshAt: Date? = nil + @State private var hackAccountState: HackAccountSettingsState? = nil + @State private var isLoadingHackAccount = false var body: some View { ScrollView { @@ -2686,18 +2785,25 @@ private struct LinearExtensionSettingsView: View { SettingsSectionHeader( breadcrumb: "Settings / Extensions / Linear", title: "Linear Extension", - subtitle: "Manage connected Linear accounts and default issue-sync routing" + subtitle: "Manage Hack auth prerequisites, connected Linear provider accounts, and saved sync routing" ) + HackAccountSettingsCard( + state: hackAccountState, + isLoading: isLoadingHackAccount, + providerName: "Linear" + ) { + Task { await refreshHackAccountState() } + } GlassCard(title: "Extension status", systemImage: "line.3.horizontal.decrease.circle") { HStack(alignment: .center, spacing: 8) { StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) StatusPill( - text: "\(linearProfiles.count) account\(linearProfiles.count == 1 ? "" : "s")", + text: "\(linearProfiles.count) provider account\(linearProfiles.count == 1 ? "" : "s")", tone: linearProfiles.isEmpty ? .neutral : .good ) if !resolvedDefaultProfile.isEmpty { StatusPill( - text: "Default remote: \(displayNameForRemoteProfileId(resolvedDefaultProfile))", + text: "Default profile: \(displayNameForRemoteProfileId(resolvedDefaultProfile))", tone: .neutral ) } @@ -2724,9 +2830,9 @@ private struct LinearExtensionSettingsView: View { } } - GlassCard(title: "Connected accounts", systemImage: "point.3.connected.trianglepath.dotted") { + GlassCard(title: "Connected provider accounts", systemImage: "point.3.connected.trianglepath.dotted") { HStack(alignment: .center, spacing: 10) { - Text("Remote Linear accounts used for ticket sync and project binding.") + Text("These are the Linear accounts Hack can route sync through. Saved profiles decide which provider account a project uses.") .font(.mono(.caption)) .foregroundStyle(.secondary) Spacer() @@ -2777,7 +2883,7 @@ private struct LinearExtensionSettingsView: View { Text(accountLabel ?? profile.id) .font(.mono(.subheadline, weight: .semibold)) if profile.isDefault { - StatusPill(text: "Default remote", tone: .good) + StatusPill(text: "Default profile", tone: .good) } if let profileStatus { StatusPill( @@ -2822,9 +2928,14 @@ private struct LinearExtensionSettingsView: View { ) } - Text("profile: \(profile.id)") + Text("routing profile: \(profile.id)") .font(.mono(.caption2)) .foregroundStyle(.secondary) + if let accountLabel, !accountLabel.isEmpty { + Text("provider account: \(accountLabel)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } if let accountEmail, !accountEmail.isEmpty { Text("email: \(accountEmail)") .font(.mono(.caption2)) @@ -2977,7 +3088,7 @@ private struct LinearExtensionSettingsView: View { ) if linearProfiles.isEmpty { - Text("Connect a Linear account before managing assignee mappings.") + Text("Connect a Linear provider account before managing assignee mappings.") .font(.mono(.caption)) .foregroundStyle(.secondary) } else { @@ -3137,9 +3248,13 @@ private struct LinearExtensionSettingsView: View { } .task { await loadConfigFromDisk() + await refreshHackAccountState() } .onChange(of: model.lastUpdated) { _, _ in - Task { await loadConfigFromDisk() } + Task { + await loadConfigFromDisk() + await refreshHackAccountState() + } } .onChange(of: assigneeMappingProfile) { _, _ in Task { await refreshLinearAssigneeMappings() } @@ -3198,33 +3313,33 @@ private struct LinearExtensionSettingsView: View { private var linearRoutingCalloutTitle: String { if linearProfiles.isEmpty { - return "Connect an account first" + return "Connect a provider account first" } if defaultLinearProfileNeedsRepair { - return "Default routing needs repair" + return "Default profile needs repair" } if resolvedDefaultProfile.isEmpty { - return "Set a default remote" + return "Set a default profile" } return "Routing and disconnect behavior" } private var linearRoutingCalloutMessage: String { if linearProfiles.isEmpty { - return "Add a Linear account before ticket or project sync can route through Linear. After the first account connects, set it as the default remote so projects inherit a working profile until they choose their own." + return "Add a Linear provider account before ticket or project sync can route through Linear. After the first account connects, set it as the default profile so projects inherit a working route until they choose their own." } if defaultLinearProfileNeedsRepair { let defaultLabel = displayNameForRemoteProfileId(resolvedDefaultProfile) - return "The default remote \(defaultLabel) no longer has a usable token. Reconnect it or switch the default before the next sync, because projects without an override still inherit that routing target." + return "The default profile \(defaultLabel) no longer has a usable token. Reconnect it or switch the default before the next sync, because projects without an override still inherit that routing target." } if resolvedDefaultProfile.isEmpty { - return "Projects can still choose a specific Linear account, but anything without a per-project override has no fallback remote until you set a default profile here." + return "Projects can still choose a specific Linear provider account, but anything without a per-project override has no fallback route until you set a default profile here." } let defaultLabel = displayNameForRemoteProfileId(resolvedDefaultProfile) - return "Projects inherit \(defaultLabel) until they choose a different Linear profile in project settings. Disconnecting an account removes its token immediately, so reroute or reconnect affected projects before the next sync run." + return "Projects inherit \(defaultLabel) until they choose a different Linear profile in project settings. Disconnecting a provider account removes its token immediately, so reroute or reconnect affected projects before the next sync run." } private var linearSyncPolicySummary: String { @@ -3267,6 +3382,12 @@ private struct LinearExtensionSettingsView: View { lastDiagnosticsRefreshAt = Date() } + private func refreshHackAccountState() async { + isLoadingHackAccount = true + defer { isLoadingHackAccount = false } + hackAccountState = await model.inspectHackAccountSettingsState() + } + private func refreshLinearProfileStatuses() async { guard let diagnostics else { profileStatusById = [:] @@ -3565,12 +3686,12 @@ private struct LinearExtensionSettingsView: View { let disconnected = await model.disconnectLinear(profileId: trimmed) if !disconnected { - message = model.errorMessage ?? "Failed to disconnect Linear account." + message = model.errorMessage ?? "Failed to disconnect the Linear provider account." return } if resolvedDefaultProfile == trimmed { - message = "Disconnected token for \(displayNameForRemoteProfileId(trimmed)). The profile stays available for reconnect, but projects inheriting this default remote need a reconnect or a different default before the next sync." + message = "Disconnected token for \(displayNameForRemoteProfileId(trimmed)). The profile stays available for reconnect, but projects inheriting this default profile need a reconnect or a different default before the next sync." } else { message = "Disconnected token for \(displayNameForRemoteProfileId(trimmed)). The profile stays available for reconnect, and any project routed to it needs a reconnect or a different override before the next sync." } @@ -3692,7 +3813,7 @@ private struct LinearExtensionSettingsView: View { let account = flowStatus.accountName ?? flowStatus.accountEmail ?? flowStatus.accountHandle - ?? "Linear account" + ?? "Linear provider account" message = "Connected \(account) to profile \(flowStatus.profileId). You can now use it for default routing or bind projects to it directly." await model.refresh() await refreshLinearDiagnostics() @@ -3735,6 +3856,8 @@ private struct GitHubExtensionSettingsView: View { @State private var message = "" @State private var lastDiagnosticsRefreshAt: Date? = nil @State private var systemIdentity: GitSystemIdentity? = nil + @State private var hackAccountState: HackAccountSettingsState? = nil + @State private var isLoadingHackAccount = false var body: some View { ScrollView { @@ -3742,18 +3865,25 @@ private struct GitHubExtensionSettingsView: View { SettingsSectionHeader( breadcrumb: "Settings / Extensions / GitHub", title: "GitHub Extension", - subtitle: "Manage connected GitHub accounts and default PR automation routing" + subtitle: "Manage Hack auth prerequisites, connected GitHub provider accounts, and saved PR automation routing" ) + HackAccountSettingsCard( + state: hackAccountState, + isLoading: isLoadingHackAccount, + providerName: "GitHub" + ) { + Task { await refreshHackAccountState() } + } GlassCard(title: "Extension status", systemImage: "chevron.left.forwardslash.chevron.right") { HStack(alignment: .center, spacing: 8) { StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) StatusPill( - text: "\(githubProfiles.count) account\(githubProfiles.count == 1 ? "" : "s")", + text: "\(githubProfiles.count) provider account\(githubProfiles.count == 1 ? "" : "s")", tone: (diagnostics?.profiles.isEmpty == false) ? .good : .neutral ) if !resolvedDefaultProfile.isEmpty { StatusPill( - text: "Default remote: \(displayNameForRemoteProfileId(resolvedDefaultProfile))", + text: "Default profile: \(displayNameForRemoteProfileId(resolvedDefaultProfile))", tone: .neutral ) } @@ -3820,9 +3950,9 @@ private struct GitHubExtensionSettingsView: View { } } - GlassCard(title: "Connected accounts", systemImage: "person.2.badge.gearshape") { + GlassCard(title: "Connected provider accounts", systemImage: "person.2.badge.gearshape") { HStack(alignment: .center, spacing: 10) { - Text("Remote OAuth/App accounts used for remote node Git operations.") + Text("These are the GitHub accounts or app installs Hack can use. Saved profiles decide which provider account a project or node uses.") .font(.mono(.caption)) .foregroundStyle(.secondary) Spacer() @@ -3879,7 +4009,7 @@ private struct GitHubExtensionSettingsView: View { Text(accountHandle.map { "@\($0)" } ?? profile.id) .font(.mono(.subheadline, weight: .semibold)) if profile.isDefault { - StatusPill(text: "Default remote", tone: .good) + StatusPill(text: "Default profile", tone: .good) } StatusPill( text: profile.mode.lowercased() == "app" ? "Remote app" : "Remote OAuth", @@ -3907,12 +4037,12 @@ private struct GitHubExtensionSettingsView: View { } } - Text("profile: \(profile.id)") + Text("routing profile: \(profile.id)") .font(.mono(.caption2)) .foregroundStyle(.secondary) if let accountHandle, !accountHandle.isEmpty { let accountNameSuffix = accountName.map { " (\($0))" } ?? "" - Text("account: @\(accountHandle)\(accountNameSuffix)") + Text("provider account: @\(accountHandle)\(accountNameSuffix)") .font(.mono(.caption2)) .foregroundStyle(.secondary) } @@ -3973,9 +4103,13 @@ private struct GitHubExtensionSettingsView: View { } .task { await loadConfigFromDisk() + await refreshHackAccountState() } .onChange(of: model.lastUpdated) { _, _ in - Task { await loadConfigFromDisk() } + Task { + await loadConfigFromDisk() + await refreshHackAccountState() + } } .onChange(of: model.githubOAuthDeepLinkContext) { _, deepLink in guard let deepLink else { return } @@ -4053,6 +4187,12 @@ private struct GitHubExtensionSettingsView: View { lastDiagnosticsRefreshAt = Date() } + private func refreshHackAccountState() async { + isLoadingHackAccount = true + defer { isLoadingHackAccount = false } + hackAccountState = await model.inspectHackAccountSettingsState() + } + private func refreshGitHubProfileStatuses() async { guard let diagnostics else { profileStatusById = [:] @@ -5190,7 +5330,9 @@ private struct ExtensionsSettingsView: View { @Environment(DashboardModel.self) private var model @Binding var selection: SettingsSidebarItem @State private var isLoading = false + @State private var isLoadingHackAccount = false @State private var suppressToggleChange = false + @State private var hackAccountState: HackAccountSettingsState? = nil @State private var linearEnabled = false @State private var githubEnabled = false @State private var cloudflareEnabled = false @@ -5211,6 +5353,13 @@ private struct ExtensionsSettingsView: View { message: "Internal components like gateway, tickets, and supervisor are configured on dedicated system pages and are intentionally omitted from the extension list.", actions: [] ) + HackAccountSettingsCard( + state: hackAccountState, + isLoading: isLoadingHackAccount, + providerName: nil + ) { + Task { await refreshHackAccountState() } + } GlassCard(title: "Managed extensions", systemImage: "puzzlepiece.extension") { VStack(alignment: .leading, spacing: 12) { extensionSummaryRow( @@ -5277,9 +5426,13 @@ private struct ExtensionsSettingsView: View { } .task { await loadConfigFromDisk() + await refreshHackAccountState() } .onChange(of: model.lastUpdated) { _, _ in - Task { await loadConfigFromDisk() } + Task { + await loadConfigFromDisk() + await refreshHackAccountState() + } } } @@ -5378,6 +5531,12 @@ private struct ExtensionsSettingsView: View { suppressToggleChange = false } + private func refreshHackAccountState() async { + isLoadingHackAccount = true + defer { isLoadingHackAccount = false } + hackAccountState = await model.inspectHackAccountSettingsState() + } + private func setExtensionEnabled( item: SettingsSidebarItem, enabled: Bool diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 8312648e4..1611ffd53 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -62,6 +62,12 @@ public actor HackCLIClient { private static let authRequestTimeoutSeconds: TimeInterval = 10 + private struct BetterAuthStatusEnvelope: Decodable { + let enabled: Bool + let reason: String? + let basePath: String + } + private struct GitHubOAuthStartEnvelope: Decodable { let ok: Bool let flow: GitHubOAuthStartEnvelopeFlow @@ -793,6 +799,50 @@ public actor HackCLIClient { ) } + /// Reads the desktop-visible Hack account/auth broker state. + /// + /// Session identity fields are intentionally optional because the broker only exposes + /// runtime Better Auth availability today. The desktop UI is structured around this + /// contract so real user/org/team fields can be filled in later without another view rewrite. + public func inspectHackAccountSettingsState() async throws -> HackAccountSettingsState { + var lastError: String? = nil + for candidate in resolveAuthServerCandidates() { + guard + let statusURL = buildAuthURL( + base: candidate, + path: "/v1/auth/better-auth/status", + queryItems: [] + ) + else { + continue + } + do { + let body = try await fetchAuthBody(url: statusURL) + if let status = tryDecodeLenient(BetterAuthStatusEnvelope.self, from: body) { + return HackAccountSettingsState( + brokerBaseURL: candidate, + authEnabled: status.enabled, + authReason: normalized(status.reason), + authBasePath: normalized(status.basePath) ?? "/api/auth", + sessionAvailable: false, + userDisplayName: nil, + userEmail: nil, + organizationName: nil, + teamName: nil + ) + } + throw HackCLIError.network("Auth broker returned invalid JSON.") + } catch { + lastError = error.localizedDescription + } + } + + throw HackCLIError.network( + lastError + ?? "Unable to reach any configured auth broker endpoint. Check network/broker status and retry." + ) + } + /// Starts cloud GitHub OAuth with the dedicated auth broker surface. /// /// This flow is intentionally separate from local gateway/daemon bearer-token auth. diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index fe76850ca..4ef5c1a49 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -952,6 +952,40 @@ public struct RailwayInspectResponse: Decodable, Hashable { } } +public struct HackAccountSettingsState: Decodable, Hashable { + public let brokerBaseURL: String + public let authEnabled: Bool + public let authReason: String? + public let authBasePath: String + public let sessionAvailable: Bool + public let userDisplayName: String? + public let userEmail: String? + public let organizationName: String? + public let teamName: String? + + public init( + brokerBaseURL: String, + authEnabled: Bool, + authReason: String?, + authBasePath: String, + sessionAvailable: Bool, + userDisplayName: String?, + userEmail: String?, + organizationName: String?, + teamName: String? + ) { + self.brokerBaseURL = brokerBaseURL + self.authEnabled = authEnabled + self.authReason = authReason + self.authBasePath = authBasePath + self.sessionAvailable = sessionAvailable + self.userDisplayName = userDisplayName + self.userEmail = userEmail + self.organizationName = organizationName + self.teamName = teamName + } +} + public struct GitHubProfileSummary: Decodable, Hashable { public let id: String public let isDefault: Bool From 41855ea0ecee916f1f125de1f23eb251cdfbeda8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 11:19:00 -0500 Subject: [PATCH 12/54] Add Hack auth CLI commands --- src/cli/spec.ts | 2 + src/commands/auth.ts | 369 ++++++++++++++++++++++++++++ src/lib/auth-session.ts | 485 +++++++++++++++++++++++++++++++++++++ tests/auth-command.test.ts | 374 ++++++++++++++++++++++++++++ 4 files changed, 1230 insertions(+) create mode 100644 src/commands/auth.ts create mode 100644 src/lib/auth-session.ts create mode 100644 tests/auth-command.test.ts diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 1f8a1690b..079084d87 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -1,5 +1,6 @@ import pkg from "../../package.json"; import { agentCommand } from "../commands/agent.ts"; +import { authCommand } from "../commands/auth.ts"; import { branchCommand } from "../commands/branch.ts"; import { configCommand } from "../commands/config.ts"; import { crashCaptureCommand } from "../commands/crash-capture.ts"; @@ -54,6 +55,7 @@ export const CLI_SPEC = defineCli({ globalOptions: [], commands: [ globalCommand, + authCommand, statusCommand, usageCommand, projectsCommand, diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 000000000..f99580bfb --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,369 @@ +import type { CommandHandlerFor } from "../cli/command.ts"; +import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; +import { optJson } from "../cli/options.ts"; +import { + deleteHackAuthSession, + fetchHackAuthMe, + type HackAuthMeResponse, + type HackAuthSession, + loadHackAuthSession, + pollHackAuthSessionFlow, + resolveHackAuthBrokerBaseUrl, + saveHackAuthSession, + startHackAuthSessionFlow, +} from "../lib/auth-session.ts"; +import { openUrl } from "../lib/os.ts"; +import { logger } from "../ui/logger.ts"; + +const optNoOpen = defineOption({ + name: "noOpen", + type: "boolean", + long: "--no-open", + description: "Print the browser URL instead of opening it automatically", +} as const); + +const optBrokerUrl = defineOption({ + name: "brokerUrl", + type: "string", + long: "--broker-url", + valueHint: "", + description: "Override the Hack auth broker base URL", +} as const); + +const loginSpec = defineCommand({ + name: "login", + summary: "Open a browser and sign in to Hack auth", + group: "Global", + options: [optJson, optNoOpen, optBrokerUrl] as const, + positionals: [] as const, + subcommands: [] as const, +} as const); + +const logoutSpec = defineCommand({ + name: "logout", + summary: "Clear the locally stored Hack auth session", + group: "Global", + options: [optJson] as const, + positionals: [] as const, + subcommands: [] as const, +} as const); + +const statusSpec = defineCommand({ + name: "status", + summary: "Show whether Hack auth is configured locally", + group: "Global", + options: [optJson, optBrokerUrl] as const, + positionals: [] as const, + subcommands: [] as const, +} as const); + +const whoamiSpec = defineCommand({ + name: "whoami", + summary: "Resolve the current Hack auth identity via the broker", + group: "Global", + options: [optJson, optBrokerUrl] as const, + positionals: [] as const, + subcommands: [] as const, +} as const); + +export const authCommand = defineCommand({ + name: "auth", + summary: "Manage Hack auth sessions", + group: "Global", + expandInRootHelp: true, + options: [] as const, + positionals: [] as const, + subcommands: [ + withHandler(loginSpec, handleAuthLogin), + withHandler(logoutSpec, handleAuthLogout), + withHandler(statusSpec, handleAuthStatus), + withHandler(whoamiSpec, handleAuthWhoami), + ] as const, +} as const); + +type LoginSpec = typeof loginSpec; +type LogoutSpec = typeof logoutSpec; +type StatusSpec = typeof statusSpec; +type WhoamiSpec = typeof whoamiSpec; + +async function handleAuthLogin({ + args, +}: Parameters>[0]): Promise { + const brokerBaseUrl = resolveHackAuthBrokerBaseUrl({ + override: args.options.brokerUrl, + }); + const start = await startHackAuthSessionFlow({ + baseUrl: brokerBaseUrl, + }); + if (!start.ok) { + return writeFailure({ + json: args.options.json === true, + error: start.error, + }); + } + + if (args.options.noOpen) { + logger.info({ + message: `Open this URL to continue Hack auth login: ${start.value.authorizeUrl}`, + }); + } else { + const openExitCode = await openUrl(start.value.authorizeUrl); + if (openExitCode !== 0) { + return writeFailure({ + json: args.options.json === true, + error: + "Failed to open a browser for Hack auth login. Re-run with --no-open and open the URL manually.", + }); + } + } + + const claimed = await pollHackAuthSessionFlow({ + baseUrl: brokerBaseUrl, + flowId: start.value.flowId, + ...(start.value.deviceCode ? { deviceCode: start.value.deviceCode } : {}), + ...(start.value.pollUrl ? { pollUrl: start.value.pollUrl } : {}), + ...(start.value.expiresAt ? { expiresAt: start.value.expiresAt } : {}), + }); + if (!claimed.ok) { + return writeFailure({ + json: args.options.json === true, + error: claimed.error, + }); + } + + await saveHackAuthSession({ + session: claimed.value, + }); + + return writeSuccess({ + json: args.options.json === true, + human: "Hack auth session stored.", + payload: { + ok: true, + authenticated: true, + brokerBaseUrl, + flowId: start.value.flowId, + authorizeUrl: start.value.authorizeUrl, + tokenStored: true, + ...(claimed.value.expiresAt + ? { expiresAt: claimed.value.expiresAt } + : {}), + }, + }); +} + +async function handleAuthLogout({ + args, +}: Parameters>[0]): Promise { + const hadToken = await deleteHackAuthSession(); + return writeSuccess({ + json: args.options.json === true, + human: hadToken + ? "Hack auth session cleared." + : "No Hack auth session was stored.", + payload: { + ok: true, + loggedOut: true, + hadToken, + }, + }); +} + +async function handleAuthStatus({ + args, +}: Parameters>[0]): Promise { + const brokerBaseUrl = resolveHackAuthBrokerBaseUrl({ + override: args.options.brokerUrl, + }); + let stored: HackAuthSession | null; + try { + stored = await loadHackAuthSession(); + } catch (error) { + return writeFailure({ + json: args.options.json === true, + error: toErrorMessage(error), + }); + } + + if (!stored) { + return writeSuccess({ + json: args.options.json === true, + human: "Not authenticated with Hack auth.", + payload: { + ok: true, + authenticated: false, + tokenStored: false, + validated: false, + brokerBaseUrl, + }, + }); + } + + const me = await fetchHackAuthMe({ + baseUrl: brokerBaseUrl, + token: stored.token, + }); + if (!me.ok) { + return writeSuccess({ + json: args.options.json === true, + human: `Hack auth token is stored locally, but broker validation failed: ${me.error}`, + payload: { + ok: true, + authenticated: false, + tokenStored: true, + validated: false, + brokerBaseUrl, + error: me.error, + ...(stored.expiresAt ? { expiresAt: stored.expiresAt } : {}), + }, + }); + } + + return writeSuccess({ + json: args.options.json === true, + human: me.value.authenticated + ? "Hack auth session is valid." + : "Hack auth token is stored, but no authenticated broker session was resolved.", + payload: { + ok: true, + authenticated: me.value.authenticated === true, + tokenStored: true, + validated: true, + brokerBaseUrl, + accessControlMode: me.value.accessControlMode ?? null, + session: me.value.session ?? null, + ...(typeof me.value.user === "object" && me.value.user !== null + ? { user: me.value.user } + : {}), + ...(typeof me.value.activeOrganization === "object" && + me.value.activeOrganization !== null + ? { activeOrganization: me.value.activeOrganization } + : {}), + ...(typeof me.value.activeTeam === "object" && + me.value.activeTeam !== null + ? { activeTeam: me.value.activeTeam } + : {}), + shellPath: + typeof me.value.shellPath === "string" ? me.value.shellPath : "/auth", + accountPath: + typeof me.value.accountPath === "string" + ? me.value.accountPath + : "/auth/account", + ...(stored.expiresAt ? { expiresAt: stored.expiresAt } : {}), + }, + }); +} + +async function handleAuthWhoami({ + args, +}: Parameters>[0]): Promise { + const brokerBaseUrl = resolveHackAuthBrokerBaseUrl({ + override: args.options.brokerUrl, + }); + let stored: HackAuthSession | null; + try { + stored = await loadHackAuthSession(); + } catch (error) { + return writeFailure({ + json: args.options.json === true, + error: toErrorMessage(error), + }); + } + if (!stored) { + return writeFailure({ + json: args.options.json === true, + error: "No stored Hack auth session. Run `hack auth login` first.", + }); + } + + const me = await fetchHackAuthMe({ + baseUrl: brokerBaseUrl, + token: stored.token, + }); + if (!me.ok) { + return writeFailure({ + json: args.options.json === true, + error: me.error, + }); + } + if (me.value.authenticated !== true) { + return writeFailure({ + json: args.options.json === true, + error: + "Stored Hack auth token is not authenticated anymore. Run `hack auth login` again.", + }); + } + + return writeSuccess({ + json: args.options.json === true, + human: renderWhoamiSummary({ + response: me.value, + }), + payload: { + ...me.value, + brokerBaseUrl, + ...(stored.expiresAt ? { expiresAt: stored.expiresAt } : {}), + }, + }); +} + +function writeSuccess(input: { + readonly json: boolean; + readonly human: string; + readonly payload: Record; +}): number { + if (input.json) { + process.stdout.write(`${JSON.stringify(input.payload, null, 2)}\n`); + } else { + logger.success({ + message: input.human, + }); + } + return 0; +} + +function writeFailure(input: { + readonly json: boolean; + readonly error: string; +}): number { + if (input.json) { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + error: input.error, + }, + null, + 2 + )}\n` + ); + } else { + logger.error({ + message: input.error, + }); + } + return 1; +} + +function renderWhoamiSummary(input: { + readonly response: HackAuthMeResponse; +}): string { + const session = input.response.session; + if (!session) { + return "Hack auth session is authenticated, but no session metadata was returned."; + } + + const details = [ + `user=${session.userId ?? "unknown"}`, + `org=${session.organizationId ?? "none"}`, + `team=${session.teamId ?? "none"}`, + ]; + if (input.response.accessControlMode) { + details.push(`mode=${input.response.accessControlMode}`); + } + return `Hack auth: ${details.join(", ")}`; +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} diff --git a/src/lib/auth-session.ts b/src/lib/auth-session.ts new file mode 100644 index 000000000..7ce172471 --- /dev/null +++ b/src/lib/auth-session.ts @@ -0,0 +1,485 @@ +import { isRecord } from "./guards.ts"; +import { resolveSecretStore, type SecretStore } from "./secret-store.ts"; + +const DEFAULT_AUTH_BROKER_URL = "https://auth.hack.broker"; +const DEFAULT_AUTH_BROKER_URL_ENV = "HACK_AUTH_BROKER_URL"; +const AUTH_SESSION_PROJECT_NAME = "hack-auth"; +const AUTH_SESSION_SECRET_KEY = "hack.auth.session"; +const TRAILING_SLASH_REGEX = /\/+$/; +const DEFAULT_POLL_INTERVAL_MS = 1000; +const DEFAULT_POLL_TIMEOUT_MS = 120_000; + +export type HackAuthSession = { + readonly token: string; + readonly expiresAt?: string; +}; + +export type HackAuthStartFlow = { + readonly flowId: string; + readonly authorizeUrl: string; + readonly deviceCode?: string; + readonly pollUrl?: string; + readonly expiresAt?: string; +}; + +export type HackAuthMeResponse = { + readonly ok: boolean; + readonly authenticated?: boolean; + readonly accessControlMode?: string; + readonly session?: { + readonly userId?: string; + readonly organizationId?: string | null; + readonly teamId?: string | null; + readonly managementTokenProfileId?: string | null; + } | null; + readonly shellPath?: string; + readonly accountPath?: string; +} & Record; + +type BrokerResult = + | { + readonly ok: true; + readonly value: T; + } + | { + readonly ok: false; + readonly error: string; + }; + +type AuthSecretStore = Pick; +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit +) => Promise; + +/** + * Resolve the Hack auth broker base URL from CLI flags or environment. + */ +export function resolveHackAuthBrokerBaseUrl(input?: { + readonly override?: string; +}): string { + const raw = + input?.override?.trim() || + process.env[DEFAULT_AUTH_BROKER_URL_ENV]?.trim() || + DEFAULT_AUTH_BROKER_URL; + return raw.replace(TRAILING_SLASH_REGEX, ""); +} + +/** + * Read the locally stored Hack auth broker management token. + */ +export async function loadHackAuthSession(input?: { + readonly store?: AuthSecretStore; +}): Promise { + const store = input?.store ?? (await resolveHackAuthSecretStore()); + const raw = await store.get({ + key: AUTH_SESSION_SECRET_KEY, + }); + if (!raw) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + "Stored Hack auth session is invalid JSON. Run `hack auth logout` to clear it." + ); + } + if ( + !isRecord(parsed) || + typeof parsed.token !== "string" || + !parsed.token.trim() + ) { + throw new Error( + "Stored Hack auth session is invalid. Run `hack auth logout` to clear it." + ); + } + return { + token: parsed.token, + ...(typeof parsed.expiresAt === "string" && parsed.expiresAt.trim() + ? { expiresAt: parsed.expiresAt } + : {}), + }; +} + +/** + * Persist the claimed Hack auth broker management token. + */ +export async function saveHackAuthSession(input: { + readonly session: HackAuthSession; + readonly store?: AuthSecretStore; +}): Promise { + const store = input.store ?? (await resolveHackAuthSecretStore()); + await store.set({ + key: AUTH_SESSION_SECRET_KEY, + value: JSON.stringify(input.session), + }); +} + +/** + * Remove any stored Hack auth broker management token. + */ +export async function deleteHackAuthSession(input?: { + readonly store?: AuthSecretStore; +}): Promise { + const store = input?.store ?? (await resolveHackAuthSecretStore()); + return await store.delete({ + key: AUTH_SESSION_SECRET_KEY, + }); +} + +/** + * Start a browser-driven Hack auth shell flow. + */ +export async function startHackAuthSessionFlow(input?: { + readonly baseUrl?: string; + readonly fetchImpl?: FetchLike; +}): Promise> { + const baseUrl = resolveHackAuthBrokerBaseUrl({ + override: input?.baseUrl, + }); + const response = await requestBrokerJson({ + url: `${baseUrl}/v1/auth/session/start`, + routePath: "/v1/auth/session/start", + fetchImpl: input?.fetchImpl, + }); + if (!response.ok) { + return response; + } + const parsed = parseStartFlowPayload({ + body: response.value, + baseUrl, + }); + if (!parsed.ok) { + return parsed; + } + return parsed; +} + +/** + * Poll and claim a Hack auth shell flow until it yields a management token. + */ +export async function pollHackAuthSessionFlow(input: { + readonly flowId: string; + readonly baseUrl?: string; + readonly deviceCode?: string; + readonly pollUrl?: string; + readonly expiresAt?: string; + readonly pollIntervalMs?: number; + readonly timeoutMs?: number; + readonly fetchImpl?: FetchLike; +}): Promise> { + const baseUrl = resolveHackAuthBrokerBaseUrl({ + override: input.baseUrl, + }); + const timeoutAtMs = resolvePollTimeoutAtMs({ + expiresAt: input.expiresAt, + timeoutMs: input.timeoutMs, + }); + + while (Date.now() < timeoutAtMs) { + const response = await requestBrokerJson({ + url: buildFlowStatusUrl({ + baseUrl, + flowId: input.flowId, + pollUrl: input.pollUrl, + deviceCode: input.deviceCode, + }), + routePath: "/v1/auth/session/flows/:flowId", + fetchImpl: input.fetchImpl, + }); + if (!response.ok) { + return { + ok: false, + error: `Hack auth broker status failed: ${response.error}`, + }; + } + + const claimed = parseFlowClaimResult({ + body: response.value, + }); + if (claimed.state === "claimed") { + return { + ok: true, + value: claimed.session, + }; + } + if (claimed.state === "error") { + return { + ok: false, + error: claimed.error, + }; + } + + await Bun.sleep(input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS); + } + + return { + ok: false, + error: "Timed out waiting for Hack auth login to complete.", + }; +} + +/** + * Resolve the broker-side identity for the stored Hack auth management token. + */ +export async function fetchHackAuthMe(input: { + readonly token: string; + readonly baseUrl?: string; + readonly fetchImpl?: FetchLike; +}): Promise> { + const baseUrl = resolveHackAuthBrokerBaseUrl({ + override: input.baseUrl, + }); + const response = await requestBrokerJson({ + url: `${baseUrl}/v1/auth/me`, + routePath: "/v1/auth/me", + fetchImpl: input.fetchImpl, + init: { + headers: { + accept: "application/json", + authorization: `Bearer ${input.token}`, + }, + }, + }); + if (!response.ok) { + return response; + } + if (!isRecord(response.value)) { + return { + ok: false, + error: "Hack auth broker returned an invalid /v1/auth/me response.", + }; + } + return { + ok: true, + value: response.value as HackAuthMeResponse, + }; +} + +async function resolveHackAuthSecretStore(): Promise { + return await resolveSecretStore({ + projectName: AUTH_SESSION_PROJECT_NAME, + }); +} + +function parseStartFlowPayload(input: { + readonly body: unknown; + readonly baseUrl: string; +}): BrokerResult { + if (!isRecord(input.body)) { + return { + ok: false, + error: + "Hack auth broker returned an invalid session-start response. Update auth-broker and retry.", + }; + } + const flowRecord = isRecord(input.body.flow) ? input.body.flow : input.body; + const flowId = readOptionalString(flowRecord.flowId); + const authorizeUrl = readOptionalString(flowRecord.authorizeUrl); + if (!(flowId && authorizeUrl)) { + return { + ok: false, + error: + "Hack auth broker response was missing `flowId` or `authorizeUrl`. Update auth-broker and retry.", + }; + } + const pollUrl = + readOptionalString(flowRecord.pollUrl) ?? + `${input.baseUrl}/v1/auth/session/flows/${encodeURIComponent(flowId)}`; + return { + ok: true, + value: { + flowId, + authorizeUrl, + ...(readOptionalString(flowRecord.deviceCode) + ? { deviceCode: readOptionalString(flowRecord.deviceCode) } + : {}), + ...(pollUrl ? { pollUrl } : {}), + ...(readOptionalString(flowRecord.expiresAt) + ? { expiresAt: readOptionalString(flowRecord.expiresAt) } + : {}), + }, + }; +} + +function extractStatusRecord(input: { + readonly body: unknown; +}): Record { + if (!isRecord(input.body)) { + return {}; + } + return isRecord(input.body.status) ? input.body.status : input.body; +} + +function buildFlowStatusUrl(input: { + readonly baseUrl: string; + readonly flowId: string; + readonly pollUrl?: string; + readonly deviceCode?: string; +}): string { + const statusUrl = new URL( + input.pollUrl ?? + `/v1/auth/session/flows/${encodeURIComponent(input.flowId)}`, + `${input.baseUrl}/` + ); + statusUrl.searchParams.set("claim", "1"); + if (input.deviceCode) { + statusUrl.searchParams.set("deviceCode", input.deviceCode); + } + return statusUrl.toString(); +} + +function parseFlowClaimResult(input: { readonly body: unknown }): + | { + readonly state: "claimed"; + readonly session: HackAuthSession; + } + | { + readonly state: "pending"; + } + | { + readonly state: "error"; + readonly error: string; + } { + const statusRecord = extractStatusRecord({ + body: input.body, + }); + const status = readOptionalString(statusRecord.status); + if (!status) { + return { + state: "error", + error: + "Hack auth broker status response was missing `status`. Update auth-broker and retry.", + }; + } + if (status === "claimed") { + const token = + readOptionalString(statusRecord.managementToken) ?? + readOptionalString(statusRecord.token); + if (!token) { + return { + state: "error", + error: + "Hack auth completed remotely, but the broker did not return a claimable token.", + }; + } + const expiresAt = + readOptionalString(statusRecord.managementTokenExpiresAt) ?? + readOptionalString(statusRecord.tokenExpiresAt); + return { + state: "claimed", + session: { + token, + ...(expiresAt ? { expiresAt } : {}), + }, + }; + } + if (status === "error") { + return { + state: "error", + error: + readOptionalString(statusRecord.error) ?? + "Hack auth broker reported an unrecoverable login error.", + }; + } + if (status === "expired") { + return { + state: "error", + error: "Hack auth login timed out. Start the flow again.", + }; + } + return { + state: "pending", + }; +} + +async function requestBrokerJson(input: { + readonly url: string; + readonly routePath: string; + readonly fetchImpl?: FetchLike; + readonly init?: RequestInit; +}): Promise> { + const fetchImpl: FetchLike = + input.fetchImpl ?? ((resource, init) => fetch(resource, init)); + let response: Response; + try { + response = await fetchImpl(input.url, { + method: "GET", + ...input.init, + headers: { + accept: "application/json", + ...(input.init?.headers ?? {}), + }, + }); + } catch (error) { + return { + ok: false, + error: `Unable to reach Hack auth broker route ${input.routePath}: ${toErrorMessage(error)}`, + }; + } + + const body = await readJsonBody({ + response, + }); + if (!response.ok) { + const remoteMessage = + isRecord(body) && readOptionalString(body.error) + ? ` (${readOptionalString(body.error)})` + : ""; + return { + ok: false, + error: `Hack auth broker route ${input.routePath} is unavailable or rejected the request (HTTP ${response.status})${remoteMessage}.`, + }; + } + + return { + ok: true, + value: body, + }; +} + +async function readJsonBody(input: { + readonly response: Response; +}): Promise { + const text = await input.response.text(); + if (!text.trim()) { + return {}; + } + try { + return JSON.parse(text); + } catch { + return { + raw: text, + }; + } +} + +function resolvePollTimeoutAtMs(input: { + readonly expiresAt?: string; + readonly timeoutMs?: number; +}): number { + const defaultTimeoutAtMs = + Date.now() + (input.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS); + if (!input.expiresAt) { + return defaultTimeoutAtMs; + } + const expiresAtMs = Date.parse(input.expiresAt); + if (!Number.isFinite(expiresAtMs)) { + return defaultTimeoutAtMs; + } + return Math.min(defaultTimeoutAtMs, expiresAtMs); +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} diff --git a/tests/auth-command.test.ts b/tests/auth-command.test.ts new file mode 100644 index 000000000..39542874c --- /dev/null +++ b/tests/auth-command.test.ts @@ -0,0 +1,374 @@ +import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const openUrlCalls: string[] = []; + +type FetchCall = { + readonly url: string; + readonly init: RequestInit | undefined; +}; + +type FetchLike = ( + input: string | URL | Request, + init?: RequestInit +) => Promise; + +let fetchCalls: FetchCall[] = []; +let fetchImpl: FetchLike = async () => + new Response("not mocked", { + status: 500, + headers: { "content-type": "text/plain" }, + }); + +mock.module("../src/lib/os.ts", () => ({ + isMac: () => process.platform === "darwin", + openUrl: async (url: string) => { + openUrlCalls.push(url); + return 0; + }, +})); + +const originalFetch = globalThis.fetch; +const originalLogger = process.env.HACK_LOGGER; +const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; +const originalSecretsKey = process.env.HACK_SECRETS_FILE_KEY; +const originalHome = process.env.HOME; + +const { runCli } = await import("../src/cli/run.ts"); +const { loadHackAuthSession, saveHackAuthSession } = await import( + "../src/lib/auth-session.ts" +); + +let tempDir: string | null = null; +let tempGlobalConfigPath: string | null = null; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-auth-command-")); + tempGlobalConfigPath = resolve(tempDir, "hack.config.json"); + process.env.HOME = tempDir; + process.env.HACK_GLOBAL_CONFIG_PATH = tempGlobalConfigPath; + process.env.HACK_SECRETS_FILE_KEY = "test-auth-command-key"; + process.env.HACK_LOGGER = "console"; + process.env.HACK_AUTH_BROKER_URL = undefined; + + await writeFile( + tempGlobalConfigPath, + `${JSON.stringify( + { + controlPlane: { + secrets: { + backend: "encrypted_file", + encryptedFile: { + path: resolve(tempDir, "secrets.enc.json"), + }, + }, + }, + }, + null, + 2 + )}\n` + ); + + openUrlCalls.length = 0; + fetchCalls = []; + fetchImpl = async () => + new Response("not mocked", { + status: 500, + headers: { "content-type": "text/plain" }, + }); + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit + ) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + fetchCalls.push({ url, init }); + return await fetchImpl(input, init); + }) as typeof globalThis.fetch; +}); + +afterEach(async () => { + globalThis.fetch = originalFetch; + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + tempDir = null; + tempGlobalConfigPath = null; + + if (originalLogger === undefined) { + process.env.HACK_LOGGER = undefined; + } else { + process.env.HACK_LOGGER = originalLogger; + } + if (originalGlobalConfigPath === undefined) { + process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + } else { + process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + } + if (originalSecretsKey === undefined) { + process.env.HACK_SECRETS_FILE_KEY = undefined; + } else { + process.env.HACK_SECRETS_FILE_KEY = originalSecretsKey; + } + if (originalHome === undefined) { + process.env.HOME = undefined; + } else { + process.env.HOME = originalHome; + } +}); + +afterAll(() => { + mock.restore(); + globalThis.fetch = originalFetch; +}); + +test("auth login opens browser, claims token, and stores the auth session", async () => { + fetchImpl = async (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url === "https://auth.hack.broker/v1/auth/session/start") { + return Response.json({ + ok: true, + flow: { + flowId: "flow-123", + authorizeUrl: + "https://auth.hack.broker/auth?flowId=flow-123&deviceCode=device-123", + deviceCode: "device-123", + pollUrl: "https://auth.hack.broker/v1/auth/session/flows/flow-123", + expiresAt: "2026-03-06T18:00:00.000Z", + socialProviders: [{ id: "github", label: "GitHub" }], + }, + }); + } + if ( + url === + "https://auth.hack.broker/v1/auth/session/flows/flow-123?claim=1&deviceCode=device-123" + ) { + return Response.json({ + ok: true, + status: { + status: "claimed", + managementToken: "hack-session-token", + managementTokenExpiresAt: "2026-03-06T19:00:00.000Z", + }, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }; + + const result = await runCliWithCapturedIo({ + argv: ["auth", "login", "--json"], + }); + + expect(result.exitCode).toBe(0); + expect(openUrlCalls).toEqual([ + "https://auth.hack.broker/auth?flowId=flow-123&deviceCode=device-123", + ]); + expect(fetchCalls.map((call) => call.url)).toEqual([ + "https://auth.hack.broker/v1/auth/session/start", + "https://auth.hack.broker/v1/auth/session/flows/flow-123?claim=1&deviceCode=device-123", + ]); + + const stored = await readStoredTokenEnvelope(); + expect(stored?.token).toBe("hack-session-token"); + expect(stored?.expiresAt).toBe("2026-03-06T19:00:00.000Z"); + + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly authenticated: boolean; + readonly flowId: string; + readonly brokerBaseUrl: string; + readonly tokenStored: boolean; + }; + expect(payload.ok).toBe(true); + expect(payload.authenticated).toBe(true); + expect(payload.flowId).toBe("flow-123"); + expect(payload.brokerBaseUrl).toBe("https://auth.hack.broker"); + expect(payload.tokenStored).toBe(true); +}); + +test("auth whoami uses stored token against broker /v1/auth/me", async () => { + await writeStoredTokenEnvelope({ token: "hack-session-token" }); + + fetchImpl = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + expect(url).toBe("https://auth.hack.broker/v1/auth/me"); + expect(init?.headers).toMatchObject({ + authorization: "Bearer hack-session-token", + }); + return Response.json({ + ok: true, + authenticated: true, + accessControlMode: "better_auth_team_owned", + session: { + userId: "user_123", + organizationId: "org_123", + teamId: "team_123", + }, + }); + }; + + const result = await runCliWithCapturedIo({ + argv: ["auth", "whoami", "--json"], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly authenticated: boolean; + readonly session: { + readonly userId: string; + readonly organizationId: string; + readonly teamId: string; + }; + }; + expect(payload.ok).toBe(true); + expect(payload.authenticated).toBe(true); + expect(payload.session.userId).toBe("user_123"); + expect(payload.session.organizationId).toBe("org_123"); + expect(payload.session.teamId).toBe("team_123"); +}); + +test("auth logout clears the stored auth session", async () => { + await writeStoredTokenEnvelope({ token: "hack-session-token" }); + + const result = await runCliWithCapturedIo({ + argv: ["auth", "logout", "--json"], + }); + + expect(result.exitCode).toBe(0); + const stored = await readStoredTokenEnvelope(); + expect(stored).toBeNull(); + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly loggedOut: boolean; + readonly hadToken: boolean; + }; + expect(payload.ok).toBe(true); + expect(payload.loggedOut).toBe(true); + expect(payload.hadToken).toBe(true); +}); + +test("auth login reports a helpful error when broker session start is unavailable", async () => { + fetchImpl = async (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + expect(url).toBe("https://auth.hack.broker/v1/auth/session/start"); + return Response.json({ error: "not_found" }, { status: 404 }); + }; + + const result = await runCliWithCapturedIo({ + argv: ["auth", "login"], + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("/v1/auth/session/start"); + expect(result.stderr.toLowerCase()).toContain("auth broker"); +}); + +test("auth status reports stored-token state even when broker validation endpoint is missing", async () => { + await writeStoredTokenEnvelope({ token: "hack-session-token" }); + + fetchImpl = async (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + expect(url).toBe("https://auth.hack.broker/v1/auth/me"); + return Response.json({ error: "not_found" }, { status: 404 }); + }; + + const result = await runCliWithCapturedIo({ + argv: ["auth", "status", "--json"], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly authenticated: boolean; + readonly tokenStored: boolean; + readonly validated: boolean; + readonly error: string; + }; + expect(payload.authenticated).toBe(false); + expect(payload.tokenStored).toBe(true); + expect(payload.validated).toBe(false); + expect(payload.error).toContain("/v1/auth/me"); +}); + +type CapturedRunResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +type StoredTokenEnvelope = { + readonly token: string; + readonly expiresAt?: string; +}; + +async function writeStoredTokenEnvelope( + input: StoredTokenEnvelope +): Promise { + await saveHackAuthSession({ + session: input, + }); +} + +async function readStoredTokenEnvelope(): Promise { + return await loadHackAuthSession(); +} + +async function runCliWithCapturedIo(input: { + readonly argv: readonly string[]; +}): Promise { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutChunks.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8") + ); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8") + ); + return true; + }) as typeof process.stderr.write; + + try { + const exitCode = await runCli([...input.argv]); + return { + exitCode, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + }; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +} From a60d5d0d78d856114e1fc21bf2c7329ae04374c2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 11:37:43 -0500 Subject: [PATCH 13/54] Add Hack account auth shell and desktop UX --- .../DashboardFeature/DashboardModel.swift | 22 ++ .../DashboardFeature/ProjectDetailView.swift | 190 +++++++++------ .../SettingsOverlayView.swift | 165 ++++++++++--- .../HackCLIService/HackCLIClient.swift | 157 +++++++++--- .../Sources/HackDesktopModels/Models.swift | 25 ++ ...ack-account-auth-and-provider-ux-design.md | 88 ++++++- ...-hack-account-auth-and-provider-ux-plan.md | 61 +++++ ...-encrypted-project-env-portability-plan.md | 35 +++ services/auth-broker/.env.example | 10 + services/auth-broker/README.md | 147 +++++++++++- .../src/modules/better-auth/shell-plugin.ts | 60 +++++ .../auth-broker/tests/session-auth.test.ts | 19 ++ src/commands/auth.ts | 224 +++++++++++++++--- src/control-plane/extensions/linear/auth.ts | 33 ++- .../extensions/linear/commands.ts | 97 +++++++- src/lib/auth-session.ts | 16 ++ tests/auth-command.test.ts | 134 +++++++++++ tests/linear-commands.test.ts | 44 ++++ 18 files changed, 1332 insertions(+), 195 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 72bddc042..637d8b4ea 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -674,6 +674,28 @@ public final class DashboardModel { } } + public func loginHackAccount() async -> HackAccountSettingsState? { + let didLogin: Bool? = await runActionResult(message: "Signing in to Hack…") { + try await self.client.loginHackAccount() + return true + } + guard didLogin == true else { + return nil + } + return await inspectHackAccountSettingsState() + } + + public func logoutHackAccount() async -> HackAccountSettingsState? { + let didLogout: Bool? = await runActionResult(message: "Signing out of Hack…") { + try await self.client.logoutHackAccount() + return true + } + guard didLogout == true else { + return nil + } + return await inspectHackAccountSettingsState() + } + public func startGitHubOAuthFlow( profileId: String, setDefault: Bool diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index dacbc310c..f3f73c253 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -710,14 +710,14 @@ struct ProjectDetailView: View { description: "Choose whether this repo defaults to local work or a routed remote node.", footnote: executionMode.summary ) { - Picker("Execution mode", selection: $executionMode) { + Picker("", selection: $executionMode) { ForEach(RemoteExecutionMode.allCases) { mode in Text(mode.title).tag(mode) } } .labelsHidden() + .accessibilityLabel("Execution mode") .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) .onChange(of: executionMode) { _, _ in guard !executionTargetLoading else { return @@ -734,15 +734,15 @@ struct ProjectDetailView: View { description: "Applies when execution mode routes work off this Mac.", footnote: selectedNodeSummary ) { - Picker("Default node", selection: $executionTargetNodeId) { + Picker("", selection: $executionTargetNodeId) { Text("Local").tag("") ForEach(executionTargetNodes, id: \.id) { node in Text(node.name).tag(node.id) } } .labelsHidden() + .accessibilityLabel("Default node") .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) .onChange(of: executionTargetNodeId) { _, _ in guard !executionTargetLoading else { return @@ -760,15 +760,15 @@ struct ProjectDetailView: View { description: "Pick the saved GitHub profile this repo should use for remote Git operations.", footnote: selectedGitSummary ) { - Picker("GitHub profile", selection: $githubProjectProfile) { + Picker("", selection: $githubProjectProfile) { Text("Local").tag("") ForEach(githubProfileOptions, id: \.self) { profile in Text(githubProfileLabel(profileId: profile)).tag(profile) } } .labelsHidden() + .accessibilityLabel("GitHub profile") .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) .onChange(of: githubProjectProfile) { _, _ in guard !executionTargetLoading else { return @@ -870,33 +870,38 @@ struct ProjectDetailView: View { ) { if linearResolvedStatus?.tokenResolved == true { projectSettingsControlGroup(footnote: selectedLinearProfileSummary) { - HStack(alignment: .center, spacing: 12) { - Picker("Saved profile", selection: $linearProjectProfile) { - Text("Inherited").tag("") - ForEach(linearProfileOptions, id: \.self) { profile in - Text(linearProfileLabel(profileId: profile)).tag(profile) + VStack(alignment: .leading, spacing: 10) { + projectSettingsFieldLabel("Project profile") + projectSettingsControlShell(maxWidth: 320) { + Picker("", selection: $linearProjectProfile) { + Text("Inherited").tag("") + ForEach(linearProfileOptions, id: \.self) { profile in + Text(linearProfileLabel(profileId: profile)).tag(profile) + } } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 320, alignment: .leading) - .onChange(of: linearProjectProfile) { _, _ in - guard !executionTargetLoading else { - return + .labelsHidden() + .accessibilityLabel("Saved profile") + .pickerStyle(.menu) + .onChange(of: linearProjectProfile) { _, _ in + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } } - Task { await persistLinearProfileOverride() } } - StatusPill(text: "Connected", tone: .good) - Spacer() - Button("Open Linear settings") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) + HStack(alignment: .center, spacing: 12) { + StatusPill(text: "Connected", tone: .good) + Spacer() + Button("Open Linear settings") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + } + .adaptiveToolbarButton() } - .adaptiveToolbarButton() } } } else { @@ -921,35 +926,39 @@ struct ProjectDetailView: View { description: "This is the primary Linear project route for project-level pull, push, and review." ) { projectSettingsControlGroup(footnote: selectedLinearProjectSummary) { - HStack(alignment: .center, spacing: 12) { - Picker("Default project route", selection: $selectedLinearProjectId) { - Text("Unbound").tag("") - ForEach(linearProjectOptions) { linearProject in - Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) - .disabled(linearProjectOptions.isEmpty) - .onChange(of: selectedLinearProjectId) { _, newValue in - guard !executionTargetLoading else { - return + VStack(alignment: .leading, spacing: 10) { + projectSettingsFieldLabel("Primary route") + projectSettingsControlShell(maxWidth: 360) { + Picker("", selection: $selectedLinearProjectId) { + Text("Unbound").tag("") + ForEach(linearProjectOptions) { linearProject in + Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + } } - guard newValue != linearBoundProjectId else { - return + .labelsHidden() + .accessibilityLabel("Default project route") + .pickerStyle(.menu) + .disabled(linearProjectOptions.isEmpty) + .onChange(of: selectedLinearProjectId) { _, newValue in + guard !executionTargetLoading else { + return + } + guard newValue != linearBoundProjectId else { + return + } + Task { await persistLinearProjectBindingSelection() } } - Task { await persistLinearProjectBindingSelection() } } - if !linearBoundProjectId.isEmpty { - Button("Clear") { - Task { await clearLinearProjectBinding() } + HStack(alignment: .center, spacing: 12) { + if !linearBoundProjectId.isEmpty { + Button("Clear") { + Task { await clearLinearProjectBinding() } + } + .adaptiveToolbarButton() } - .adaptiveToolbarButton() + Spacer() } - - Spacer() } } @@ -975,29 +984,34 @@ struct ProjectDetailView: View { } projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { - HStack(spacing: 8) { - Picker("Add linked project", selection: $selectedAdditionalLinearProjectId) { - Text("Choose project").tag("") - ForEach(availableAdditionalLinearProjects) { linearProject in - Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + VStack(alignment: .leading, spacing: 10) { + projectSettingsFieldLabel("Add linked project") + HStack(spacing: 8) { + projectSettingsControlShell(maxWidth: 360) { + Picker("", selection: $selectedAdditionalLinearProjectId) { + Text("Choose project").tag("") + ForEach(availableAdditionalLinearProjects) { linearProject in + Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + } + } + .labelsHidden() + .accessibilityLabel("Add linked project") + .pickerStyle(.menu) + .disabled(availableAdditionalLinearProjects.isEmpty) } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 360, alignment: .leading) - .disabled(availableAdditionalLinearProjects.isEmpty) - Button("Add") { - Task { await persistAdditionalLinearProjectSelection() } - } - .adaptiveToolbarButton() - .disabled( - selectedAdditionalLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - ) + Button("Add") { + Task { await persistAdditionalLinearProjectSelection() } + } + .adaptiveToolbarButton() + .disabled( + selectedAdditionalLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + ) - Spacer() + Spacer() + } } } } @@ -1077,13 +1091,15 @@ struct ProjectDetailView: View { footnote: String, @ViewBuilder content: () -> Content ) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { Text(title) .font(.system(size: 13, weight: .semibold)) Text(description) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) - content() + projectSettingsControlShell(maxWidth: 360) { + content() + } projectSettingsFootnote(footnote) } } @@ -1098,6 +1114,34 @@ struct ProjectDetailView: View { } } + private func projectSettingsFieldLabel(_ text: String) -> some View { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + + private func projectSettingsControlShell( + maxWidth: CGFloat? = nil, + @ViewBuilder content: () -> Content + ) -> some View { + HStack(spacing: 0) { + content() + Spacer(minLength: 0) + } + .frame(maxWidth: maxWidth, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(colorScheme == .dark ? 0.05 : 0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(colorScheme == .dark ? 0.12 : 0.08), lineWidth: 1) + ) + } + private func routingSheetSection( title: String, description: String, diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index 807d89781..af4384eb6 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -422,32 +422,60 @@ struct SettingsSectionHeader: View { } private struct HackAccountSettingsCard: View { + @Environment(DashboardModel.self) private var model let state: HackAccountSettingsState? let isLoading: Bool let providerName: String? let onRefresh: () -> Void + @State private var isSubmitting = false var body: some View { GlassCard(title: "Hack account", systemImage: "person.crop.circle.badge.checkmark") { HStack(alignment: .center, spacing: 8) { if let state { StatusPill( - text: state.authEnabled ? "Broker auth enabled" : "Broker auth unavailable", + text: state.authEnabled ? "Hack auth ready" : "Hack auth unavailable", tone: state.authEnabled ? .good : .warn ) StatusPill( - text: state.sessionAvailable ? "Session details available" : "Desktop session pending", - tone: state.sessionAvailable ? .good : .neutral + text: state.authenticated ? "Signed in" : "Signed out", + tone: state.authenticated ? .good : .neutral + ) + StatusPill( + text: state.validated ? "Broker validated" : state.tokenStored ? "Stored locally" : "No local session", + tone: state.validated ? .good : state.tokenStored ? .neutral : .warn + ) + StatusPill( + text: state.manageAccountAvailable ? "Manage account ready" : "Manage account unavailable", + tone: state.manageAccountAvailable ? .good : .neutral ) } else { - StatusPill(text: "Broker status unavailable", tone: .warn) + StatusPill(text: "Auth status unavailable", tone: .warn) } Spacer() + Button("Manage account") { + openResolvedManageURL() + } + .adaptiveToolbarButton() + .disabled(resolvedManageURL == nil) + if let state, state.authenticated { + Button("Sign out") { + Task { await handleSignOut() } + } + .adaptiveToolbarButton() + .disabled(isBusy) + } else { + Button("Sign in") { + Task { await handleSignIn() } + } + .adaptiveToolbarButtonProminent() + .disabled(isBusy || state?.authEnabled == false) + } Button("Refresh") { onRefresh() } .adaptiveToolbarButton() - if isLoading { + if isBusy { ProgressView() .controlSize(.small) } @@ -458,49 +486,40 @@ private struct HackAccountSettingsCard: View { .foregroundStyle(.secondary) if let state { - if state.sessionAvailable { - if let userLabel = state.userDisplayName, !userLabel.isEmpty { - Text("hack user: \(userLabel)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } + if state.authenticated { + detailRow(title: "Hack user", value: state.userDisplayName ?? "Authenticated session") if let email = state.userEmail, !email.isEmpty { - Text("email: \(email)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) + detailRow(title: "Email", value: email, selectable: true) } if let organizationName = state.organizationName, !organizationName.isEmpty { - Text("organization: \(organizationName)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) + detailRow(title: "Organization", value: organizationName) } if let teamName = state.teamName, !teamName.isEmpty { - Text("team: \(teamName)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) + detailRow(title: "Team", value: teamName) } } else { - Text( - "Desktop sign-in details are not exposed here yet. This card is reserved for first-party Hack identity, while provider pages below manage provider accounts and saved routing profiles." - ) + Text(unauthenticatedMessage(for: state)) .font(.mono(.caption2)) .foregroundStyle(.tertiary) } - Text("broker: \(state.brokerBaseURL)\(state.authBasePath)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) + detailRow( + title: "Manage account", + value: state.manageAccountAvailable + ? "Available in browser" + : "Available after broker auth is ready" + ) + detailRow(title: "Broker", value: state.brokerBaseURL, selectable: true) if let reason = state.authReason, !reason.isEmpty { - Text("auth status: \(reason)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + detailRow(title: "Session note", value: reason) + } + if let accessControlMode = state.accessControlMode, !accessControlMode.isEmpty { + detailRow(title: "Access mode", value: accessControlMode) } } else { Text( - "Hack Desktop could not read auth broker status yet. Provider account setup still depends on the broker being reachable." + "Hack Desktop could not read Hack auth status yet. Shared provider setup still depends on the broker being reachable." ) .font(.mono(.caption2)) .foregroundStyle(.tertiary) @@ -509,12 +528,90 @@ private struct HackAccountSettingsCard: View { } private var summaryMessage: String { + if let state, state.authenticated { + if let providerName, !providerName.isEmpty { + return + "\(providerName) accounts below are connected under this Hack account. Saved profiles stay local to this Mac and only decide routing defaults." + } + return + "This Hack account owns shared provider connections and other broker-backed features. Local-only project and ticket workflows remain available without sign-in." + } + if let providerName, !providerName.isEmpty { return - "\(providerName) accounts below are provider accounts connected through your Hack workspace. Saved profiles are local routing labels Hack uses for defaults and project overrides; they are not separate Hack users." + "Sign in to Hack before connecting \(providerName) for shared remote features. Linked login methods and account management stay in the browser, while saved profiles here remain local routing labels." } return - "Use this card to verify Hack auth is available before connecting external providers. GitHub and Linear pages manage provider accounts and saved routing profiles, not your first-party Hack identity." + "Use this card to sign in to Hack for shared remote features. External provider pages manage connected provider accounts and local routing profiles, not your first-party Hack identity." + } + + private var isBusy: Bool { + isLoading || isSubmitting + } + + private var resolvedManageURL: String? { + guard let state else { + return nil + } + return state.accountURL ?? state.shellURL + } + + private func handleSignIn() async { + isSubmitting = true + defer { isSubmitting = false } + if await model.loginHackAccount() != nil { + onRefresh() + return + } + onRefresh() + } + + private func handleSignOut() async { + isSubmitting = true + defer { isSubmitting = false } + if await model.logoutHackAccount() != nil { + onRefresh() + return + } + onRefresh() + } + + private func openResolvedManageURL() { + guard let urlString = resolvedManageURL, let url = URL(string: urlString) else { + return + } + NSWorkspace.shared.open(url) + } + + private func unauthenticatedMessage(for state: HackAccountSettingsState) -> String { + if state.tokenStored && !state.validated { + return "Hack has a locally stored session token, but the broker could not validate it. Sign in again to refresh this desktop session." + } + if state.tokenStored { + return "Hack has a stored session, but no authenticated browser session is active right now. Sign in again to continue." + } + if state.authEnabled { + return "Sign in to Hack before managing shared provider accounts, project routing, or other broker-backed features from this Mac." + } + return "Hack auth is not fully available on the broker yet, so shared remote features are unavailable from this Mac." + } + + private func detailRow(title: String, value: String, selectable: Bool = false) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + if selectable { + Text(value) + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } else { + Text(value) + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.primary) + } + } } } diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 1611ffd53..4254adde9 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -68,6 +68,47 @@ public actor HackCLIClient { let basePath: String } + private struct HackAuthStatusEnvelope: Decodable { + let ok: Bool + let authenticated: Bool? + let tokenStored: Bool? + let validated: Bool? + let brokerBaseUrl: String? + let accessControlMode: String? + let authReason: String? + let error: String? + let user: HackAuthUserEnvelope? + let activeOrganization: HackAuthNamedEntityEnvelope? + let activeTeam: HackAuthNamedEntityEnvelope? + let shellPath: String? + let accountPath: String? + } + + private struct HackAuthUserEnvelope: Decodable { + let id: String? + let email: String? + let name: String? + let emailVerified: Bool? + } + + private struct HackAuthNamedEntityEnvelope: Decodable { + let id: String? + let name: String? + } + + private struct HackAuthLoginEnvelope: Decodable { + let ok: Bool + let authenticated: Bool? + let brokerBaseUrl: String? + let authorizeUrl: String? + } + + private struct HackAuthLogoutEnvelope: Decodable { + let ok: Bool + let loggedOut: Bool? + let hadToken: Bool? + } + private struct GitHubOAuthStartEnvelope: Decodable { let ok: Bool let flow: GitHubOAuthStartEnvelopeFlow @@ -799,50 +840,92 @@ public actor HackCLIClient { ) } - /// Reads the desktop-visible Hack account/auth broker state. + /// Reads the desktop-visible Hack account/auth broker state through the public CLI surface. /// - /// Session identity fields are intentionally optional because the broker only exposes - /// runtime Better Auth availability today. The desktop UI is structured around this - /// contract so real user/org/team fields can be filled in later without another view rewrite. + /// The desktop app intentionally mirrors `hack auth status --json` instead of reaching into + /// broker-only pages so browser login, local secret storage, and provider-linked identity + /// resolve through the same path as the CLI. public func inspectHackAccountSettingsState() async throws -> HackAccountSettingsState { - var lastError: String? = nil - for candidate in resolveAuthServerCandidates() { - guard - let statusURL = buildAuthURL( - base: candidate, - path: "/v1/auth/better-auth/status", - queryItems: [] - ) - else { - continue - } - do { - let body = try await fetchAuthBody(url: statusURL) - if let status = tryDecodeLenient(BetterAuthStatusEnvelope.self, from: body) { - return HackAccountSettingsState( - brokerBaseURL: candidate, - authEnabled: status.enabled, - authReason: normalized(status.reason), - authBasePath: normalized(status.basePath) ?? "/api/auth", - sessionAvailable: false, - userDisplayName: nil, - userEmail: nil, - organizationName: nil, - teamName: nil - ) - } - throw HackCLIError.network("Auth broker returned invalid JSON.") - } catch { - lastError = error.localizedDescription - } + let result = try await run(["auth", "status", "--json"], allowNonZeroExit: true) + let payload = try decodeJsonOrThrow(HackAuthStatusEnvelope.self, result: result) + guard payload.ok else { + throw HackCLIError.network( + normalized(payload.error) ?? "Hack auth status returned an unexpected response." + ) } - throw HackCLIError.network( - lastError - ?? "Unable to reach any configured auth broker endpoint. Check network/broker status and retry." + let brokerBaseURL = normalized(payload.brokerBaseUrl) + ?? resolveAuthServerCandidates().first + ?? "https://auth.hack.broker" + let betterAuthStatus = try? await fetchBetterAuthStatus(baseURL: brokerBaseURL) + let shellPath = normalized(payload.shellPath) ?? "/auth" + let accountPath = normalized(payload.accountPath) ?? "/auth/account" + let shellURL = buildAuthURL(base: brokerBaseURL, path: shellPath, queryItems: [])?.absoluteString + let accountURL = + buildAuthURL(base: brokerBaseURL, path: accountPath, queryItems: [])?.absoluteString + let userLabel = normalized(payload.user?.name) + ?? normalized(payload.user?.email) + ?? normalized(payload.user?.id) + let organizationLabel = normalized(payload.activeOrganization?.name) + ?? normalized(payload.activeOrganization?.id) + let teamLabel = normalized(payload.activeTeam?.name) + ?? normalized(payload.activeTeam?.id) + + return HackAccountSettingsState( + brokerBaseURL: brokerBaseURL, + authEnabled: betterAuthStatus?.enabled ?? false, + authReason: + normalized(payload.authReason) + ?? normalized(payload.error) + ?? normalized(betterAuthStatus?.reason), + authBasePath: normalized(betterAuthStatus?.basePath) ?? "/api/auth", + authenticated: payload.authenticated == true, + validated: payload.validated == true, + tokenStored: payload.tokenStored == true, + accessControlMode: normalized(payload.accessControlMode), + shellURL: shellURL, + accountURL: accountURL, + sessionAvailable: payload.authenticated == true, + userDisplayName: userLabel, + userEmail: normalized(payload.user?.email), + organizationName: organizationLabel, + teamName: teamLabel ) } + public func loginHackAccount() async throws { + let result = try await run(["auth", "login", "--json"], allowNonZeroExit: true) + let payload = try decodeJsonOrThrow(HackAuthLoginEnvelope.self, result: result) + guard payload.ok else { + throw HackCLIError.network("Hack auth login did not complete.") + } + } + + public func logoutHackAccount() async throws { + let result = try await run(["auth", "logout", "--json"], allowNonZeroExit: true) + let payload = try decodeJsonOrThrow(HackAuthLogoutEnvelope.self, result: result) + guard payload.ok else { + throw HackCLIError.network("Hack auth logout did not complete.") + } + } + + private func fetchBetterAuthStatus(baseURL: String) async throws -> BetterAuthStatusEnvelope { + guard + let statusURL = buildAuthURL( + base: baseURL, + path: "/v1/auth/better-auth/status", + queryItems: [] + ) + else { + throw HackCLIError.network("Invalid auth broker URL.") + } + let body = try await fetchAuthBody(url: statusURL) + guard let status = tryDecodeLenient(BetterAuthStatusEnvelope.self, from: body) else { + throw HackCLIError.network("Auth broker returned invalid JSON.") + } + return status + } + /// Starts cloud GitHub OAuth with the dedicated auth broker surface. /// /// This flow is intentionally separate from local gateway/daemon bearer-token auth. diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 4ef5c1a49..42f5a335a 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -957,6 +957,12 @@ public struct HackAccountSettingsState: Decodable, Hashable { public let authEnabled: Bool public let authReason: String? public let authBasePath: String + public let authenticated: Bool + public let validated: Bool + public let tokenStored: Bool + public let accessControlMode: String? + public let shellURL: String? + public let accountURL: String? public let sessionAvailable: Bool public let userDisplayName: String? public let userEmail: String? @@ -968,6 +974,12 @@ public struct HackAccountSettingsState: Decodable, Hashable { authEnabled: Bool, authReason: String?, authBasePath: String, + authenticated: Bool, + validated: Bool, + tokenStored: Bool, + accessControlMode: String?, + shellURL: String?, + accountURL: String?, sessionAvailable: Bool, userDisplayName: String?, userEmail: String?, @@ -978,12 +990,25 @@ public struct HackAccountSettingsState: Decodable, Hashable { self.authEnabled = authEnabled self.authReason = authReason self.authBasePath = authBasePath + self.authenticated = authenticated + self.validated = validated + self.tokenStored = tokenStored + self.accessControlMode = accessControlMode + self.shellURL = shellURL + self.accountURL = accountURL self.sessionAvailable = sessionAvailable self.userDisplayName = userDisplayName self.userEmail = userEmail self.organizationName = organizationName self.teamName = teamName } + + public var manageAccountAvailable: Bool { + guard let accountURL else { + return false + } + return !accountURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } } public struct GitHubProfileSummary: Decodable, Hashable { diff --git a/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md index 0acbfde3c..7015eb228 100644 --- a/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md +++ b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md @@ -26,6 +26,33 @@ This design introduces a first-class, optional Hack account layer while keeping - Improve macOS configuration UX so forms are compact, styled, and easier to scan. - Establish the next design boundary for remote encrypted project/env portability. +## Approved Contract + +The approved account/auth redesign is based on a small first-party auth shell +hosted by `auth-broker` plus local client storage of a broker management token. + +### Auth shell routes + +The broker-owned Hack account surface is: + +- `GET /auth` +- `GET /auth/account` +- `GET /v1/auth/session/start` +- `GET /v1/auth/session/flows/:flowId` +- `GET /v1/auth/me` + +The expected client flow is: + +1. CLI or macOS starts `GET /v1/auth/session/start` +2. Browser opens the returned `authorizeUrl` +3. User signs into Hack through `/auth` +4. Client polls `GET /v1/auth/session/flows/:flowId?claim=1` +5. Client stores the claimed broker management token locally + +This token is a Hack-account bootstrap credential for broker-owned resources. +It is not a replacement for provider tokens and it is not the same thing as a +gateway or daemon transport token. + ## Non-Goals - Do not make local project/runtime/ticket usage require sign-in. @@ -60,6 +87,22 @@ This gives the product a clear rule: - local-only = no login required - shared/remote = login required +### Local vs remote examples + +Local-only examples: + +- `hack up`, `hack down`, `hack logs`, `hack env` +- local git-backed tickets +- local Git identity and local provider tokens used only on-device +- project routing decisions that do not call the broker + +Remote/shared examples: + +- broker-owned Linear connections, deliveries, and autosync subscriptions +- future broker-owned GitHub management surfaces +- future remote encrypted project/env bundles +- any state owned by a Hack user, org, or team in `auth-broker` + ## Identity Model There are three different concepts and the UI must keep them separate. @@ -126,11 +169,8 @@ Current broker code already supports: - Better Auth with email/password - GitHub social login in Better Auth -- organization/team plugin - -Current broker code does not yet support: - - Google social login in Better Auth +- organization/team plugin ### Design decision @@ -140,7 +180,20 @@ That means: - if only GitHub is configured, show GitHub sign-in - if Google is added, show Google sign-in too -- do not hardcode a product promise around Google before the broker exposes it +- if both are configured, show both +- do not hardcode provider buttons outside env-driven broker metadata + +### Configuration rule + +Social login configuration belongs to the broker, not to the desktop app. + +- `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` enable GitHub as a Hack login method +- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` enable Google as a Hack login method +- the auth shell should show only the providers that are configured at runtime + +GitHub is a special case because the broker also uses the GitHub OAuth app +credentials for GitHub provider integration flows. The UX still needs to keep +`Sign in to Hack with GitHub` separate from `Connect GitHub integration`. ## Web Surface @@ -199,7 +252,8 @@ Add a first-class `hack auth` command group. - opens the auth shell in browser - completes local session/bootstrap -- stores enough local state to identify the signed-in Hack user/session for broker-backed workflows +- stores the claimed Hack broker management token in the existing local secret backend +- does not overwrite provider-specific local auth by default #### `hack auth status` @@ -314,6 +368,17 @@ The broker already has the right ownership enforcement direction: The missing product layer is session visibility and a clean login path in CLI/macOS. +### Account linking contract + +Account linking is intentionally strict: + +- automatic linking requires a verified provider email +- different emails do not auto-link +- unverified emails do not auto-link +- trusted-provider bypasses are off by default + +This protects the Hack account boundary even when multiple login methods are available. + ## Remote Secret Portability Direction This is not part of the immediate implementation, but the auth model should leave room for it. @@ -326,6 +391,8 @@ We should explore remote encrypted project/env storage with: - remote portability across macOS/Linux/nodes - less dependence on single-machine keychain custody - clear ownership under Hack account/org/team +- immutable bundle versions with explicit apply/publish checkpoints +- no provider-token portability in the initial design ### Constraint @@ -336,6 +403,15 @@ Track it as follow-on architecture work. - `docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md` +### Dependency on Hack auth + +Remote env portability must build on the Hack account layer, not on provider auth. + +- ownership is Hack user/org/team scoped +- broker stores encrypted bundle metadata and ciphertext +- local clients still decrypt/apply for the initial slice +- provider integrations such as Linear are orthogonal to env portability + ## Implementation Priorities ### Immediate diff --git a/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md index afbf634e4..88bbb5277 100644 --- a/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md +++ b/docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md @@ -8,6 +8,62 @@ **Tech Stack:** Bun, Elysia, Better Auth, Drizzle/Neon, TypeScript, SwiftUI, Hack CLI ticket/config/auth flows. +## Approved Documentation Contract + +The docs/config slice for this redesign must keep five points aligned across +broker docs, env examples, and plans: + +1. Hack auth is a first-party account/session layer exposed through `/auth`, + `/auth/account`, `/v1/auth/session/start`, `/v1/auth/session/flows/:flowId`, + and `/v1/auth/me`. +2. Local-only Hack workflows remain unauthenticated. +3. Broker-owned/shared features require Hack sign-in. +4. Login methods and provider integrations are separate concepts. +5. Remote encrypted env portability is a follow-on that depends on Hack-account + ownership rather than provider identity. + +## Provider Configuration Rules + +The auth shell must remain provider-driven and env-driven. + +- `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` enable GitHub as a Hack login method. +- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` enable Google as a Hack login method. +- The auth shell should render only the providers configured at runtime. +- GitHub provider env is dual-use in the current broker: it powers both GitHub + provider OAuth routes and GitHub-as-a-Hack-login in Better Auth. +- Google env is currently relevant only to Hack sign-in, not to a separate + provider integration surface. + +## Verified-Email Linking Rules + +The operational contract for account linking is: + +- auto-link only when provider email is present and verified +- refuse auto-link on missing email +- refuse auto-link on unverified email +- refuse auto-link across mismatched emails +- keep trusted-provider bypass lists empty by default + +## Local vs Remote Boundary + +Use this product rule consistently in docs and UI copy: + +- local-only = no Hack login required +- shared/remote = Hack login required + +Examples that should stay on the remote/shared side: + +- broker-owned Linear connections, subscriptions, and deliveries +- future broker-owned GitHub management surfaces +- future remote encrypted project/env bundle storage + +Examples that should stay local-only: + +- runtime orchestration +- local sessions +- local git-backed tickets +- local secret storage and local provider tokens used only on-device + --- ### Task 1: Document and tighten broker auth requirements @@ -316,6 +372,7 @@ git commit -m "feat: add optional Google auth provider" **Files:** - Modify: `services/auth-broker/README.md` - Modify: `docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md` +- Modify: `docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md` - Create/Modify: auth usage docs discovered during implementation **Step 1: Write the failing doc check** @@ -325,6 +382,7 @@ List the doc gaps explicitly in the PR/task notes before editing: - local-only vs remote/shared auth requirements - provider-driven auth shell behavior - Google optionality +- Hack-auth dependency for remote encrypted env portability **Step 2: Update docs minimally** @@ -333,6 +391,9 @@ Document: - broker-required feature guidance - provider integration ownership model - sign-in methods +- verified-email account-linking policy +- the local-storage boundary for claimed broker management tokens +- the remote-ownership boundary for future env portability **Step 3: Run docs-adjacent verification** diff --git a/docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md b/docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md index 1e08bfe47..ebc0b4870 100644 --- a/docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md +++ b/docs/plans/2026-03-06-remote-encrypted-project-env-portability-plan.md @@ -8,6 +8,30 @@ **Tech Stack:** Bun, Elysia auth-broker, Better Auth user/org/team ownership, Drizzle/Postgres broker tables, Hack CLI env commands, macOS Hack Desktop follow-on UX. +## Dependency on Hack Account Auth + +This plan depends on the approved Hack account/auth redesign. + +### Required auth assumptions + +- Hack account auth is provided through the broker auth shell +- CLI/macOS can bootstrap a local broker management token through + `GET /v1/auth/session/start`, `GET /v1/auth/session/flows/:flowId`, and + `GET /v1/auth/me` +- remote resources are owned by Hack user/org/team scope, not by provider identity +- provider connections such as Linear remain separate resources under that Hack account + +### Boundary with provider auth + +Remote encrypted env portability must not be modeled as: + +- a GitHub integration feature +- a Linear integration feature +- a keychain-export convenience layer for provider tokens + +It is a Hack-account-owned remote secret portability feature with its own +authorization and audit boundary. + --- ## Scope Boundary @@ -38,6 +62,7 @@ - remote portability is opt-in per project - remote env state is owned by a Hack account scope, not by a provider profile - broker stores encrypted payloads and metadata, not editable plaintext +- local provider auth remains separate and is not made portable by this plan ### Ownership model @@ -89,6 +114,15 @@ For the first slice, choose one wrap path and keep it simple: The critical design constraint is that the broker remains the durable registry for the encrypted payload and ownership metadata. The first slice should not require a single machine-local keychain to remain the only recovery path. +### Local vs remote secret boundary + +Keep the plaintext boundary narrow: + +- local client may read and decrypt plaintext during explicit publish/apply +- broker stores ciphertext, wrapped-key material, and metadata +- remote portability does not imply remote plaintext editing in the broker +- remote portability does not imply migration of provider OAuth tokens + ### Apply model The first implementation slice should use client-mediated apply: @@ -396,4 +430,5 @@ git commit -m "Show portable env binding state in desktop" ## Links - `docs/plans/2026-03-06-hack-account-auth-and-provider-ux-design.md` +- `docs/plans/2026-03-06-hack-account-auth-and-provider-ux-plan.md` - `T-00194` diff --git a/services/auth-broker/.env.example b/services/auth-broker/.env.example index 6a8d78a6a..d61146ca6 100644 --- a/services/auth-broker/.env.example +++ b/services/auth-broker/.env.example @@ -1,4 +1,7 @@ # GitHub OAuth app credentials +# Used for: +# - GitHub provider OAuth routes (`/v1/auth/github/*`, `/gh/*`) +# - GitHub as a Better Auth social login method in `/auth` GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= # Optional OAuth scope hint; default is read:user,user:email,read:org. @@ -36,13 +39,20 @@ PORT=8080 FLOW_STORE_PATH=.data/oauth-flows.json # Better Auth + Neon runtime +# These power the Hack account/auth shell and broker-owned remote resources. DATABASE_URL= NEON_PROJECT_ID= BETTER_AUTH_SECRET= BETTER_AUTH_URL=https://auth.hack.broker +# Comma-separated first-party origins allowed to call Better Auth. BETTER_AUTH_TRUSTED_ORIGINS=http://127.0.0.1:8080 +# Optional Better Auth social login providers shown in `/auth`. +# GitHub uses the GitHub OAuth app credentials above. GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# Optional callback-side user bootstrap helpers. These do not relax verified-email +# account-linking policy; they only allow new Better Auth user creation when no +# matching user already exists. # Optional: auto-provision Better Auth users from GitHub email during callback. BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS=false # Optional: auto-provision Better Auth users from Linear email during callback. diff --git a/services/auth-broker/README.md b/services/auth-broker/README.md index 6416209ea..ef0647f0d 100644 --- a/services/auth-broker/README.md +++ b/services/auth-broker/README.md @@ -14,6 +14,64 @@ Minimal Bun + Elysia auth broker for browser-based provider callbacks. This keeps provider auth UX one-click while storing long-term credentials on the client host. +## Hack account vs provider integrations + +The broker now has two distinct identity layers and the docs/UI need to keep +them separate. + +### 1. Hack account auth + +Hack account auth is first-party identity handled through Better Auth and the +broker auth shell: + +- `GET /auth` +- `GET /auth/account` +- `GET /v1/auth/session/start` +- `GET /v1/auth/session/flows/:flowId` +- `GET /v1/auth/me` + +CLI and macOS use this flow to claim and locally store a broker management +token for remote broker-owned features. + +### 2. Provider integrations + +Provider integrations are separate resources that sit under a Hack account. +Examples: + +- GitHub provider OAuth/app installs +- Linear OAuth profiles, webhook deliveries, and autosync subscriptions + +Signing into Hack with GitHub is not the same thing as connecting a GitHub +integration. The same separation applies to any future providers. + +## Local vs remote boundary + +Hack remains local-first. + +### Local-only flows + +These should continue to work without Hack sign-in: + +- project/runtime orchestration +- local sessions +- local git-backed tickets +- local secret storage +- local provider tokens used purely on-device + +### Remote/shared flows + +These require Hack auth because they are broker-owned: + +- broker-managed Linear connections +- broker-managed deliveries and autosync subscriptions +- future broker-managed GitHub account/org surfaces +- future remote encrypted project/env bundle portability + +The product rule is: + +- local-only = no Hack login required +- shared/remote = Hack login required + ## Security boundary `auth-broker` handles cloud identity/provider OAuth only. @@ -100,6 +158,18 @@ The claimed token is a signed broker management token. It can be stored by the CLI/macOS app and sent as `Authorization: Bearer ` to protected broker routes when a browser cookie session is not present. +This token is: + +- local client state +- scoped to Hack-account broker access +- separate from provider-specific access tokens + +This token is not: + +- a replacement for provider OAuth/app tokens +- a gateway or daemon transport bearer token +- a general-purpose local secret export mechanism + `GET /v1/auth/me` resolves either: 1. the current Better Auth browser session, or @@ -113,10 +183,72 @@ account-linking groundwork through Better Auth's own linking policy: 2. different provider emails are not allowed by default 3. no trusted providers are pre-whitelisted +## Social provider configuration + +The auth shell is provider-driven and env-driven. + +### GitHub + +`GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` are currently dual-use: + +- they enable GitHub OAuth provider routes for integration flows +- they also enable GitHub as a Hack login method in `/auth` + +Because of that, product surfaces must still distinguish: + +- `Sign in to Hack with GitHub` +- `Connect GitHub integration` + +### Google + +`GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are now relevant because they +enable Google as a Hack login method in the Better Auth shell. + +Google is currently: + +- a Hack sign-in method +- rendered only when both env vars are configured +- not a separate provider-integration surface in this broker + +### Rendering rule + +The auth shell should render only the configured social providers at runtime. +Do not hardcode provider promises in desktop or CLI copy that the broker is not +currently configured to serve. + +## Verified-email account linking + +Hack account linking is intentionally strict. + +Automatic linking is allowed only when: + +- the provider returns an email +- that email is verified +- the normalized email matches an existing Hack user + +Automatic linking is refused when: + +- email is missing +- email is unverified +- emails do not match + +Trusted-provider bypasses are empty by default. This is an auth boundary, not a +convenience feature. + +## Remote env portability boundary + +Remote encrypted project/env portability is follow-on work and should build on +Hack account ownership, not on provider identity. + +- ownership is Hack user/org/team scoped +- broker stores ciphertext plus metadata, not general plaintext env state +- provider-token portability is out of scope +- local decrypt/apply remains the first implementation boundary + ## Environment -1. `GITHUB_CLIENT_ID` (required) -2. `GITHUB_CLIENT_SECRET` (required) +1. `GITHUB_CLIENT_ID` (required; enables GitHub provider OAuth and GitHub social sign-in) +2. `GITHUB_CLIENT_SECRET` (required; enables GitHub provider OAuth and GitHub social sign-in) 3. `GITHUB_SCOPES` (optional; default: `read:user,user:email,read:org`) 4. `GITHUB_APP_ID` (optional; returned in flow metadata for app-mode binding) 5. `GITHUB_APP_SLUG` (optional; used to build install URL) @@ -132,8 +264,8 @@ account-linking groundwork through Better Auth's own linking policy: 15. `BETTER_AUTH_SECRET` (required to enable Better Auth runtime) 16. `BETTER_AUTH_URL` (optional base URL override) 17. `BETTER_AUTH_TRUSTED_ORIGINS` (optional comma-separated origins) -18. `GOOGLE_CLIENT_ID` (optional; enables Google social sign-in in Better Auth shell) -19. `GOOGLE_CLIENT_SECRET` (optional; enables Google social sign-in in Better Auth shell) +18. `GOOGLE_CLIENT_ID` (optional; enables Google social sign-in in `/auth`) +19. `GOOGLE_CLIENT_SECRET` (optional; enables Google social sign-in in `/auth`) 20. `BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from GitHub email if no match exists) 21. `BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from Linear email if no match exists) 22. `HACK_LINEAR_CLIENT_ID` (recommended Linear OAuth client id) @@ -149,6 +281,13 @@ account-linking groundwork through Better Auth's own linking policy: 32. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) 33. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) +`services/auth-broker/.env.example` reflects the recommended grouping: + +- GitHub env powers both provider OAuth and GitHub sign-in +- Google env powers Google sign-in only +- Better Auth env powers Hack account/session ownership +- Linear env powers provider integration flows and agent webhooks + When running broker from repo root (`bun run auth:dev`), Linear env aliases also fallback to root `.env.local` / `.env` if process env values are unset. diff --git a/services/auth-broker/src/modules/better-auth/shell-plugin.ts b/services/auth-broker/src/modules/better-auth/shell-plugin.ts index 7136a595b..656e8f6e0 100644 --- a/services/auth-broker/src/modules/better-auth/shell-plugin.ts +++ b/services/auth-broker/src/modules/better-auth/shell-plugin.ts @@ -220,15 +220,41 @@ export function createBetterAuthShellPlugin({ error: runtime.reason ?? "Better Auth is not configured.", } as const; } + const rawSession = await runtime.auth.api.getSession({ + headers: request.headers, + }); const resolvedSession = await resolveBetterAuthSession({ runtime, request, }); + const browserSession = toBrowserSessionUser({ + session: rawSession, + }); + const activeOrganization = extractNamedEntity({ + session: rawSession, + kind: "organization", + fallbackId: resolvedSession.session?.organizationId ?? null, + }); + const activeTeam = extractNamedEntity({ + session: rawSession, + kind: "team", + fallbackId: resolvedSession.session?.teamId ?? null, + }); return { ok: true, authenticated: Boolean(resolvedSession.session), accessControlMode: resolvedSession.accessControlMode, session: resolvedSession.session, + user: browserSession + ? { + id: browserSession.id, + email: browserSession.email, + name: browserSession.name, + emailVerified: browserSession.emailVerified, + } + : null, + activeOrganization, + activeTeam, socialProviders: getSocialProviders({ runtime }), accountLinkingPolicy: getAccountLinkingPolicy({ runtime }), shellPath: "/auth", @@ -545,6 +571,40 @@ function toBrowserSessionUser(input: { }; } +function extractNamedEntity(input: { + readonly session: unknown; + readonly kind: "organization" | "team"; + readonly fallbackId: string | null; +}): { readonly id: string; readonly name: string | null } | null { + const record = readRecord(input.session); + const sessionRecord = readRecord(record?.session); + const topLevelKey = + input.kind === "organization" ? "activeOrganization" : "activeTeam"; + const entityRecord = + readRecord(sessionRecord?.[topLevelKey]) ?? + readRecord(record?.[topLevelKey]); + const id = + normalizeText(entityRecord?.id) ?? + normalizeText( + sessionRecord?.[ + input.kind === "organization" ? "activeOrganizationId" : "activeTeamId" + ] + ) ?? + normalizeText( + record?.[ + input.kind === "organization" ? "activeOrganizationId" : "activeTeamId" + ] + ) ?? + input.fallbackId; + if (!id) { + return null; + } + return { + id, + name: normalizeText(entityRecord?.name), + }; +} + function renderAccountBody(input: { readonly session: Awaited< ReturnType diff --git a/services/auth-broker/tests/session-auth.test.ts b/services/auth-broker/tests/session-auth.test.ts index 0b1419882..19bd57522 100644 --- a/services/auth-broker/tests/session-auth.test.ts +++ b/services/auth-broker/tests/session-auth.test.ts @@ -206,6 +206,20 @@ describe("broker Hack session auth flow", () => { expect(meResponse.status).toBe(200); const mePayload = (await meResponse.json()) as { readonly ok: true; + readonly user: { + readonly id: string; + readonly email: string | null; + readonly name: string | null; + readonly emailVerified: boolean; + } | null; + readonly activeOrganization: { + readonly id: string; + readonly name: string | null; + } | null; + readonly activeTeam: { + readonly id: string; + readonly name: string | null; + } | null; readonly session: { readonly userId: string; readonly email: string | null; @@ -215,6 +229,11 @@ describe("broker Hack session auth flow", () => { } | null; }; expect(mePayload.ok).toBe(true); + expect(mePayload.user?.id).toBe("user-123"); + expect(mePayload.user?.email).toBe("hack@example.com"); + expect(mePayload.user?.emailVerified).toBe(true); + expect(mePayload.activeOrganization?.id).toBe("org-123"); + expect(mePayload.activeTeam?.id).toBe("team-123"); expect(mePayload.session?.userId).toBe("user-123"); expect(mePayload.session?.email).toBe("hack@example.com"); expect(mePayload.session?.organizationId).toBe("org-123"); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index f99580bfb..845c4b525 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -135,16 +135,40 @@ async function handleAuthLogin({ session: claimed.value, }); + const me = await fetchHackAuthMe({ + baseUrl: brokerBaseUrl, + token: claimed.value.token, + }); + const identityPayload = me.ok + ? buildAuthIdentityPayload({ + response: me.value, + }) + : {}; + const loginHuman = + me.ok && me.value.authenticated === true + ? renderWhoamiSummary({ + response: me.value, + }) + : "Hack auth session stored."; + return writeSuccess({ json: args.options.json === true, - human: "Hack auth session stored.", + human: loginHuman, payload: { ok: true, authenticated: true, + validated: me.ok && me.value.authenticated === true, brokerBaseUrl, flowId: start.value.flowId, authorizeUrl: start.value.authorizeUrl, tokenStored: true, + loginRequired: false, + ...identityPayload, + ...(me.ok + ? {} + : { + warning: `Hack auth session was stored, but broker identity could not be resolved yet: ${me.error}`, + }), ...(claimed.value.expiresAt ? { expiresAt: claimed.value.expiresAt } : {}), @@ -188,12 +212,14 @@ async function handleAuthStatus({ if (!stored) { return writeSuccess({ json: args.options.json === true, - human: "Not authenticated with Hack auth.", + human: "Not authenticated with Hack auth. Run `hack auth login`.", payload: { ok: true, authenticated: false, tokenStored: false, validated: false, + loginRequired: true, + nextStep: "Run `hack auth login`.", brokerBaseUrl, }, }); @@ -206,12 +232,14 @@ async function handleAuthStatus({ if (!me.ok) { return writeSuccess({ json: args.options.json === true, - human: `Hack auth token is stored locally, but broker validation failed: ${me.error}`, + human: `Hack auth token is stored locally, but broker validation failed: ${me.error} Run \`hack auth login\` if the session expired.`, payload: { ok: true, authenticated: false, tokenStored: true, validated: false, + loginRequired: true, + nextStep: "Run `hack auth login` if the stored session is stale.", brokerBaseUrl, error: me.error, ...(stored.expiresAt ? { expiresAt: stored.expiresAt } : {}), @@ -222,33 +250,25 @@ async function handleAuthStatus({ return writeSuccess({ json: args.options.json === true, human: me.value.authenticated - ? "Hack auth session is valid." - : "Hack auth token is stored, but no authenticated broker session was resolved.", + ? renderAuthStatusSummary({ + response: me.value, + }) + : "Hack auth token is stored, but no authenticated broker session was resolved. Run `hack auth login` again.", payload: { ok: true, authenticated: me.value.authenticated === true, tokenStored: true, validated: true, + loginRequired: me.value.authenticated !== true, + ...(me.value.authenticated === true + ? {} + : { + nextStep: "Run `hack auth login` again.", + }), brokerBaseUrl, - accessControlMode: me.value.accessControlMode ?? null, - session: me.value.session ?? null, - ...(typeof me.value.user === "object" && me.value.user !== null - ? { user: me.value.user } - : {}), - ...(typeof me.value.activeOrganization === "object" && - me.value.activeOrganization !== null - ? { activeOrganization: me.value.activeOrganization } - : {}), - ...(typeof me.value.activeTeam === "object" && - me.value.activeTeam !== null - ? { activeTeam: me.value.activeTeam } - : {}), - shellPath: - typeof me.value.shellPath === "string" ? me.value.shellPath : "/auth", - accountPath: - typeof me.value.accountPath === "string" - ? me.value.accountPath - : "/auth/account", + ...buildAuthIdentityPayload({ + response: me.value, + }), ...(stored.expiresAt ? { expiresAt: stored.expiresAt } : {}), }, }); @@ -273,6 +293,10 @@ async function handleAuthWhoami({ return writeFailure({ json: args.options.json === true, error: "No stored Hack auth session. Run `hack auth login` first.", + payload: { + loginRequired: true, + nextStep: "Run `hack auth login` first.", + }, }); } @@ -284,6 +308,10 @@ async function handleAuthWhoami({ return writeFailure({ json: args.options.json === true, error: me.error, + payload: { + loginRequired: true, + nextStep: "Run `hack auth login` if the stored session is stale.", + }, }); } if (me.value.authenticated !== true) { @@ -291,6 +319,10 @@ async function handleAuthWhoami({ json: args.options.json === true, error: "Stored Hack auth token is not authenticated anymore. Run `hack auth login` again.", + payload: { + loginRequired: true, + nextStep: "Run `hack auth login` again.", + }, }); } @@ -325,6 +357,7 @@ function writeSuccess(input: { function writeFailure(input: { readonly json: boolean; readonly error: string; + readonly payload?: Record; }): number { if (input.json) { process.stdout.write( @@ -332,6 +365,7 @@ function writeFailure(input: { { ok: false, error: input.error, + ...(input.payload ?? {}), }, null, 2 @@ -349,21 +383,143 @@ function renderWhoamiSummary(input: { readonly response: HackAuthMeResponse; }): string { const session = input.response.session; - if (!session) { - return "Hack auth session is authenticated, but no session metadata was returned."; + const userLabel = formatHackAuthUserLabel({ + response: input.response, + }); + const orgLabel = formatHackAuthOrgLabel({ + response: input.response, + }); + const teamLabel = formatHackAuthTeamLabel({ + response: input.response, + }); + const details: string[] = []; + if (orgLabel) { + details.push(`org: ${orgLabel}`); + } + if (teamLabel) { + details.push(`team: ${teamLabel}`); } - - const details = [ - `user=${session.userId ?? "unknown"}`, - `org=${session.organizationId ?? "none"}`, - `team=${session.teamId ?? "none"}`, - ]; if (input.response.accessControlMode) { - details.push(`mode=${input.response.accessControlMode}`); + details.push(`mode: ${input.response.accessControlMode}`); + } + if (session || userLabel || orgLabel || teamLabel) { + const headline = userLabel + ? `Signed in to Hack auth as ${userLabel}` + : "Signed in to Hack auth"; + return details.length > 0 + ? `${headline} (${details.join(", ")})` + : headline; } - return `Hack auth: ${details.join(", ")}`; + return "Hack auth session is authenticated, but no session metadata was returned."; } function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "Unknown error"; } + +function buildAuthIdentityPayload(input: { + readonly response: HackAuthMeResponse; +}): Record { + return { + accessControlMode: input.response.accessControlMode ?? null, + session: input.response.session ?? null, + ...(input.response.user ? { user: input.response.user } : {}), + ...(input.response.activeOrganization + ? { activeOrganization: input.response.activeOrganization } + : {}), + ...(input.response.activeTeam + ? { activeTeam: input.response.activeTeam } + : {}), + shellPath: + typeof input.response.shellPath === "string" + ? input.response.shellPath + : "/auth", + accountPath: + typeof input.response.accountPath === "string" + ? input.response.accountPath + : "/auth/account", + }; +} + +function renderAuthStatusSummary(input: { + readonly response: HackAuthMeResponse; +}): string { + return renderWhoamiSummary({ + response: input.response, + }); +} + +function formatHackAuthUserLabel(input: { + readonly response: HackAuthMeResponse; +}): string | null { + const name = + typeof input.response.user?.name === "string" + ? input.response.user.name.trim() + : ""; + const email = + typeof input.response.user?.email === "string" + ? input.response.user.email.trim() + : ""; + if (name && email) { + return `${name} <${email}>`; + } + if (email) { + return email; + } + if (name) { + return name; + } + const userId = + typeof input.response.session?.userId === "string" + ? input.response.session.userId.trim() + : ""; + return userId || null; +} + +function formatHackAuthOrgLabel(input: { + readonly response: HackAuthMeResponse; +}): string | null { + const name = + typeof input.response.activeOrganization?.name === "string" + ? input.response.activeOrganization.name.trim() + : ""; + const slug = + typeof input.response.activeOrganization?.slug === "string" + ? input.response.activeOrganization.slug.trim() + : ""; + if (name) { + return slug && slug !== name ? `${name} (${slug})` : name; + } + if (slug) { + return slug; + } + const orgId = + typeof input.response.session?.organizationId === "string" + ? input.response.session.organizationId.trim() + : ""; + return orgId || null; +} + +function formatHackAuthTeamLabel(input: { + readonly response: HackAuthMeResponse; +}): string | null { + const name = + typeof input.response.activeTeam?.name === "string" + ? input.response.activeTeam.name.trim() + : ""; + const slug = + typeof input.response.activeTeam?.slug === "string" + ? input.response.activeTeam.slug.trim() + : ""; + if (name) { + return slug && slug !== name ? `${name} (${slug})` : name; + } + if (slug) { + return slug; + } + const teamId = + typeof input.response.session?.teamId === "string" + ? input.response.session.teamId.trim() + : ""; + return teamId || null; +} diff --git a/src/control-plane/extensions/linear/auth.ts b/src/control-plane/extensions/linear/auth.ts index bc8af2f38..41b342b65 100644 --- a/src/control-plane/extensions/linear/auth.ts +++ b/src/control-plane/extensions/linear/auth.ts @@ -1,5 +1,6 @@ import { secrets } from "bun"; +import { loadHackAuthSession } from "../../../lib/auth-session.ts"; import { isRecord } from "../../../lib/guards.ts"; import type { ControlPlaneConfig } from "../../sdk/config.ts"; @@ -541,9 +542,23 @@ export async function resolveLinearBrokerManagementToken(input: { const envelope = parseStoredTokenEnvelope(stored); const managementToken = envelope?.managementToken?.trim() ?? ""; if (!managementToken) { + const genericHackSession = await loadHackAuthSession().catch(() => null); + const genericManagementToken = genericHackSession?.token?.trim() ?? ""; + if (genericManagementToken) { + return { + ok: true, + managementToken: genericManagementToken, + ...(genericHackSession?.expiresAt + ? { managementTokenExpiresAt: genericHackSession.expiresAt } + : {}), + profileId: settings.profileId, + authRef, + service, + }; + } return { ok: false, - error: `Linear broker management token is missing for profile "${settings.profileId}". Reconnect this Linear profile to manage broker-backed deliveries and autosync subscriptions.`, + error: `Linear broker management token is missing for profile "${settings.profileId}". Run \`hack auth login\` for broker-owned access, or reconnect this Linear profile if its saved broker token is stale.`, profileId: settings.profileId, authRef, service, @@ -557,9 +572,23 @@ export async function resolveLinearBrokerManagementToken(input: { managementTokenExpiresAtMs !== null && managementTokenExpiresAtMs <= Date.now() ) { + const genericHackSession = await loadHackAuthSession().catch(() => null); + const genericManagementToken = genericHackSession?.token?.trim() ?? ""; + if (genericManagementToken) { + return { + ok: true, + managementToken: genericManagementToken, + ...(genericHackSession?.expiresAt + ? { managementTokenExpiresAt: genericHackSession.expiresAt } + : {}), + profileId: settings.profileId, + authRef, + service, + }; + } return { ok: false, - error: `Linear broker management token expired for profile "${settings.profileId}". Reconnect this Linear profile to manage broker-backed deliveries and autosync subscriptions.`, + error: `Linear broker management token expired for profile "${settings.profileId}". Run \`hack auth login\` for broker-owned access, or reconnect this Linear profile to refresh its saved broker token.`, profileId: settings.profileId, authRef, service, diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index f0bdecdee..fb30b1cc5 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -5770,7 +5770,17 @@ async function listLinearDeliveries(input: { }, }); if (!response.ok) { - lastError = response.error; + lastError = normalizeBrokerProtectedLinearError({ + error: response.error, + profileId: brokerAuth.profileId, + }); + if ( + shouldStopLinearBrokerPathFallback({ + error: response.error, + }) + ) { + return { ok: false, error: lastError }; + } continue; } @@ -5835,7 +5845,17 @@ async function applyLinearDelivery(input: { }, }); if (!response.ok) { - lastError = response.error; + lastError = normalizeBrokerProtectedLinearError({ + error: response.error, + profileId: brokerAuth.profileId, + }); + if ( + shouldStopLinearBrokerPathFallback({ + error: response.error, + }) + ) { + return { ok: false, error: lastError }; + } continue; } const payload = parseLinearDeliveryApplyPayload({ @@ -5888,7 +5908,13 @@ async function listLinearAutosyncSubscriptions(input: { }, }); if (!response.ok) { - return response; + return { + ok: false, + error: normalizeBrokerProtectedLinearError({ + error: response.error, + profileId: brokerAuth.profileId, + }), + }; } const payload = parseLinearAutosyncSubscriptionsPayload({ payload: response.value, @@ -5948,7 +5974,13 @@ async function upsertLinearAutosyncSubscription(input: { }, }); if (!response.ok) { - return response; + return { + ok: false, + error: normalizeBrokerProtectedLinearError({ + error: response.error, + profileId: brokerAuth.profileId, + }), + }; } const payload = parseLinearAutosyncSubscriptionMutationPayload({ payload: response.value, @@ -6004,7 +6036,13 @@ async function removeLinearAutosyncSubscription(input: { }, }); if (!response.ok) { - return response; + return { + ok: false, + error: normalizeBrokerProtectedLinearError({ + error: response.error, + profileId: brokerAuth.profileId, + }), + }; } const payload = parseLinearAutosyncSubscriptionMutationPayload({ payload: response.value, @@ -6054,6 +6092,54 @@ async function resolveLinearBrokerAuthorization(input: { }; } +function shouldStopLinearBrokerPathFallback(input: { + readonly error: string; +}): boolean { + return isLinearBrokerProtectedAuthError({ + error: input.error, + }); +} + +function normalizeBrokerProtectedLinearError(input: { + readonly error: string; + readonly profileId: string; +}): string { + const error = input.error.trim(); + if (!error) { + return error; + } + if (error.includes("hack auth login")) { + return error; + } + if (error === "better_auth_session_required") { + return `Linear broker access for profile "${input.profileId}" requires Hack account login. Run \`hack auth login\` and retry.`; + } + if ( + error === "management_token_missing" || + error === "management_token_invalid" || + error === "management_token_expired" + ) { + return `Linear broker access for profile "${input.profileId}" needs a fresh Hack account session. Run \`hack auth login\` and retry.`; + } + if (error === "better_auth_profile_forbidden") { + return `The current Hack account does not have access to Linear profile "${input.profileId}". Run \`hack auth login\` with the correct account, or choose a permitted profile.`; + } + return error; +} + +function isLinearBrokerProtectedAuthError(input: { + readonly error: string; +}): boolean { + const error = input.error.trim(); + return ( + error === "better_auth_session_required" || + error === "better_auth_profile_forbidden" || + error === "management_token_missing" || + error === "management_token_invalid" || + error === "management_token_expired" + ); +} + function parseLinearDeliveriesListPayload(input: { readonly payload: unknown; readonly profileId?: string; @@ -7533,6 +7619,7 @@ export const __testOnly = { buildOAuthArgsFromConnectArgs, detectAuthoritativeFieldConflicts, findProjectBindingTarget, + normalizeBrokerProtectedLinearError, parseApplyDeliveryArgs, parseAutosyncSubscriptionsArgs, parseAssigneeMappingsArgs, diff --git a/src/lib/auth-session.ts b/src/lib/auth-session.ts index 7ce172471..8390672f9 100644 --- a/src/lib/auth-session.ts +++ b/src/lib/auth-session.ts @@ -32,6 +32,22 @@ export type HackAuthMeResponse = { readonly teamId?: string | null; readonly managementTokenProfileId?: string | null; } | null; + readonly user?: { + readonly id?: string; + readonly email?: string; + readonly name?: string; + readonly emailVerified?: boolean; + } | null; + readonly activeOrganization?: { + readonly id?: string; + readonly slug?: string; + readonly name?: string; + } | null; + readonly activeTeam?: { + readonly id?: string; + readonly slug?: string; + readonly name?: string; + } | null; readonly shellPath?: string; readonly accountPath?: string; } & Record; diff --git a/tests/auth-command.test.ts b/tests/auth-command.test.ts index 39542874c..a00d12ff6 100644 --- a/tests/auth-command.test.ts +++ b/tests/auth-command.test.ts @@ -163,6 +163,33 @@ test("auth login opens browser, claims token, and stores the auth session", asyn }, }); } + if (url === "https://auth.hack.broker/v1/auth/me") { + return Response.json({ + ok: true, + authenticated: true, + accessControlMode: "better_auth_team_owned", + session: { + userId: "user_123", + organizationId: "org_123", + teamId: "team_123", + }, + user: { + id: "user_123", + email: "dio@hack.dance", + name: "Dio", + }, + activeOrganization: { + id: "org_123", + slug: "hack", + name: "Hack", + }, + activeTeam: { + id: "team_123", + slug: "cli", + name: "CLI", + }, + }); + } throw new Error(`Unexpected fetch URL: ${url}`); }; @@ -177,6 +204,7 @@ test("auth login opens browser, claims token, and stores the auth session", asyn expect(fetchCalls.map((call) => call.url)).toEqual([ "https://auth.hack.broker/v1/auth/session/start", "https://auth.hack.broker/v1/auth/session/flows/flow-123?claim=1&deviceCode=device-123", + "https://auth.hack.broker/v1/auth/me", ]); const stored = await readStoredTokenEnvelope(); @@ -189,12 +217,26 @@ test("auth login opens browser, claims token, and stores the auth session", asyn readonly flowId: string; readonly brokerBaseUrl: string; readonly tokenStored: boolean; + readonly accessControlMode: string; + readonly user?: { + readonly email?: string; + }; + readonly activeOrganization?: { + readonly slug?: string; + }; + readonly activeTeam?: { + readonly slug?: string; + }; }; expect(payload.ok).toBe(true); expect(payload.authenticated).toBe(true); expect(payload.flowId).toBe("flow-123"); expect(payload.brokerBaseUrl).toBe("https://auth.hack.broker"); expect(payload.tokenStored).toBe(true); + expect(payload.accessControlMode).toBe("better_auth_team_owned"); + expect(payload.user?.email).toBe("dio@hack.dance"); + expect(payload.activeOrganization?.slug).toBe("hack"); + expect(payload.activeTeam?.slug).toBe("cli"); }); test("auth whoami uses stored token against broker /v1/auth/me", async () => { @@ -244,6 +286,70 @@ test("auth whoami uses stored token against broker /v1/auth/me", async () => { expect(payload.session.teamId).toBe("team_123"); }); +test("auth status includes resolved account, org, and team metadata", async () => { + await writeStoredTokenEnvelope({ token: "hack-session-token" }); + + fetchImpl = async () => + Response.json({ + ok: true, + authenticated: true, + accessControlMode: "better_auth_team_owned", + user: { + id: "user_123", + email: "hack@example.com", + name: "Hack User", + emailVerified: true, + }, + activeOrganization: { + id: "org_123", + name: "Hack Org", + }, + activeTeam: { + id: "team_123", + name: "Infra", + }, + session: { + userId: "user_123", + organizationId: "org_123", + teamId: "team_123", + }, + shellPath: "/auth", + accountPath: "/auth/account", + }); + + const result = await runCliWithCapturedIo({ + argv: ["auth", "status", "--json"], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly authenticated: boolean; + readonly validated: boolean; + readonly user?: { + readonly id: string; + readonly email: string; + readonly name: string; + }; + readonly activeOrganization?: { + readonly id: string; + readonly name: string; + }; + readonly activeTeam?: { + readonly id: string; + readonly name: string; + }; + readonly shellPath?: string; + readonly accountPath?: string; + }; + expect(payload.authenticated).toBe(true); + expect(payload.validated).toBe(true); + expect(payload.user?.email).toBe("hack@example.com"); + expect(payload.activeOrganization?.name).toBe("Hack Org"); + expect(payload.activeTeam?.name).toBe("Infra"); + expect(payload.shellPath).toBe("/auth"); + expect(payload.accountPath).toBe("/auth/account"); +}); + test("auth logout clears the stored auth session", async () => { await writeStoredTokenEnvelope({ token: "hack-session-token" }); @@ -316,6 +422,34 @@ test("auth status reports stored-token state even when broker validation endpoin expect(payload.error).toContain("/v1/auth/me"); }); +test("auth status interactive output tells the user to run hack auth login when no session exists", async () => { + const result = await runCliWithCapturedIo({ + argv: ["auth", "status"], + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Not authenticated with Hack auth"); + expect(result.stderr).toContain("hack auth login"); +}); + +test("auth status json includes a next step when login is required", async () => { + const result = await runCliWithCapturedIo({ + argv: ["auth", "status", "--json"], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly authenticated: boolean; + readonly tokenStored: boolean; + readonly loginRequired?: boolean; + readonly nextStep?: string; + }; + expect(payload.authenticated).toBe(false); + expect(payload.tokenStored).toBe(false); + expect(payload.loginRequired).toBe(true); + expect(payload.nextStep).toContain("hack auth login"); +}); + type CapturedRunResult = { readonly exitCode: number; readonly stdout: string; diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index 940e0ad3b..ec95b22ab 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; +import { resolveLinearToken } from "../src/control-plane/extensions/linear/auth.ts"; import { __testOnly } from "../src/control-plane/extensions/linear/commands.ts"; const minimalLinearBindingConfig = { @@ -583,6 +584,49 @@ test("parseRemoveAutosyncSubscriptionArgs parses optional scope", () => { }); }); +test("normalizeBrokerProtectedLinearError tells the user to run hack auth login when broker auth is required", () => { + const message = __testOnly.normalizeBrokerProtectedLinearError({ + error: "better_auth_session_required", + profileId: "work", + }); + + expect(message).toContain("hack auth login"); + expect(message).toContain('profile "work"'); +}); + +test("normalizeBrokerProtectedLinearError explains profile access failures", () => { + const message = __testOnly.normalizeBrokerProtectedLinearError({ + error: "better_auth_profile_forbidden", + profileId: "work", + }); + + expect(message).toContain("does not have access"); + expect(message).toContain('profile "work"'); + expect(message).toContain("hack auth login"); +}); + +test("resolveLinearToken keeps local token setup ungated", async () => { + const result = await resolveLinearToken({ + controlPlaneConfig: {} as Parameters< + typeof resolveLinearToken + >[0]["controlPlaneConfig"], + env: {}, + store: { + get: async () => null, + set: async () => undefined, + delete: async () => false, + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error).toContain("hack x linear connect"); + expect(result.error).not.toContain("hack auth login"); +}); + test("parseUpsertAssigneeMappingArgs requires a local assignee and remote target", () => { const parsed = __testOnly.parseUpsertAssigneeMappingArgs({ args: ["--profile", "work", "--local-assignee", "alice@hack"], From fdc63be52e5d3f0d5a6d6f3bccf02d329a1da6e0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 12:23:54 -0500 Subject: [PATCH 14/54] Refine Hack Desktop account and settings UX --- .../DashboardFeature/DashboardView.swift | 222 +++++-- .../NodeTopologySettingsView.swift | 12 +- .../DashboardFeature/ProjectDetailView.swift | 397 ++++++++---- .../SettingsOverlayView.swift | 574 ++++++------------ 4 files changed, 622 insertions(+), 583 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift index 82781fc40..651480524 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift @@ -12,7 +12,8 @@ public struct DashboardView: View { @State private var showCommandPalette = false @State private var showTerminalDrawer = false @State private var showSettingsOverlay = false - @State private var selectedSettingsItem: SettingsSidebarItem = .runtime + @State private var selectedSettingsItem: SettingsSidebarItem = .account + @State private var toolbarHackAccountState: HackAccountSettingsState? = nil @State private var terminalDrawerHeight: CGFloat = 360 @State private var terminalDrawerInitialHeight: CGFloat? = nil @State private var terminalDrawerModel = TerminalDrawerModel(globalShellProject: Self.makeGlobalShellProject()) @@ -105,28 +106,6 @@ public struct DashboardView: View { } } ToolbarItemGroup(placement: .primaryAction) { - ToolbarIconButton( - systemImage: "gearshape", - help: "Open settings", - accessibilityLabel: "Open settings", - symbolTint: titlebarNeutralIconTint, - hoverSymbolTint: titlebarNeutralIconHoverTint, - action: { - openSettings(.runtime) - } - ) - ToolbarIconButton( - systemImage: globalToggleIcon, - hoverSystemImage: globalToggleHoverIcon, - help: globalToggleHelp, - accessibilityLabel: globalToggleAccessibilityLabel, - symbolTint: globalToggleTint, - hoverSymbolTint: globalToggleHoverTint, - action: { - guard !globalToggleIsBusy else { return } - Task { await model.toggleGlobalInfrastructure() } - } - ) ToolbarIconButton( systemImage: "terminal", help: "Toggle terminal", @@ -142,6 +121,7 @@ public struct DashboardView: View { } } ) + toolbarAccountMenu } } .navigationTitle("") @@ -150,6 +130,7 @@ public struct DashboardView: View { .tuneWindowToolbar() .task { model.start() + await refreshToolbarHackAccountState() } .onReceive(NotificationCenter.default.publisher(for: .hackCommandPaletteRequested)) { _ in showCommandPalette = true @@ -202,6 +183,12 @@ public struct DashboardView: View { openSettings(.runtime) } } + .onChange(of: showSettingsOverlay) { _, isVisible in + guard isVisible else { return } + Task { + await refreshToolbarHackAccountState() + } + } .sheet(isPresented: $showCommandPalette) { CommandPaletteView() .environment(model) @@ -263,13 +250,6 @@ public struct DashboardView: View { return model.globalInfraRunning ? "bolt.fill" : "power.circle" } - private var globalToggleHoverIcon: String? { - if globalToggleIsBusy { - return nil - } - return model.globalInfraRunning ? "power.circle.fill" : "bolt.fill" - } - private var updateBadgeHelpText: String { if latestKnownReleaseVersion.isEmpty { return "A new version is available. Click to update." @@ -277,37 +257,6 @@ public struct DashboardView: View { return "Version \(latestKnownReleaseVersion) is available. Click to update." } - private var globalToggleTint: NSColor { - if globalToggleIsBusy { - return .secondaryLabelColor - } - return model.globalInfraRunning ? NSColor.systemGreen : NSColor.systemRed - } - - private var globalToggleHoverTint: NSColor? { - if globalToggleIsBusy { - return nil - } - return model.globalInfraRunning ? NSColor.systemRed : NSColor.systemGreen - } - - private var globalToggleHelp: String { - if let action = model.globalLifecycleAction { - return action == .starting ? "Starting global services…" : "Stopping global services…" - } - if model.globalInfraRunning { - return "Global services are running. Click to stop (`hack global down`)." - } - return "Global services are stopped. Click to start (`hack global up`)." - } - - private var globalToggleAccessibilityLabel: String { - if let action = model.globalLifecycleAction { - return action == .starting ? "Starting global services" : "Stopping global services" - } - return model.globalInfraRunning ? "Stop global services" : "Start global services" - } - private var globalToggleIsBusy: Bool { model.globalLifecycleAction != nil } @@ -610,6 +559,157 @@ public struct DashboardView: View { showSettingsOverlay = true } + private var toolbarAccountMenu: some View { + Menu { + if let state = toolbarHackAccountState { + if let title = toolbarAccountPrimaryLabel { + Text(title) + } + if let subtitle = toolbarAccountSecondaryLabel { + Text(subtitle) + } + if toolbarAccountPrimaryLabel != nil || toolbarAccountSecondaryLabel != nil { + Divider() + } + Button { + openSettings(.account) + } label: { + Label("Account settings", systemImage: "person.crop.circle") + } + if let accountURL = state.accountURL, let url = URL(string: accountURL) { + Button { + NSWorkspace.shared.open(url) + } label: { + Label("Manage account in browser", systemImage: "globe") + } + } + Divider() + if state.authenticated { + Button { + Task { + toolbarHackAccountState = await model.logoutHackAccount() + } + } label: { + Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") + } + } else { + Button { + Task { + toolbarHackAccountState = await model.loginHackAccount() + } + } label: { + Label("Sign in to Hack", systemImage: "person.badge.key") + } + } + } else { + Button { + Task { + await refreshToolbarHackAccountState() + } + } label: { + Label("Refresh account state", systemImage: "arrow.clockwise") + } + } + + Divider() + + Button { + openSettings(.runtime) + } label: { + Label("Open runtime settings", systemImage: "gearshape") + } + + Button { + guard !globalToggleIsBusy else { return } + Task { await model.toggleGlobalInfrastructure() } + } label: { + Label(globalToggleMenuTitle, systemImage: globalToggleIcon) + } + } label: { + toolbarAccountLabel + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .help(toolbarHackAccountState?.authenticated == true ? "Hack account" : "Hack account and app actions") + } + + private var toolbarAccountLabel: some View { + ZStack { + Circle() + .fill(toolbarAvatarBackgroundColor) + if let initials = toolbarAccountInitials { + Text(initials) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(toolbarAvatarForegroundColor) + } else { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(toolbarAvatarForegroundColor) + } + } + .frame(width: 28, height: 28) + .overlay( + Circle() + .stroke(Color.primary.opacity(0.10), lineWidth: 1) + ) + .contentShape(Circle()) + .accessibilityLabel("Hack account") + } + + private var toolbarAccountPrimaryLabel: String? { + if let name = toolbarHackAccountState?.userDisplayName, !name.isEmpty { + return name + } + if let email = toolbarHackAccountState?.userEmail, !email.isEmpty { + return email + } + return toolbarHackAccountState?.authenticated == true ? "Hack account" : nil + } + + private var toolbarAccountSecondaryLabel: String? { + if let organization = toolbarHackAccountState?.organizationName, !organization.isEmpty { + if let team = toolbarHackAccountState?.teamName, !team.isEmpty { + return "\(organization) • \(team)" + } + return organization + } + if toolbarHackAccountState?.authenticated == false { + return "Signed out" + } + return nil + } + + private var toolbarAccountInitials: String? { + guard toolbarHackAccountState?.authenticated == true else { + return nil + } + let source = toolbarHackAccountState?.userDisplayName ?? toolbarHackAccountState?.userEmail ?? "" + let tokens = source + .split(whereSeparator: { $0 == " " || $0 == "." || $0 == "@" || $0 == "_" || $0 == "-" }) + .prefix(2) + let initials = tokens.compactMap { $0.first.map(String.init) }.joined().uppercased() + return initials.isEmpty ? nil : initials + } + + private var toolbarAvatarBackgroundColor: Color { + guard toolbarHackAccountState?.authenticated == true else { + return Color.primary.opacity(0.08) + } + return Color.accentColor.opacity(colorScheme == .dark ? 0.30 : 0.18) + } + + private var toolbarAvatarForegroundColor: Color { + toolbarHackAccountState?.authenticated == true ? .accentColor : .secondary + } + + private var globalToggleMenuTitle: String { + model.globalInfraRunning ? "Stop local runtime" : "Start local runtime" + } + + private func refreshToolbarHackAccountState() async { + toolbarHackAccountState = await model.inspectHackAccountSettingsState() + } + private var footer: some View { VStack(alignment: .leading, spacing: 8) { if let errorMessage = model.errorMessage { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift index fabc737b7..f2c48499a 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift @@ -207,8 +207,8 @@ struct NodeTopologySettingsView: View { Button("Open Tailscale") { openTailscaleSettings() } - Button("Open Extensions") { - openExtensionsSettings() + Button("Open account") { + openAccountSettings() } } label: { Label("Add node", systemImage: "plus.circle") @@ -301,9 +301,9 @@ struct NodeTopologySettingsView: View { topologyExpanded = false openTailscaleSettings() } - Button("Open Extensions") { + Button("Open account") { topologyExpanded = false - openExtensionsSettings() + openAccountSettings() } } label: { Label("Add node", systemImage: "plus.circle") @@ -1444,11 +1444,11 @@ struct NodeTopologySettingsView: View { ) } - private func openExtensionsSettings() { + private func openAccountSettings() { NotificationCenter.default.post( name: .hackSettingsRequested, object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.extensions.rawValue] + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.account.rawValue] ) } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index f3f73c253..7a15cf04f 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -120,7 +120,6 @@ struct ProjectDetailView: View { @State private var githubProfilesById: [String: GitHubProfileSummary] = [:] @State private var githubProfileStatusById: [String: GitHubStatusResponse] = [:] @State private var githubResolvedProfile = "" - @State private var githubResolvedStatus: GitHubStatusResponse? = nil @State private var githubProfileMessage = "" @State private var linearProjectProfile = "" @State private var linearDefaultProfile = "" @@ -128,7 +127,6 @@ struct ProjectDetailView: View { @State private var linearProfilesById: [String: LinearProfileSummary] = [:] @State private var linearProfileStatusById: [String: LinearStatusResponse] = [:] @State private var linearResolvedProfile = "" - @State private var linearResolvedStatus: LinearStatusResponse? = nil @State private var linearBoundProjectId = "" @State private var linearBoundProjectName = "" @State private var linearBoundTeamId = "" @@ -693,7 +691,7 @@ struct ProjectDetailView: View { VStack(alignment: .leading, spacing: 4) { Text("Project settings") .font(.system(size: 20, weight: .semibold)) - Text("Keep runtime and identity settings here. Linear routing now lives in a focused sheet so ticket work stays in Tickets.") + Text("Keep runtime defaults and provider routing here. Day-to-day sync stays in Tickets.") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) } @@ -710,20 +708,30 @@ struct ProjectDetailView: View { description: "Choose whether this repo defaults to local work or a routed remote node.", footnote: executionMode.summary ) { - Picker("", selection: $executionMode) { + Menu { ForEach(RemoteExecutionMode.allCases) { mode in - Text(mode.title).tag(mode) + Button { + guard executionMode != mode else { + return + } + executionMode = mode + guard !executionTargetLoading else { + return + } + Task { await persistExecutionModeSelection() } + } label: { + if executionMode == mode { + Label(mode.title, systemImage: "checkmark") + } else { + Text(mode.title) + } + } } + } label: { + projectSettingsMenuLabel(executionMode.title) } - .labelsHidden() + .buttonStyle(.plain) .accessibilityLabel("Execution mode") - .pickerStyle(.menu) - .onChange(of: executionMode) { _, _ in - guard !executionTargetLoading else { - return - } - Task { await persistExecutionModeSelection() } - } } Divider() @@ -734,22 +742,51 @@ struct ProjectDetailView: View { description: "Applies when execution mode routes work off this Mac.", footnote: selectedNodeSummary ) { - Picker("", selection: $executionTargetNodeId) { - Text("Local").tag("") - ForEach(executionTargetNodes, id: \.id) { node in - Text(node.name).tag(node.id) - } - } - .labelsHidden() - .accessibilityLabel("Default node") - .pickerStyle(.menu) - .onChange(of: executionTargetNodeId) { _, _ in - guard !executionTargetLoading else { - return + if executionMode == .local { + projectSettingsMenuLabel("Local", isDisabled: true) + .accessibilityLabel("Default node") + } else { + Menu { + Button { + guard !executionTargetNodeId.isEmpty else { + return + } + executionTargetNodeId = "" + guard !executionTargetLoading else { + return + } + Task { await persistSimpleDefaultNodeSelection() } + } label: { + if executionTargetNodeId.isEmpty { + Label("Local", systemImage: "checkmark") + } else { + Text("Local") + } + } + ForEach(executionTargetNodes, id: \.id) { node in + Button { + guard executionTargetNodeId != node.id else { + return + } + executionTargetNodeId = node.id + guard !executionTargetLoading else { + return + } + Task { await persistSimpleDefaultNodeSelection() } + } label: { + if executionTargetNodeId == node.id { + Label(node.name, systemImage: "checkmark") + } else { + Text(node.name) + } + } + } + } label: { + projectSettingsMenuLabel(selectedNodeSummary) } - Task { await persistSimpleDefaultNodeSelection() } + .buttonStyle(.plain) + .accessibilityLabel("Default node") } - .disabled(executionMode == .local) } Divider() @@ -760,21 +797,47 @@ struct ProjectDetailView: View { description: "Pick the saved GitHub profile this repo should use for remote Git operations.", footnote: selectedGitSummary ) { - Picker("", selection: $githubProjectProfile) { - Text("Local").tag("") + Menu { + Button { + guard !githubProjectProfile.isEmpty else { + return + } + githubProjectProfile = "" + guard !executionTargetLoading else { + return + } + Task { await persistGitCredentialsSelection() } + } label: { + if githubProjectProfile.isEmpty { + Label("Local", systemImage: "checkmark") + } else { + Text("Local") + } + } ForEach(githubProfileOptions, id: \.self) { profile in - Text(githubProfileLabel(profileId: profile)).tag(profile) + Button { + guard githubProjectProfile != profile else { + return + } + githubProjectProfile = profile + guard !executionTargetLoading else { + return + } + Task { await persistGitCredentialsSelection() } + } label: { + let label = githubProfileLabel(profileId: profile) + if githubProjectProfile == profile { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } } + } label: { + projectSettingsMenuLabel(selectedGitSummary) } - .labelsHidden() + .buttonStyle(.plain) .accessibilityLabel("GitHub profile") - .pickerStyle(.menu) - .onChange(of: githubProjectProfile) { _, _ in - guard !executionTargetLoading else { - return - } - Task { await persistGitCredentialsSelection() } - } } } .padding(16) @@ -811,15 +874,15 @@ struct ProjectDetailView: View { .foregroundStyle(.secondary) } Spacer() - Button(linearResolvedStatus?.tokenResolved == true ? "Edit routing" : "Connect Linear") { + Button(hasConfiguredLinearProfile ? "Edit routing" : "Connect Linear") { showLinearRoutingSheet = true } .adaptiveToolbarButtonProminent() } HStack(spacing: 8) { - if linearResolvedStatus?.tokenResolved == true { - StatusPill(text: "Connected", tone: .good) + if hasConfiguredLinearProfile { + StatusPill(text: "Configured", tone: .good) } else { StatusPill(text: "Needs connection", tone: .warn) } @@ -859,35 +922,61 @@ struct ProjectDetailView: View { VStack(alignment: .leading, spacing: 6) { Text("Linear routing") .font(.system(size: 22, weight: .semibold)) - Text("Choose the saved Linear profile and project routes for this repo. Ticket review and one-off sync actions stay in Tickets.") + Text("Choose which saved Linear account and projects this repo uses. Sync actions stay in Tickets.") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) } routingSheetSection( title: "Saved profile", - description: "Choose the saved Linear profile Hack should use for this repo. Profiles point to provider accounts connected in global Linear settings." + description: "Choose the saved Linear profile Hack should use for this repo." ) { - if linearResolvedStatus?.tokenResolved == true { + if hasConfiguredLinearProfile { projectSettingsControlGroup(footnote: selectedLinearProfileSummary) { VStack(alignment: .leading, spacing: 10) { projectSettingsFieldLabel("Project profile") projectSettingsControlShell(maxWidth: 320) { - Picker("", selection: $linearProjectProfile) { - Text("Inherited").tag("") + Menu { + Button { + guard !linearProjectProfile.isEmpty else { + return + } + linearProjectProfile = "" + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } + } label: { + if linearProjectProfile.isEmpty { + Label("Inherited", systemImage: "checkmark") + } else { + Text("Inherited") + } + } ForEach(linearProfileOptions, id: \.self) { profile in - Text(linearProfileLabel(profileId: profile)).tag(profile) + Button { + guard linearProjectProfile != profile else { + return + } + linearProjectProfile = profile + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } + } label: { + let label = linearProfileLabel(profileId: profile) + if linearProjectProfile == profile { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } } + } label: { + projectSettingsMenuLabel(selectedLinearProfileSummary) } - .labelsHidden() + .buttonStyle(.plain) .accessibilityLabel("Saved profile") - .pickerStyle(.menu) - .onChange(of: linearProjectProfile) { _, _ in - guard !executionTargetLoading else { - return - } - Task { await persistLinearProfileOverride() } - } } HStack(alignment: .center, spacing: 12) { @@ -923,30 +1012,57 @@ struct ProjectDetailView: View { routingSheetSection( title: "Default project route", - description: "This is the primary Linear project route for project-level pull, push, and review." + description: "This is the primary Linear project for pull, push, and review." ) { projectSettingsControlGroup(footnote: selectedLinearProjectSummary) { VStack(alignment: .leading, spacing: 10) { projectSettingsFieldLabel("Primary route") projectSettingsControlShell(maxWidth: 360) { - Picker("", selection: $selectedLinearProjectId) { - Text("Unbound").tag("") - ForEach(linearProjectOptions) { linearProject in - Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) - } - } - .labelsHidden() - .accessibilityLabel("Default project route") - .pickerStyle(.menu) - .disabled(linearProjectOptions.isEmpty) - .onChange(of: selectedLinearProjectId) { _, newValue in - guard !executionTargetLoading else { - return - } - guard newValue != linearBoundProjectId else { - return + if linearProjectOptions.isEmpty { + projectSettingsMenuLabel("Unbound", isDisabled: true) + .accessibilityLabel("Default project route") + } else { + Menu { + Button { + guard !selectedLinearProjectId.isEmpty else { + return + } + selectedLinearProjectId = "" + guard !executionTargetLoading else { + return + } + Task { await persistLinearProjectBindingSelection() } + } label: { + if selectedLinearProjectId.isEmpty { + Label("Unbound", systemImage: "checkmark") + } else { + Text("Unbound") + } + } + ForEach(linearProjectOptions) { linearProject in + Button { + guard selectedLinearProjectId != linearProject.id else { + return + } + selectedLinearProjectId = linearProject.id + guard !executionTargetLoading else { + return + } + Task { await persistLinearProjectBindingSelection() } + } label: { + let label = linearProjectMenuLabel(linearProject) + if selectedLinearProjectId == linearProject.id { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } + } + } label: { + projectSettingsMenuLabel(selectedLinearProjectSummary) } - Task { await persistLinearProjectBindingSelection() } + .buttonStyle(.plain) + .accessibilityLabel("Default project route") } } @@ -988,16 +1104,33 @@ struct ProjectDetailView: View { projectSettingsFieldLabel("Add linked project") HStack(spacing: 8) { projectSettingsControlShell(maxWidth: 360) { - Picker("", selection: $selectedAdditionalLinearProjectId) { - Text("Choose project").tag("") - ForEach(availableAdditionalLinearProjects) { linearProject in - Text(linearProjectMenuLabel(linearProject)).tag(linearProject.id) + if availableAdditionalLinearProjects.isEmpty { + projectSettingsMenuLabel("Choose project", isDisabled: true) + .accessibilityLabel("Add linked project") + } else { + Menu { + ForEach(availableAdditionalLinearProjects) { linearProject in + Button { + selectedAdditionalLinearProjectId = linearProject.id + } label: { + let label = linearProjectMenuLabel(linearProject) + if selectedAdditionalLinearProjectId == linearProject.id { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } + } + } label: { + projectSettingsMenuLabel( + additionalProjectMenuLabel( + selectedAdditionalLinearProjectId + ) + ) } + .buttonStyle(.plain) + .accessibilityLabel("Add linked project") } - .labelsHidden() - .accessibilityLabel("Add linked project") - .pickerStyle(.menu) - .disabled(availableAdditionalLinearProjects.isEmpty) } Button("Add") { @@ -1028,6 +1161,9 @@ struct ProjectDetailView: View { } .frame(minWidth: 620, minHeight: 560) .background(Color.clear) + .task { + await loadLinearRoutingProjectOptionsIfNeeded() + } } private var defaultLinearRouteTarget: LinearProjectBindingTarget? { @@ -1055,8 +1191,16 @@ struct ProjectDetailView: View { return inherited.isEmpty ? nil : inherited } + private var hasConfiguredLinearProfile: Bool { + let resolvedProfile = resolvedLinearRoutingProfileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !resolvedProfile.isEmpty { + return linearProfilesById[resolvedProfile] != nil + } + return !linearProfileOptions.isEmpty + } + private var linearRoutingSummaryLine: String { - if linearResolvedStatus?.tokenResolved != true { + if !hasConfiguredLinearProfile { return "No Linear provider account connected for this repo yet." } if !hasAnyLinearProjectRouting { @@ -1121,6 +1265,24 @@ struct ProjectDetailView: View { .textCase(.uppercase) } + private func projectSettingsMenuLabel( + _ text: String, + isDisabled: Bool = false + ) -> some View { + HStack(spacing: 10) { + Text(text) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isDisabled ? .tertiary : .primary) + .lineLimit(1) + Spacer(minLength: 8) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(isDisabled ? .tertiary : .secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + private func projectSettingsControlShell( maxWidth: CGFloat? = nil, @ViewBuilder content: () -> Content @@ -1142,6 +1304,17 @@ struct ProjectDetailView: View { ) } + private func additionalProjectMenuLabel(_ projectId: String) -> String { + let trimmed = projectId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "Choose project" + } + guard let project = availableAdditionalLinearProjects.first(where: { $0.id == trimmed }) else { + return trimmed + } + return linearProjectMenuLabel(project) + } + private func routingSheetSection( title: String, description: String, @@ -1979,6 +2152,25 @@ struct ProjectDetailView: View { return nil } + private func loadLinearRoutingProjectOptionsIfNeeded() async { + let resolvedProfile = resolvedLinearRoutingProfileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !resolvedProfile.isEmpty else { + linearProjectOptions = [] + return + } + guard hasConfiguredLinearProfile else { + linearProjectOptions = [] + return + } + if let projectCatalog = await model.listLinearProjects(profileId: resolvedProfile) { + linearProjectOptions = projectCatalog.projects.sorted { lhs, rhs in + lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } else { + linearProjectOptions = [] + } + } + private func linearAccountEmail(profileId: String) -> String? { let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -2124,25 +2316,12 @@ struct ProjectDetailView: View { .sorted { lhs, rhs in lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending } - var profileStatusById: [String: GitHubStatusResponse] = [:] - for profileId in githubProfileOptions { - if let status = await model.inspectGitHubStatus(profileId: profileId) { - profileStatusById[profileId] = status - } - } - githubProfileStatusById = profileStatusById + githubProfileStatusById = [:] let resolvedGitHubProfile = githubProjectProfile.isEmpty ? githubDefaultProfile : githubProjectProfile githubResolvedProfile = resolvedGitHubProfile projectSystemGitIdentity = resolvedSystemGitIdentity - if let preloadedStatus = profileStatusById[resolvedGitHubProfile] { - githubResolvedStatus = preloadedStatus - } else { - githubResolvedStatus = await model.inspectGitHubStatus( - profileId: resolvedGitHubProfile.isEmpty ? nil : resolvedGitHubProfile - ) - } linearProjectProfile = (resolvedLinearBinding?.profileId ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -2162,34 +2341,12 @@ struct ProjectDetailView: View { .sorted { lhs, rhs in lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending } - var linearStatusesById: [String: LinearStatusResponse] = [:] - for profileId in linearProfileOptions { - if let status = await model.inspectLinearStatus(profileId: profileId) { - linearStatusesById[profileId] = status - } - } - linearProfileStatusById = linearStatusesById + linearProfileStatusById = [:] let resolvedLinearProfile = linearProjectProfile.isEmpty ? linearDefaultProfile : linearProjectProfile linearResolvedProfile = resolvedLinearProfile - if let preloadedStatus = linearStatusesById[resolvedLinearProfile] { - linearResolvedStatus = preloadedStatus - } else { - linearResolvedStatus = await model.inspectLinearStatus( - profileId: resolvedLinearProfile.isEmpty ? nil : resolvedLinearProfile - ) - } - if linearResolvedStatus?.tokenResolved == true { - let projectCatalog = await model.listLinearProjects( - profileId: resolvedLinearProfile.isEmpty ? nil : resolvedLinearProfile - ) - linearProjectOptions = (projectCatalog?.projects ?? []).sorted { lhs, rhs in - lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - } else { - linearProjectOptions = [] - } + linearProjectOptions = [] selectedLinearProjectId = linearBoundProjectId if linearAdditionalProjects.contains(where: { $0.projectId == selectedAdditionalLinearProjectId }) { selectedAdditionalLinearProjectId = "" diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index af4384eb6..da4a1a728 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -7,6 +7,7 @@ import HackCLIService import HackDesktopModels enum SettingsSidebarItem: String, Hashable, Identifiable { + case account case preferences case updates case topology @@ -15,7 +16,6 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { case global case supervisor case permissions - case extensions case linear case github case cloudflare @@ -28,6 +28,8 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { var title: String { switch self { + case .account: + return "Account" case .preferences: return "Preferences" case .updates: @@ -44,8 +46,6 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { return "Supervisor" case .permissions: return "Permissions" - case .extensions: - return "Extensions" case .linear: return "Linear" case .github: @@ -65,6 +65,8 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { var icon: String { switch self { + case .account: + return "person.crop.circle" case .preferences: return "slider.horizontal.3" case .updates: @@ -81,8 +83,6 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { return "cpu" case .permissions: return "hand.raised.fill" - case .extensions: - return "puzzlepiece.extension" case .linear: return "line.3.horizontal.decrease.circle" case .github: @@ -179,6 +179,9 @@ struct SettingsOverlayView: View { private var settingsSidebar: some View { List(selection: $selection) { + Section("Account") { + settingsRow(.account) + } Section("Preferences") { settingsRow(.preferences) settingsRow(.updates) @@ -195,8 +198,7 @@ struct SettingsOverlayView: View { settingsRow(.logging) settingsRow(.certificates) } - Section("Extensions") { - settingsRow(.extensions) + Section("Integrations") { settingsRow(.linear) settingsRow(.github) settingsRow(.cloudflare) @@ -221,6 +223,8 @@ struct SettingsOverlayView: View { private var settingsDetail: some View { Group { switch selection { + case .account: + AccountSettingsView(selection: $selection) case .preferences: PreferencesSettingsView() case .updates: @@ -237,8 +241,6 @@ struct SettingsOverlayView: View { SupervisorSettingsView() case .permissions: PermissionsSettingsView() - case .extensions: - ExtensionsSettingsView(selection: $selection) case .linear: LinearExtensionSettingsView() case .github: @@ -421,6 +423,100 @@ struct SettingsSectionHeader: View { } } +private struct AccountSettingsView: View { + @Environment(DashboardModel.self) private var model + @Binding var selection: SettingsSidebarItem + @State private var hackAccountState: HackAccountSettingsState? = nil + @State private var isLoadingHackAccount = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Account", + title: "Hack account", + subtitle: "Sign in to Hack for shared remote features. Local-only workflows still work without an account." + ) + HackAccountSettingsCard( + state: hackAccountState, + isLoading: isLoadingHackAccount, + providerName: nil + ) { + Task { await refreshHackAccountState() } + } + GlassCard(title: "Connected services", systemImage: "link.badge.plus") { + VStack(alignment: .leading, spacing: 12) { + integrationShortcutRow( + item: .linear, + title: "Linear", + subtitle: "Shared provider accounts, routing defaults, and sync policy." + ) + Divider() + .opacity(0.2) + integrationShortcutRow( + item: .github, + title: "GitHub", + subtitle: "Shared provider accounts, PR automation routing, and app installs." + ) + Divider() + .opacity(0.2) + integrationShortcutRow( + item: .cloudflare, + title: "Cloudflare", + subtitle: "Tunnel connectivity and deployment access." + ) + Divider() + .opacity(0.2) + integrationShortcutRow( + item: .railway, + title: "Railway", + subtitle: "Service bootstrap defaults and provider access." + ) + Divider() + .opacity(0.2) + integrationShortcutRow( + item: .tailscale, + title: "Tailscale", + subtitle: "Tailnet connectivity and auth key management." + ) + } + } + } + .padding(24) + } + .task { + await refreshHackAccountState() + } + } + + private func integrationShortcutRow( + item: SettingsSidebarItem, + title: String, + subtitle: String + ) -> some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.mono(.subheadline, weight: .semibold)) + Text(subtitle) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + Spacer() + Button("Open") { + selection = item + } + .adaptiveToolbarButton() + } + } + + private func refreshHackAccountState() async { + isLoadingHackAccount = true + defer { isLoadingHackAccount = false } + hackAccountState = await model.inspectHackAccountSettingsState() + } +} + private struct HackAccountSettingsCard: View { @Environment(DashboardModel.self) private var model let state: HackAccountSettingsState? @@ -1467,8 +1563,8 @@ private struct CloudflareExtensionSettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 20) { SettingsSectionHeader( - breadcrumb: "Settings / Extensions / Cloudflare", - title: "Cloudflare Extension", + breadcrumb: "Settings / Cloudflare", + title: "Cloudflare", subtitle: "Public tunnel exposure via cloudflared for HTTPS and SSH entrypoints" ) GlassCard(title: "Extension status", systemImage: "cloud") { @@ -1852,8 +1948,8 @@ private struct RailwayExtensionSettingsView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 20) { SettingsSectionHeader( - breadcrumb: "Settings / Extensions / Railway", - title: "Railway Provider", + breadcrumb: "Settings / Railway", + title: "Railway", subtitle: "Bootstrap remote nodes with minimal input; advanced options stay optional" ) @@ -2880,17 +2976,10 @@ private struct LinearExtensionSettingsView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 20) { SettingsSectionHeader( - breadcrumb: "Settings / Extensions / Linear", - title: "Linear Extension", - subtitle: "Manage Hack auth prerequisites, connected Linear provider accounts, and saved sync routing" + breadcrumb: "Settings / Linear", + title: "Linear", + subtitle: "Connect accounts, choose a default routing profile, and tune sync behavior." ) - HackAccountSettingsCard( - state: hackAccountState, - isLoading: isLoadingHackAccount, - providerName: "Linear" - ) { - Task { await refreshHackAccountState() } - } GlassCard(title: "Extension status", systemImage: "line.3.horizontal.decrease.circle") { HStack(alignment: .center, spacing: 8) { StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) @@ -2927,21 +3016,34 @@ private struct LinearExtensionSettingsView: View { } } - GlassCard(title: "Connected provider accounts", systemImage: "point.3.connected.trianglepath.dotted") { + GlassCard(title: "Accounts", systemImage: "point.3.connected.trianglepath.dotted") { HStack(alignment: .center, spacing: 10) { - Text("These are the Linear accounts Hack can route sync through. Saved profiles decide which provider account a project uses.") + Text("Saved profiles decide which Linear account a project uses.") .font(.mono(.caption)) .foregroundStyle(.secondary) Spacer() - Button { - toggleLinearAuthFlow() - } label: { - Label( - isAuthenticating ? "Cancel add account" : "Add account", - systemImage: isAuthenticating ? "xmark.circle" : "plus.circle" - ) + if hackAccountState?.authenticated == true { + Button { + toggleLinearAuthFlow() + } label: { + Label( + isAuthenticating ? "Cancel connect" : "Connect account", + systemImage: isAuthenticating ? "xmark.circle" : "plus.circle" + ) + } + .adaptiveToolbarButtonProminent() + } else { + Button("Sign in to Hack") { + Task { _ = await model.loginHackAccount() } + } + .adaptiveToolbarButtonProminent() } - .adaptiveToolbarButtonProminent() + } + + if hackAccountState?.authenticated != true { + Text("Sign in to Hack before connecting shared Linear accounts.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) } if isAuthenticating { @@ -2957,13 +3059,6 @@ private struct LinearExtensionSettingsView: View { } } - InlineCallout( - tone: linearRoutingCalloutTone, - title: linearRoutingCalloutTitle, - message: linearRoutingCalloutMessage, - actions: [] - ) - if linearProfiles.isEmpty { Text("No accounts connected yet.") .font(.mono(.caption)) @@ -2982,12 +3077,6 @@ private struct LinearExtensionSettingsView: View { if profile.isDefault { StatusPill(text: "Default profile", tone: .good) } - if let profileStatus { - StatusPill( - text: profileStatus.tokenResolved ? "Token ready" : "Token missing", - tone: profileStatus.tokenResolved ? .good : .warn - ) - } Spacer() if !profile.isDefault { Button { @@ -3020,16 +3109,15 @@ private struct LinearExtensionSettingsView: View { .adaptiveToolbarButton() .disabled( isAuthenticating || - disconnectingLinearProfiles.contains(profile.id) || - profileStatus?.tokenResolved != true + disconnectingLinearProfiles.contains(profile.id) ) } - Text("routing profile: \(profile.id)") + Text("profile id: \(profile.id)") .font(.mono(.caption2)) .foregroundStyle(.secondary) if let accountLabel, !accountLabel.isEmpty { - Text("provider account: \(accountLabel)") + Text("account: \(accountLabel)") .font(.mono(.caption2)) .foregroundStyle(.secondary) } @@ -3073,16 +3161,12 @@ private struct LinearExtensionSettingsView: View { } } - GlassCard(title: "Sync policy", systemImage: "arrow.triangle.branch") { + GlassCard(title: "Sync fields", systemImage: "arrow.triangle.branch") { VStack(alignment: .leading, spacing: 12) { - InlineCallout( - tone: .neutral, - title: "How sync behaves", - message: linearSyncPolicySummary, - actions: [] - ) - - Text("Manual sync stays the default. These toggles control which Linear fields Hack will translate when you run project or ticket sync tools.") + Text(linearSyncPolicySummary) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Text("Manual sync stays the default. These toggles control which fields Hack will translate when you run sync.") .font(.mono(.caption)) .foregroundStyle(.secondary) @@ -3401,44 +3485,6 @@ private struct LinearExtensionSettingsView: View { return !status.tokenResolved } - private var linearRoutingCalloutTone: StatusTone { - if linearProfiles.isEmpty || defaultLinearProfileNeedsRepair || resolvedDefaultProfile.isEmpty { - return .warn - } - return .neutral - } - - private var linearRoutingCalloutTitle: String { - if linearProfiles.isEmpty { - return "Connect a provider account first" - } - if defaultLinearProfileNeedsRepair { - return "Default profile needs repair" - } - if resolvedDefaultProfile.isEmpty { - return "Set a default profile" - } - return "Routing and disconnect behavior" - } - - private var linearRoutingCalloutMessage: String { - if linearProfiles.isEmpty { - return "Add a Linear provider account before ticket or project sync can route through Linear. After the first account connects, set it as the default profile so projects inherit a working route until they choose their own." - } - - if defaultLinearProfileNeedsRepair { - let defaultLabel = displayNameForRemoteProfileId(resolvedDefaultProfile) - return "The default profile \(defaultLabel) no longer has a usable token. Reconnect it or switch the default before the next sync, because projects without an override still inherit that routing target." - } - - if resolvedDefaultProfile.isEmpty { - return "Projects can still choose a specific Linear provider account, but anything without a per-project override has no fallback route until you set a default profile here." - } - - let defaultLabel = displayNameForRemoteProfileId(resolvedDefaultProfile) - return "Projects inherit \(defaultLabel) until they choose a different Linear profile in project settings. Disconnecting a provider account removes its token immediately, so reroute or reconnect affected projects before the next sync run." - } - private var linearSyncPolicySummary: String { "Authority follows ticket origin. Origin-owned fields such as title, body, status, and project binding stay authoritative on the side that created the ticket, while assignees, labels, dependencies, and sub-issue links are best-effort mergeable when enabled. Comments are append-only and synced in FIFO order rather than edited in place." } @@ -3486,17 +3532,8 @@ private struct LinearExtensionSettingsView: View { } private func refreshLinearProfileStatuses() async { - guard let diagnostics else { - profileStatusById = [:] - return - } - var statuses: [String: LinearStatusResponse] = [:] - for profile in diagnostics.profiles { - if let status = await model.inspectLinearStatus(profileId: profile.id) { - statuses[profile.id] = status - } - } - profileStatusById = statuses + // Passive settings pages should not trigger keychain-backed token resolution. + profileStatusById = [:] } private var normalizedAssigneeMappingProfile: String { @@ -3960,17 +3997,10 @@ private struct GitHubExtensionSettingsView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 20) { SettingsSectionHeader( - breadcrumb: "Settings / Extensions / GitHub", - title: "GitHub Extension", - subtitle: "Manage Hack auth prerequisites, connected GitHub provider accounts, and saved PR automation routing" + breadcrumb: "Settings / GitHub", + title: "GitHub", + subtitle: "Connect accounts and app installs, then choose the default profile for automation." ) - HackAccountSettingsCard( - state: hackAccountState, - isLoading: isLoadingHackAccount, - providerName: "GitHub" - ) { - Task { await refreshHackAccountState() } - } GlassCard(title: "Extension status", systemImage: "chevron.left.forwardslash.chevron.right") { HStack(alignment: .center, spacing: 8) { StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) @@ -4047,21 +4077,34 @@ private struct GitHubExtensionSettingsView: View { } } - GlassCard(title: "Connected provider accounts", systemImage: "person.2.badge.gearshape") { + GlassCard(title: "Accounts", systemImage: "person.2.badge.gearshape") { HStack(alignment: .center, spacing: 10) { - Text("These are the GitHub accounts or app installs Hack can use. Saved profiles decide which provider account a project or node uses.") + Text("Saved profiles decide which GitHub account or app install a project or node uses.") .font(.mono(.caption)) .foregroundStyle(.secondary) Spacer() - Button { - toggleGitHubAuthFlow() - } label: { - Label( - isAuthenticating ? "Cancel add account" : "Add account", - systemImage: isAuthenticating ? "xmark.circle" : "plus.circle" - ) + if hackAccountState?.authenticated == true { + Button { + toggleGitHubAuthFlow() + } label: { + Label( + isAuthenticating ? "Cancel connect" : "Connect account", + systemImage: isAuthenticating ? "xmark.circle" : "plus.circle" + ) + } + .adaptiveToolbarButtonProminent() + } else { + Button("Sign in to Hack") { + Task { _ = await model.loginHackAccount() } + } + .adaptiveToolbarButtonProminent() } - .adaptiveToolbarButtonProminent() + } + + if hackAccountState?.authenticated != true { + Text("Sign in to Hack before connecting shared GitHub accounts or app installs.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) } if isAuthenticating { @@ -4117,12 +4160,6 @@ private struct GitHubExtensionSettingsView: View { } else if profile.mode.lowercased() == "app" { StatusPill(text: "Install missing", tone: .warn) } - if let profileStatus { - StatusPill( - text: profileStatus.tokenResolved ? "Token ready" : "Token missing", - tone: profileStatus.tokenResolved ? .good : .warn - ) - } Spacer() if !profile.isDefault { Button { @@ -4134,12 +4171,12 @@ private struct GitHubExtensionSettingsView: View { } } - Text("routing profile: \(profile.id)") + Text("profile id: \(profile.id)") .font(.mono(.caption2)) .foregroundStyle(.secondary) if let accountHandle, !accountHandle.isEmpty { let accountNameSuffix = accountName.map { " (\($0))" } ?? "" - Text("provider account: @\(accountHandle)\(accountNameSuffix)") + Text("account: @\(accountHandle)\(accountNameSuffix)") .font(.mono(.caption2)) .foregroundStyle(.secondary) } @@ -4291,17 +4328,8 @@ private struct GitHubExtensionSettingsView: View { } private func refreshGitHubProfileStatuses() async { - guard let diagnostics else { - profileStatusById = [:] - return - } - var statuses: [String: GitHubStatusResponse] = [:] - for profile in diagnostics.profiles { - if let status = await model.inspectGitHubStatus(profileId: profile.id) { - statuses[profile.id] = status - } - } - profileStatusById = statuses + // Passive settings pages should not trigger keychain-backed token resolution. + profileStatusById = [:] } private func applyGitHubEnabledToggle(_ newValue: Bool) async { @@ -4527,7 +4555,7 @@ private struct GitHubExtensionSettingsView: View { let activeAuthFlowId, let activeAuthStatusURL else { - message = "GitHub callback received. Use Add account to finish setup." + message = "GitHub callback received. Return to GitHub settings to finish setup." return } @@ -4608,8 +4636,8 @@ private struct TailscaleExtensionSettingsView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 20) { SettingsSectionHeader( - breadcrumb: "Settings / Extensions / Tailscale", - title: "Tailscale Extension", + breadcrumb: "Settings / Tailscale", + title: "Tailscale", subtitle: "Tailnet-based secure access and remote routing for gateway projects" ) GlassCard(title: "Extension status", systemImage: "network") { @@ -5423,252 +5451,6 @@ private struct PermissionsSettingsView: View { } } -private struct ExtensionsSettingsView: View { - @Environment(DashboardModel.self) private var model - @Binding var selection: SettingsSidebarItem - @State private var isLoading = false - @State private var isLoadingHackAccount = false - @State private var suppressToggleChange = false - @State private var hackAccountState: HackAccountSettingsState? = nil - @State private var linearEnabled = false - @State private var githubEnabled = false - @State private var cloudflareEnabled = false - @State private var railwayEnabled = false - @State private var tailscaleEnabled = false - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Extensions", - title: "Extensions", - subtitle: "External connectivity integrations available in this workspace" - ) - InlineCallout( - tone: .neutral, - title: "Built-ins are hidden", - message: "Internal components like gateway, tickets, and supervisor are configured on dedicated system pages and are intentionally omitted from the extension list.", - actions: [] - ) - HackAccountSettingsCard( - state: hackAccountState, - isLoading: isLoadingHackAccount, - providerName: nil - ) { - Task { await refreshHackAccountState() } - } - GlassCard(title: "Managed extensions", systemImage: "puzzlepiece.extension") { - VStack(alignment: .leading, spacing: 12) { - extensionSummaryRow( - item: .linear, - name: "Linear", - description: "Issue sync profiles, default project routing, and broker-based OAuth account setup.", - projectCount: projectCount(for: "dance.hack.linear"), - exposure: extensionExposure(id: "linear"), - isOn: $linearEnabled - ) - Divider() - .opacity(0.2) - extensionSummaryRow( - item: .github, - name: "GitHub", - description: "PR automation auth profiles, account routing, and install metadata.", - projectCount: projectCount(for: "dance.hack.github"), - exposure: extensionExposure(id: "github"), - isOn: $githubEnabled - ) - Divider() - .opacity(0.2) - extensionSummaryRow( - item: .cloudflare, - name: "Cloudflare", - description: "Public HTTPS + SSH tunnel exposure via cloudflared.", - projectCount: projectCount(for: "dance.hack.cloudflare"), - exposure: extensionExposure(id: "cloudflare"), - isOn: $cloudflareEnabled - ) - Divider() - .opacity(0.2) - extensionSummaryRow( - item: .railway, - name: "Railway", - description: "Remote node provider bootstrap, auth checks, and private tailscale bring-up.", - projectCount: projectCount(for: "dance.hack.railway"), - exposure: extensionExposure(id: "railway"), - isOn: $railwayEnabled - ) - Divider() - .opacity(0.2) - extensionSummaryRow( - item: .tailscale, - name: "Tailscale", - description: "Tailnet exposure and secure remote access.", - projectCount: projectCount(for: "dance.hack.tailscale"), - exposure: extensionExposure(id: "tailscale"), - isOn: $tailscaleEnabled - ) - if isLoading { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Saving extension settings…") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } - } - } - } - .padding(16) - } - .task { - await loadConfigFromDisk() - await refreshHackAccountState() - } - .onChange(of: model.lastUpdated) { _, _ in - Task { - await loadConfigFromDisk() - await refreshHackAccountState() - } - } - } - - private func extensionSummaryRow( - item: SettingsSidebarItem, - name: String, - description: String, - projectCount: Int, - exposure: GatewayExposure?, - isOn: Binding - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(name) - .font(.mono(.subheadline, weight: .semibold)) - Spacer() - Toggle("", isOn: isOn) - .toggleStyle(.switch) - .labelsHidden() - .onTapGesture { - // Prevent row navigation tap when toggling inline. - } - .onChange(of: isOn.wrappedValue) { _, newValue in - guard !suppressToggleChange else { return } - Task { - await setExtensionEnabled(item: item, enabled: newValue) - } - } - if let exposure { - StatusPill(text: exposure.statusLabel, tone: exposure.statusTone) - } else { - StatusPill(text: "Unknown", tone: .neutral) - } - Image(systemName: "chevron.right") - .font(.mono(.caption)) - .foregroundStyle(.tertiary) - } - Text(description) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text("\(projectCount) project\(projectCount == 1 ? "" : "s") enabled") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - if let detail = exposure?.detail, !detail.isEmpty { - Text(detail) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - .contentShape(Rectangle()) - .onTapGesture { - selection = item - } - } - - private func projectCount(for extensionId: String) -> Int { - model.projects.filter { project in - canonicalExtensionIds(for: project).contains(extensionId) - }.count - } - - private func canonicalExtensionIds(for project: ProjectSummary) -> Set { - var ids: Set = [] - for value in (project.extensionsEnabled ?? []) + (project.features ?? []) { - let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - switch normalized { - case "linear", "dance.hack.linear": - ids.insert("dance.hack.linear") - case "cloudflare", "dance.hack.cloudflare": - ids.insert("dance.hack.cloudflare") - case "github", "dance.hack.github": - ids.insert("dance.hack.github") - case "railway", "dance.hack.railway": - ids.insert("dance.hack.railway") - case "tailscale", "dance.hack.tailscale": - ids.insert("dance.hack.tailscale") - default: - continue - } - } - return ids - } - - private func extensionExposure(id: String) -> GatewayExposure? { - model.globalStatus?.gateway?.exposures?.first(where: { $0.id == id }) - } - - private func loadConfigFromDisk() async { - let snapshot = GlobalConfigSnapshot.load() - suppressToggleChange = true - linearEnabled = snapshot.linearExtensionEnabled ?? false - githubEnabled = snapshot.githubExtensionEnabled ?? false - cloudflareEnabled = snapshot.cloudflareExtensionEnabled ?? false - railwayEnabled = snapshot.railwayExtensionEnabled ?? false - tailscaleEnabled = snapshot.tailscaleExtensionEnabled ?? false - suppressToggleChange = false - } - - private func refreshHackAccountState() async { - isLoadingHackAccount = true - defer { isLoadingHackAccount = false } - hackAccountState = await model.inspectHackAccountSettingsState() - } - - private func setExtensionEnabled( - item: SettingsSidebarItem, - enabled: Bool - ) async { - isLoading = true - defer { isLoading = false } - - let key: String - switch item { - case .linear: - key = "controlPlane.extensions[\"dance.hack.linear\"].enabled" - case .github: - key = "controlPlane.extensions[\"dance.hack.github\"].enabled" - case .cloudflare: - key = "controlPlane.extensions[\"dance.hack.cloudflare\"].enabled" - case .railway: - key = "controlPlane.extensions[\"dance.hack.railway\"].enabled" - case .tailscale: - key = "controlPlane.extensions[\"dance.hack.tailscale\"].enabled" - default: - return - } - - let didUpdate = await model.setGlobalConfig( - key: key, - value: enabled ? "true" : "false" - ) - guard didUpdate else { - await loadConfigFromDisk() - return - } - await loadConfigFromDisk() - } -} - private struct CertificatesSettingsView: View { @Environment(\.openURL) private var openURL From a6b24a4b0a3bbdee64607355ac338639e4db7715 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 12:42:36 -0500 Subject: [PATCH 15/54] Simplify Hack account UX and shared refresh state --- .../DashboardFeature/DashboardModel.swift | 58 ++++- .../DashboardFeature/DashboardView.swift | 47 ++-- .../SettingsOverlayView.swift | 241 ++++++------------ 3 files changed, 158 insertions(+), 188 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 637d8b4ea..8f9113107 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -82,6 +82,8 @@ public final class DashboardModel { public private(set) var runtimeResetAt: String? = nil public private(set) var runtimeResetCount: Int? = nil public private(set) var lastUpdated: Date? = nil + public private(set) var hackAccountState: HackAccountSettingsState? = nil + public private(set) var isLoadingHackAccountState = false public var selectedItem: SidebarItem? = .home { didSet { handleSelectedItemChange(previous: oldValue, current: selectedItem) @@ -101,6 +103,9 @@ public final class DashboardModel { private var lastSelectedProjectId: String? = nil private var refreshTask: Task? = nil private var statusClearTask: Task? = nil + private var lastHackAccountRefreshAt: Date? = nil + + private static let hackAccountRefreshTTL: TimeInterval = 20 public init(client: HackCLIClient, ticketsClient: HackCLIClient = HackCLIClient()) { self.client = client @@ -172,8 +177,13 @@ public final class DashboardModel { async let projectsTask = fetchProjects() async let daemonTask = fetchDaemonStatus() async let globalTask = fetchGlobalStatus() + async let hackAccountTask: Void = refreshHackAccountState( + force: false, + updateErrorMessage: false + ) let errors = await [projectsTask, daemonTask, globalTask].compactMap { $0 } + _ = await hackAccountTask if !errors.isEmpty { errorMessage = errors.joined(separator: "\n") } @@ -665,12 +675,46 @@ public final class DashboardModel { } } - public func inspectHackAccountSettingsState() async -> HackAccountSettingsState? { + public func inspectHackAccountSettingsState( + force: Bool = false, + updateErrorMessage: Bool = true + ) async -> HackAccountSettingsState? { + await refreshHackAccountState( + force: force, + updateErrorMessage: updateErrorMessage + ) + return hackAccountState + } + + public func refreshHackAccountState( + force: Bool = false, + updateErrorMessage: Bool = false + ) async { + if !force, + let lastHackAccountRefreshAt, + let hackAccountState, + Date().timeIntervalSince(lastHackAccountRefreshAt) < Self.hackAccountRefreshTTL + { + _ = hackAccountState + return + } + + guard !isLoadingHackAccountState else { + return + } + + isLoadingHackAccountState = true + defer { + isLoadingHackAccountState = false + } + do { - return try await client.inspectHackAccountSettingsState() + hackAccountState = try await client.inspectHackAccountSettingsState() + lastHackAccountRefreshAt = Date() } catch { - errorMessage = error.localizedDescription - return nil + if updateErrorMessage { + errorMessage = error.localizedDescription + } } } @@ -682,7 +726,8 @@ public final class DashboardModel { guard didLogin == true else { return nil } - return await inspectHackAccountSettingsState() + await refreshHackAccountState(force: true, updateErrorMessage: true) + return hackAccountState } public func logoutHackAccount() async -> HackAccountSettingsState? { @@ -693,7 +738,8 @@ public final class DashboardModel { guard didLogout == true else { return nil } - return await inspectHackAccountSettingsState() + await refreshHackAccountState(force: true, updateErrorMessage: true) + return hackAccountState } public func startGitHubOAuthFlow( diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift index 651480524..1c5839942 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift @@ -13,7 +13,6 @@ public struct DashboardView: View { @State private var showTerminalDrawer = false @State private var showSettingsOverlay = false @State private var selectedSettingsItem: SettingsSidebarItem = .account - @State private var toolbarHackAccountState: HackAccountSettingsState? = nil @State private var terminalDrawerHeight: CGFloat = 360 @State private var terminalDrawerInitialHeight: CGFloat? = nil @State private var terminalDrawerModel = TerminalDrawerModel(globalShellProject: Self.makeGlobalShellProject()) @@ -130,7 +129,7 @@ public struct DashboardView: View { .tuneWindowToolbar() .task { model.start() - await refreshToolbarHackAccountState() + await model.refreshHackAccountState(force: false, updateErrorMessage: false) } .onReceive(NotificationCenter.default.publisher(for: .hackCommandPaletteRequested)) { _ in showCommandPalette = true @@ -186,7 +185,7 @@ public struct DashboardView: View { .onChange(of: showSettingsOverlay) { _, isVisible in guard isVisible else { return } Task { - await refreshToolbarHackAccountState() + await model.refreshHackAccountState(force: false, updateErrorMessage: false) } } .sheet(isPresented: $showCommandPalette) { @@ -561,7 +560,7 @@ public struct DashboardView: View { private var toolbarAccountMenu: some View { Menu { - if let state = toolbarHackAccountState { + if let state = model.hackAccountState { if let title = toolbarAccountPrimaryLabel { Text(title) } @@ -587,7 +586,7 @@ public struct DashboardView: View { if state.authenticated { Button { Task { - toolbarHackAccountState = await model.logoutHackAccount() + _ = await model.logoutHackAccount() } } label: { Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") @@ -595,24 +594,26 @@ public struct DashboardView: View { } else { Button { Task { - toolbarHackAccountState = await model.loginHackAccount() + _ = await model.loginHackAccount() } } label: { Label("Sign in to Hack", systemImage: "person.badge.key") } } + Divider() + } else if model.isLoadingHackAccountState { + Text("Loading account…") } else { Button { Task { - await refreshToolbarHackAccountState() + _ = await model.loginHackAccount() } } label: { - Label("Refresh account state", systemImage: "arrow.clockwise") + Label("Sign in to Hack", systemImage: "person.badge.key") } + Divider() } - Divider() - Button { openSettings(.runtime) } label: { @@ -630,7 +631,7 @@ public struct DashboardView: View { } .menuStyle(.borderlessButton) .menuIndicator(.hidden) - .help(toolbarHackAccountState?.authenticated == true ? "Hack account" : "Hack account and app actions") + .help(model.hackAccountState?.authenticated == true ? "Hack account" : "Hack account and app actions") } private var toolbarAccountLabel: some View { @@ -657,33 +658,33 @@ public struct DashboardView: View { } private var toolbarAccountPrimaryLabel: String? { - if let name = toolbarHackAccountState?.userDisplayName, !name.isEmpty { + if let name = model.hackAccountState?.userDisplayName, !name.isEmpty { return name } - if let email = toolbarHackAccountState?.userEmail, !email.isEmpty { + if let email = model.hackAccountState?.userEmail, !email.isEmpty { return email } - return toolbarHackAccountState?.authenticated == true ? "Hack account" : nil + return model.hackAccountState?.authenticated == true ? "Hack account" : nil } private var toolbarAccountSecondaryLabel: String? { - if let organization = toolbarHackAccountState?.organizationName, !organization.isEmpty { - if let team = toolbarHackAccountState?.teamName, !team.isEmpty { + if let organization = model.hackAccountState?.organizationName, !organization.isEmpty { + if let team = model.hackAccountState?.teamName, !team.isEmpty { return "\(organization) • \(team)" } return organization } - if toolbarHackAccountState?.authenticated == false { + if model.hackAccountState?.authenticated == false { return "Signed out" } return nil } private var toolbarAccountInitials: String? { - guard toolbarHackAccountState?.authenticated == true else { + guard model.hackAccountState?.authenticated == true else { return nil } - let source = toolbarHackAccountState?.userDisplayName ?? toolbarHackAccountState?.userEmail ?? "" + let source = model.hackAccountState?.userDisplayName ?? model.hackAccountState?.userEmail ?? "" let tokens = source .split(whereSeparator: { $0 == " " || $0 == "." || $0 == "@" || $0 == "_" || $0 == "-" }) .prefix(2) @@ -692,24 +693,20 @@ public struct DashboardView: View { } private var toolbarAvatarBackgroundColor: Color { - guard toolbarHackAccountState?.authenticated == true else { + guard model.hackAccountState?.authenticated == true else { return Color.primary.opacity(0.08) } return Color.accentColor.opacity(colorScheme == .dark ? 0.30 : 0.18) } private var toolbarAvatarForegroundColor: Color { - toolbarHackAccountState?.authenticated == true ? .accentColor : .secondary + model.hackAccountState?.authenticated == true ? .accentColor : .secondary } private var globalToggleMenuTitle: String { model.globalInfraRunning ? "Stop local runtime" : "Start local runtime" } - private func refreshToolbarHackAccountState() async { - toolbarHackAccountState = await model.inspectHackAccountSettingsState() - } - private var footer: some View { VStack(alignment: .leading, spacing: 8) { if let errorMessage = model.errorMessage { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index da4a1a728..ee8908295 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -426,8 +426,6 @@ struct SettingsSectionHeader: View { private struct AccountSettingsView: View { @Environment(DashboardModel.self) private var model @Binding var selection: SettingsSidebarItem - @State private var hackAccountState: HackAccountSettingsState? = nil - @State private var isLoadingHackAccount = false var body: some View { ScrollView { @@ -438,12 +436,9 @@ private struct AccountSettingsView: View { subtitle: "Sign in to Hack for shared remote features. Local-only workflows still work without an account." ) HackAccountSettingsCard( - state: hackAccountState, - isLoading: isLoadingHackAccount, - providerName: nil - ) { - Task { await refreshHackAccountState() } - } + state: model.hackAccountState, + isLoading: model.isLoadingHackAccountState + ) GlassCard(title: "Connected services", systemImage: "link.badge.plus") { VStack(alignment: .leading, spacing: 12) { integrationShortcutRow( @@ -485,7 +480,7 @@ private struct AccountSettingsView: View { .padding(24) } .task { - await refreshHackAccountState() + await model.refreshHackAccountState(force: false, updateErrorMessage: false) } } @@ -509,51 +504,25 @@ private struct AccountSettingsView: View { .adaptiveToolbarButton() } } - - private func refreshHackAccountState() async { - isLoadingHackAccount = true - defer { isLoadingHackAccount = false } - hackAccountState = await model.inspectHackAccountSettingsState() - } } private struct HackAccountSettingsCard: View { @Environment(DashboardModel.self) private var model let state: HackAccountSettingsState? let isLoading: Bool - let providerName: String? - let onRefresh: () -> Void @State private var isSubmitting = false var body: some View { - GlassCard(title: "Hack account", systemImage: "person.crop.circle.badge.checkmark") { + GlassCard(title: "Status", systemImage: "person.crop.circle.badge.checkmark") { HStack(alignment: .center, spacing: 8) { - if let state { - StatusPill( - text: state.authEnabled ? "Hack auth ready" : "Hack auth unavailable", - tone: state.authEnabled ? .good : .warn - ) - StatusPill( - text: state.authenticated ? "Signed in" : "Signed out", - tone: state.authenticated ? .good : .neutral - ) - StatusPill( - text: state.validated ? "Broker validated" : state.tokenStored ? "Stored locally" : "No local session", - tone: state.validated ? .good : state.tokenStored ? .neutral : .warn - ) - StatusPill( - text: state.manageAccountAvailable ? "Manage account ready" : "Manage account unavailable", - tone: state.manageAccountAvailable ? .good : .neutral - ) - } else { - StatusPill(text: "Auth status unavailable", tone: .warn) - } + StatusPill(text: primaryStatusText, tone: primaryStatusTone) Spacer() - Button("Manage account") { - openResolvedManageURL() + if let manageURL = resolvedManageURL, state?.authenticated == true { + Button("Manage account") { + openResolvedManageURL(manageURL) + } + .adaptiveToolbarButton() } - .adaptiveToolbarButton() - .disabled(resolvedManageURL == nil) if let state, state.authenticated { Button("Sign out") { Task { await handleSignOut() } @@ -561,16 +530,12 @@ private struct HackAccountSettingsCard: View { .adaptiveToolbarButton() .disabled(isBusy) } else { - Button("Sign in") { + Button("Sign in to Hack") { Task { await handleSignIn() } } .adaptiveToolbarButtonProminent() .disabled(isBusy || state?.authEnabled == false) } - Button("Refresh") { - onRefresh() - } - .adaptiveToolbarButton() if isBusy { ProgressView() .controlSize(.small) @@ -581,64 +546,62 @@ private struct HackAccountSettingsCard: View { .font(.mono(.caption)) .foregroundStyle(.secondary) - if let state { - if state.authenticated { - detailRow(title: "Hack user", value: state.userDisplayName ?? "Authenticated session") - if let email = state.userEmail, !email.isEmpty { - detailRow(title: "Email", value: email, selectable: true) - } - if let organizationName = state.organizationName, !organizationName.isEmpty { - detailRow(title: "Organization", value: organizationName) - } - if let teamName = state.teamName, !teamName.isEmpty { - detailRow(title: "Team", value: teamName) - } - } else { - Text(unauthenticatedMessage(for: state)) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + if let state, state.authenticated { + detailRow(title: "Hack user", value: state.userDisplayName ?? "Authenticated session") + if let email = state.userEmail, !email.isEmpty { + detailRow(title: "Email", value: email, selectable: true) } - - detailRow( - title: "Manage account", - value: state.manageAccountAvailable - ? "Available in browser" - : "Available after broker auth is ready" - ) - detailRow(title: "Broker", value: state.brokerBaseURL, selectable: true) - - if let reason = state.authReason, !reason.isEmpty { - detailRow(title: "Session note", value: reason) + if let organizationName = state.organizationName, !organizationName.isEmpty { + detailRow(title: "Organization", value: organizationName) } - if let accessControlMode = state.accessControlMode, !accessControlMode.isEmpty { - detailRow(title: "Access mode", value: accessControlMode) + if let teamName = state.teamName, !teamName.isEmpty { + detailRow(title: "Team", value: teamName) } - } else { - Text( - "Hack Desktop could not read Hack auth status yet. Shared provider setup still depends on the broker being reachable." - ) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + } else if let sessionHint { + Text(sessionHint) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) } } } - private var summaryMessage: String { - if let state, state.authenticated { - if let providerName, !providerName.isEmpty { - return - "\(providerName) accounts below are connected under this Hack account. Saved profiles stay local to this Mac and only decide routing defaults." - } - return - "This Hack account owns shared provider connections and other broker-backed features. Local-only project and ticket workflows remain available without sign-in." + private var primaryStatusText: String { + guard let state else { + return "Status unavailable" + } + if !state.authEnabled { + return "Unavailable" + } + if state.authenticated { + return "Signed in" } + if state.tokenStored && !state.validated { + return "Session stale" + } + return "Sign in required" + } - if let providerName, !providerName.isEmpty { - return - "Sign in to Hack before connecting \(providerName) for shared remote features. Linked login methods and account management stay in the browser, while saved profiles here remain local routing labels." + private var primaryStatusTone: StatusTone { + guard let state else { + return .warn + } + if !state.authEnabled { + return .warn + } + if state.authenticated { + return .good + } + if state.tokenStored && !state.validated { + return .warn + } + return .neutral + } + + private var summaryMessage: String { + if let state, state.authenticated { + return "Shared provider connections and broker-backed features use this Hack account." } - return - "Use this card to sign in to Hack for shared remote features. External provider pages manage connected provider accounts and local routing profiles, not your first-party Hack identity." + return "Sign in to use shared provider connections and other broker-backed features." } private var isBusy: Bool { @@ -646,52 +609,44 @@ private struct HackAccountSettingsCard: View { } private var resolvedManageURL: String? { - guard let state else { + guard let state, state.authenticated else { return nil } return state.accountURL ?? state.shellURL } + private var sessionHint: String? { + guard let state else { + return "Hack Desktop has not loaded account state yet." + } + if state.tokenStored && !state.validated { + return "A stored session could not be validated. Sign in again to refresh it." + } + if !state.authEnabled { + return "Hack auth is not available on the broker yet, so shared remote features are unavailable from this Mac." + } + return "Local-only workflows still work without signing in." + } + private func handleSignIn() async { isSubmitting = true defer { isSubmitting = false } - if await model.loginHackAccount() != nil { - onRefresh() - return - } - onRefresh() + _ = await model.loginHackAccount() } private func handleSignOut() async { isSubmitting = true defer { isSubmitting = false } - if await model.logoutHackAccount() != nil { - onRefresh() - return - } - onRefresh() + _ = await model.logoutHackAccount() } - private func openResolvedManageURL() { - guard let urlString = resolvedManageURL, let url = URL(string: urlString) else { + private func openResolvedManageURL(_ urlString: String) { + guard let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } - private func unauthenticatedMessage(for state: HackAccountSettingsState) -> String { - if state.tokenStored && !state.validated { - return "Hack has a locally stored session token, but the broker could not validate it. Sign in again to refresh this desktop session." - } - if state.tokenStored { - return "Hack has a stored session, but no authenticated browser session is active right now. Sign in again to continue." - } - if state.authEnabled { - return "Sign in to Hack before managing shared provider accounts, project routing, or other broker-backed features from this Mac." - } - return "Hack auth is not fully available on the broker yet, so shared remote features are unavailable from this Mac." - } - private func detailRow(title: String, value: String, selectable: Bool = false) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) @@ -2969,8 +2924,6 @@ private struct LinearExtensionSettingsView: View { @State private var isSavingAssigneeMapping = false @State private var message = "" @State private var lastDiagnosticsRefreshAt: Date? = nil - @State private var hackAccountState: HackAccountSettingsState? = nil - @State private var isLoadingHackAccount = false var body: some View { ScrollView { @@ -3022,7 +2975,7 @@ private struct LinearExtensionSettingsView: View { .font(.mono(.caption)) .foregroundStyle(.secondary) Spacer() - if hackAccountState?.authenticated == true { + if model.hackAccountState?.authenticated == true { Button { toggleLinearAuthFlow() } label: { @@ -3040,8 +2993,8 @@ private struct LinearExtensionSettingsView: View { } } - if hackAccountState?.authenticated != true { - Text("Sign in to Hack before connecting shared Linear accounts.") + if model.hackAccountState?.authenticated != true { + Text("Sign in to Hack to connect shared accounts.") .font(.mono(.caption)) .foregroundStyle(.secondary) } @@ -3429,13 +3382,7 @@ private struct LinearExtensionSettingsView: View { } .task { await loadConfigFromDisk() - await refreshHackAccountState() - } - .onChange(of: model.lastUpdated) { _, _ in - Task { - await loadConfigFromDisk() - await refreshHackAccountState() - } + await model.refreshHackAccountState(force: false, updateErrorMessage: false) } .onChange(of: assigneeMappingProfile) { _, _ in Task { await refreshLinearAssigneeMappings() } @@ -3525,12 +3472,6 @@ private struct LinearExtensionSettingsView: View { lastDiagnosticsRefreshAt = Date() } - private func refreshHackAccountState() async { - isLoadingHackAccount = true - defer { isLoadingHackAccount = false } - hackAccountState = await model.inspectHackAccountSettingsState() - } - private func refreshLinearProfileStatuses() async { // Passive settings pages should not trigger keychain-backed token resolution. profileStatusById = [:] @@ -3990,8 +3931,6 @@ private struct GitHubExtensionSettingsView: View { @State private var message = "" @State private var lastDiagnosticsRefreshAt: Date? = nil @State private var systemIdentity: GitSystemIdentity? = nil - @State private var hackAccountState: HackAccountSettingsState? = nil - @State private var isLoadingHackAccount = false var body: some View { ScrollView { @@ -4083,7 +4022,7 @@ private struct GitHubExtensionSettingsView: View { .font(.mono(.caption)) .foregroundStyle(.secondary) Spacer() - if hackAccountState?.authenticated == true { + if model.hackAccountState?.authenticated == true { Button { toggleGitHubAuthFlow() } label: { @@ -4101,8 +4040,8 @@ private struct GitHubExtensionSettingsView: View { } } - if hackAccountState?.authenticated != true { - Text("Sign in to Hack before connecting shared GitHub accounts or app installs.") + if model.hackAccountState?.authenticated != true { + Text("Sign in to Hack to connect shared accounts.") .font(.mono(.caption)) .foregroundStyle(.secondary) } @@ -4237,13 +4176,7 @@ private struct GitHubExtensionSettingsView: View { } .task { await loadConfigFromDisk() - await refreshHackAccountState() - } - .onChange(of: model.lastUpdated) { _, _ in - Task { - await loadConfigFromDisk() - await refreshHackAccountState() - } + await model.refreshHackAccountState(force: false, updateErrorMessage: false) } .onChange(of: model.githubOAuthDeepLinkContext) { _, deepLink in guard let deepLink else { return } @@ -4321,12 +4254,6 @@ private struct GitHubExtensionSettingsView: View { lastDiagnosticsRefreshAt = Date() } - private func refreshHackAccountState() async { - isLoadingHackAccount = true - defer { isLoadingHackAccount = false } - hackAccountState = await model.inspectHackAccountSettingsState() - } - private func refreshGitHubProfileStatuses() async { // Passive settings pages should not trigger keychain-backed token resolution. profileStatusById = [:] From 5dcdcbaf9dfa338f5fd15be16e6e8a267a00e2a5 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 13:04:40 -0500 Subject: [PATCH 16/54] Calm desktop settings account and routing UX --- .../DashboardFeature/ProjectDetailView.swift | 278 +++++------ .../SettingsOverlayView.swift | 444 ++++++------------ 2 files changed, 274 insertions(+), 448 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 7a15cf04f..637a03186 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -691,9 +691,6 @@ struct ProjectDetailView: View { VStack(alignment: .leading, spacing: 4) { Text("Project settings") .font(.system(size: 20, weight: .semibold)) - Text("Keep runtime defaults and provider routing here. Day-to-day sync stays in Tickets.") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) } Spacer() if executionTargetLoading || executionTargetSaving { @@ -705,7 +702,7 @@ struct ProjectDetailView: View { VStack(alignment: .leading, spacing: 16) { projectSettingsField( title: "Execution mode", - description: "Choose whether this repo defaults to local work or a routed remote node.", + help: "Choose whether this repo defaults to local work on this Mac or routes execution to a remote node.", footnote: executionMode.summary ) { Menu { @@ -739,11 +736,11 @@ struct ProjectDetailView: View { projectSettingsField( title: "Default node", - description: "Applies when execution mode routes work off this Mac.", + help: "Choose the default node when execution mode is set to remote.", footnote: selectedNodeSummary ) { if executionMode == .local { - projectSettingsMenuLabel("Local", isDisabled: true) + projectSettingsStaticValue("Uses this Mac while execution mode stays local") .accessibilityLabel("Default node") } else { Menu { @@ -794,7 +791,7 @@ struct ProjectDetailView: View { projectSettingsField( title: "GitHub profile", - description: "Pick the saved GitHub profile this repo should use for remote Git operations.", + help: "Pick the saved GitHub profile this repo should use for remote Git operations.", footnote: selectedGitSummary ) { Menu { @@ -919,17 +916,12 @@ struct ProjectDetailView: View { private var linearRoutingSheet: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 6) { - Text("Linear routing") - .font(.system(size: 22, weight: .semibold)) - Text("Choose which saved Linear account and projects this repo uses. Sync actions stay in Tickets.") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - } + Text("Linear routing") + .font(.system(size: 22, weight: .semibold)) routingSheetSection( title: "Saved profile", - description: "Choose the saved Linear profile Hack should use for this repo." + help: "Choose which saved Linear profile this repo routes through." ) { if hasConfiguredLinearProfile { projectSettingsControlGroup(footnote: selectedLinearProfileSummary) { @@ -995,9 +987,6 @@ struct ProjectDetailView: View { } } else { VStack(alignment: .leading, spacing: 10) { - Text("Connect a Linear provider account before binding routes for this repo.") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) Button("Connect Linear") { NotificationCenter.default.post( name: .hackSettingsRequested, @@ -1010,140 +999,137 @@ struct ProjectDetailView: View { } } - routingSheetSection( - title: "Default project route", - description: "This is the primary Linear project for pull, push, and review." - ) { - projectSettingsControlGroup(footnote: selectedLinearProjectSummary) { - VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel("Primary route") - projectSettingsControlShell(maxWidth: 360) { - if linearProjectOptions.isEmpty { - projectSettingsMenuLabel("Unbound", isDisabled: true) - .accessibilityLabel("Default project route") - } else { - Menu { - Button { - guard !selectedLinearProjectId.isEmpty else { - return - } - selectedLinearProjectId = "" - guard !executionTargetLoading else { - return - } - Task { await persistLinearProjectBindingSelection() } - } label: { - if selectedLinearProjectId.isEmpty { - Label("Unbound", systemImage: "checkmark") - } else { - Text("Unbound") - } - } - ForEach(linearProjectOptions) { linearProject in + if hasConfiguredLinearProfile { + routingSheetSection( + title: "Default project route", + help: "This is the primary Linear project for pull, push, and review." + ) { + projectSettingsControlGroup(footnote: selectedLinearProjectSummary) { + VStack(alignment: .leading, spacing: 10) { + projectSettingsFieldLabel("Primary route") + projectSettingsControlShell(maxWidth: 360) { + if linearProjectOptions.isEmpty { + projectSettingsStaticValue("No projects available") + .accessibilityLabel("Default project route") + } else { + Menu { Button { - guard selectedLinearProjectId != linearProject.id else { + guard !selectedLinearProjectId.isEmpty else { return } - selectedLinearProjectId = linearProject.id + selectedLinearProjectId = "" guard !executionTargetLoading else { return } Task { await persistLinearProjectBindingSelection() } } label: { - let label = linearProjectMenuLabel(linearProject) - if selectedLinearProjectId == linearProject.id { - Label(label, systemImage: "checkmark") + if selectedLinearProjectId.isEmpty { + Label("Unbound", systemImage: "checkmark") } else { - Text(label) + Text("Unbound") + } + } + ForEach(linearProjectOptions) { linearProject in + Button { + guard selectedLinearProjectId != linearProject.id else { + return + } + selectedLinearProjectId = linearProject.id + guard !executionTargetLoading else { + return + } + Task { await persistLinearProjectBindingSelection() } + } label: { + let label = linearProjectMenuLabel(linearProject) + if selectedLinearProjectId == linearProject.id { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } } } + } label: { + projectSettingsMenuLabel(selectedLinearProjectSummary) } - } label: { - projectSettingsMenuLabel(selectedLinearProjectSummary) + .buttonStyle(.plain) + .accessibilityLabel("Default project route") } - .buttonStyle(.plain) - .accessibilityLabel("Default project route") } - } - HStack(alignment: .center, spacing: 12) { if !linearBoundProjectId.isEmpty { Button("Clear") { Task { await clearLinearProjectBinding() } } .adaptiveToolbarButton() } - Spacer() } } + if let defaultTarget = defaultLinearRouteTarget { + linearScopeRouteRow(target: defaultTarget, isDefault: true) + } } - if let defaultTarget = defaultLinearRouteTarget { - linearScopeRouteRow(target: defaultTarget, isDefault: true) - } - } - - routingSheetSection( - title: "Additional project routes", - description: "Link extra Linear projects when this repo needs review or autosync coverage beyond the default project route." - ) { - if linearAdditionalProjects.isEmpty { - Text("No additional linked projects.") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(linearAdditionalProjects) { target in - linearScopeRouteRow(target: target, isDefault: false) + routingSheetSection( + title: "Additional project routes", + help: "Link extra Linear projects when this repo needs review or autosync coverage beyond the default project route." + ) { + if linearAdditionalProjects.isEmpty { + Text("No additional linked projects.") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(linearAdditionalProjects) { target in + linearScopeRouteRow(target: target, isDefault: false) + } } } - } - - projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { - VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel("Add linked project") - HStack(spacing: 8) { - projectSettingsControlShell(maxWidth: 360) { - if availableAdditionalLinearProjects.isEmpty { - projectSettingsMenuLabel("Choose project", isDisabled: true) - .accessibilityLabel("Add linked project") - } else { - Menu { - ForEach(availableAdditionalLinearProjects) { linearProject in - Button { - selectedAdditionalLinearProjectId = linearProject.id - } label: { - let label = linearProjectMenuLabel(linearProject) - if selectedAdditionalLinearProjectId == linearProject.id { - Label(label, systemImage: "checkmark") - } else { - Text(label) + projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { + VStack(alignment: .leading, spacing: 10) { + projectSettingsFieldLabel("Add linked project") + HStack(spacing: 8) { + projectSettingsControlShell(maxWidth: 360) { + if availableAdditionalLinearProjects.isEmpty { + projectSettingsStaticValue("No more projects available") + .accessibilityLabel("Add linked project") + } else { + Menu { + ForEach(availableAdditionalLinearProjects) { linearProject in + Button { + selectedAdditionalLinearProjectId = linearProject.id + } label: { + let label = linearProjectMenuLabel(linearProject) + if selectedAdditionalLinearProjectId == linearProject.id { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } } } - } - } label: { - projectSettingsMenuLabel( - additionalProjectMenuLabel( - selectedAdditionalLinearProjectId + } label: { + projectSettingsMenuLabel( + additionalProjectMenuLabel( + selectedAdditionalLinearProjectId + ) ) - ) + } + .buttonStyle(.plain) + .accessibilityLabel("Add linked project") } - .buttonStyle(.plain) - .accessibilityLabel("Add linked project") } - } - Button("Add") { - Task { await persistAdditionalLinearProjectSelection() } - } - .adaptiveToolbarButton() - .disabled( - selectedAdditionalLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - ) + Button("Add") { + Task { await persistAdditionalLinearProjectSelection() } + } + .adaptiveToolbarButton() + .disabled( + selectedAdditionalLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + ) - Spacer() + Spacer() + } } } } @@ -1231,16 +1217,12 @@ struct ProjectDetailView: View { private func projectSettingsField( title: String, - description: String, + help: String? = nil, footnote: String, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.system(size: 13, weight: .semibold)) - Text(description) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) + projectSettingsFieldLabel(title, help: help) projectSettingsControlShell(maxWidth: 360) { content() } @@ -1258,11 +1240,19 @@ struct ProjectDetailView: View { } } - private func projectSettingsFieldLabel(_ text: String) -> some View { - Text(text) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) + private func projectSettingsFieldLabel(_ text: String, help: String? = nil) -> some View { + HStack(alignment: .center, spacing: 6) { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + if let help, !help.isEmpty { + Image(systemName: "info.circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + .help(help) + } + } } private func projectSettingsMenuLabel( @@ -1283,6 +1273,13 @@ struct ProjectDetailView: View { .contentShape(Rectangle()) } + private func projectSettingsStaticValue(_ text: String) -> some View { + Text(text) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + private func projectSettingsControlShell( maxWidth: CGFloat? = nil, @ViewBuilder content: () -> Content @@ -1317,16 +1314,19 @@ struct ProjectDetailView: View { private func routingSheetSection( title: String, - description: String, + help: String? = nil, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 6) { Text(title) .font(.system(size: 15, weight: .semibold)) - Text(description) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) + if let help, !help.isEmpty { + Image(systemName: "info.circle") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.tertiary) + .help(help) + } } content() @@ -1871,8 +1871,8 @@ struct ProjectDetailView: View { } .disabled(isLinearAutosyncBusy(for: normalizedTarget)) - Menu { - if !isDefault { + if !isDefault { + Menu { Button("Make default") { Task { await makeAdditionalLinearProjectDefault(normalizedTarget) } } @@ -1881,11 +1881,11 @@ struct ProjectDetailView: View { } label: { Text("Remove") } + } label: { + Label("Actions", systemImage: "ellipsis.circle") } - } label: { - Label("Actions", systemImage: "ellipsis.circle") + .buttonStyle(PressableIconButtonStyle()) } - .buttonStyle(PressableIconButtonStyle()) } HStack(spacing: 8) { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index ee8908295..92963bd2c 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -147,6 +147,9 @@ struct SettingsOverlayView: View { .onExitCommand { onClose() } + .task { + await model.refreshHackAccountState(force: false, updateErrorMessage: false) + } } private var topBar: some View { @@ -224,7 +227,7 @@ struct SettingsOverlayView: View { Group { switch selection { case .account: - AccountSettingsView(selection: $selection) + AccountSettingsView() case .preferences: PreferencesSettingsView() case .updates: @@ -407,7 +410,7 @@ private struct UpdatesSettingsView: View { struct SettingsSectionHeader: View { let breadcrumb: String let title: String - let subtitle: String + let subtitle: String? var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -416,16 +419,17 @@ struct SettingsSectionHeader: View { .foregroundStyle(.secondary) Text(title) .font(.mono(.headline, weight: .semibold)) - Text(subtitle) - .font(.mono(.caption)) - .foregroundStyle(.secondary) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } } } } private struct AccountSettingsView: View { @Environment(DashboardModel.self) private var model - @Binding var selection: SettingsSidebarItem var body: some View { ScrollView { @@ -433,76 +437,15 @@ private struct AccountSettingsView: View { SettingsSectionHeader( breadcrumb: "Settings / Account", title: "Hack account", - subtitle: "Sign in to Hack for shared remote features. Local-only workflows still work without an account." + subtitle: nil ) HackAccountSettingsCard( state: model.hackAccountState, isLoading: model.isLoadingHackAccountState ) - GlassCard(title: "Connected services", systemImage: "link.badge.plus") { - VStack(alignment: .leading, spacing: 12) { - integrationShortcutRow( - item: .linear, - title: "Linear", - subtitle: "Shared provider accounts, routing defaults, and sync policy." - ) - Divider() - .opacity(0.2) - integrationShortcutRow( - item: .github, - title: "GitHub", - subtitle: "Shared provider accounts, PR automation routing, and app installs." - ) - Divider() - .opacity(0.2) - integrationShortcutRow( - item: .cloudflare, - title: "Cloudflare", - subtitle: "Tunnel connectivity and deployment access." - ) - Divider() - .opacity(0.2) - integrationShortcutRow( - item: .railway, - title: "Railway", - subtitle: "Service bootstrap defaults and provider access." - ) - Divider() - .opacity(0.2) - integrationShortcutRow( - item: .tailscale, - title: "Tailscale", - subtitle: "Tailnet connectivity and auth key management." - ) - } - } } .padding(24) } - .task { - await model.refreshHackAccountState(force: false, updateErrorMessage: false) - } - } - - private func integrationShortcutRow( - item: SettingsSidebarItem, - title: String, - subtitle: String - ) -> some View { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(.mono(.subheadline, weight: .semibold)) - Text(subtitle) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - Spacer() - Button("Open") { - selection = item - } - .adaptiveToolbarButton() - } } } @@ -1963,11 +1906,6 @@ private struct RailwayExtensionSettingsView: View { .font(.mono(.caption)) .foregroundStyle(.secondary) } - if let lastDiagnosticsRefreshAt { - Text("Last checked \(lastDiagnosticsRefreshAt.formatted(date: .abbreviated, time: .shortened))") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } if let error = diagnostics?.error, !error.isEmpty { Text(error) .font(.mono(.caption)) @@ -1975,13 +1913,6 @@ private struct RailwayExtensionSettingsView: View { } HStack(spacing: 10) { - Button { - Task { await refreshRailwayDiagnostics() } - } label: { - Label("Refresh status", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButtonProminent() - Button { openTailscaleSettings() } label: { @@ -2062,13 +1993,15 @@ private struct RailwayExtensionSettingsView: View { .adaptiveToolbarButtonProminent() .disabled(isSavingConfig || isBootstrapping) - Button { - Task { await bootstrapRailwayNode() } - } label: { - Label("Bootstrap node now", systemImage: "play.fill") + if railwayInstalled && railwayAuthenticated && canBootstrapRailwayNode { + Button { + Task { await bootstrapRailwayNode() } + } label: { + Label("Bootstrap node now", systemImage: "play.fill") + } + .adaptiveToolbarButton() + .disabled(isBootstrapping) } - .adaptiveToolbarButton() - .disabled(!canBootstrapRailwayNode || isBootstrapping) Button { openGlobalCommandInTerminalPanel( @@ -2215,6 +2148,9 @@ private struct RailwayExtensionSettingsView: View { } private var canBootstrapRailwayNode: Bool { + if !railwayInstalled || !railwayAuthenticated { + return false + } if railwayPrivate, !privateBootstrapReady { return false } @@ -2931,7 +2867,7 @@ private struct LinearExtensionSettingsView: View { SettingsSectionHeader( breadcrumb: "Settings / Linear", title: "Linear", - subtitle: "Connect accounts, choose a default routing profile, and tune sync behavior." + subtitle: nil ) GlassCard(title: "Extension status", systemImage: "line.3.horizontal.decrease.circle") { HStack(alignment: .center, spacing: 8) { @@ -2961,19 +2897,10 @@ private struct LinearExtensionSettingsView: View { .controlSize(.small) } } - - if let lastDiagnosticsRefreshAt { - Text("Last checked \(lastDiagnosticsRefreshAt.formatted(date: .abbreviated, time: .shortened))") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } } GlassCard(title: "Accounts", systemImage: "point.3.connected.trianglepath.dotted") { HStack(alignment: .center, spacing: 10) { - Text("Saved profiles decide which Linear account a project uses.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) Spacer() if model.hackAccountState?.authenticated == true { Button { @@ -2993,12 +2920,6 @@ private struct LinearExtensionSettingsView: View { } } - if model.hackAccountState?.authenticated != true { - Text("Sign in to Hack to connect shared accounts.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if isAuthenticating { StatusPill(text: "Waiting for browser auth", tone: .good) } else if let authFlowStatus { @@ -3022,7 +2943,6 @@ private struct LinearExtensionSettingsView: View { let profileStatus = profileStatusById[profile.id] let accountLabel = linearAccountLabel(profile: profile, status: profileStatus) let accountEmail = linearAccountEmail(profile: profile, status: profileStatus) - let accountId = linearAccountId(profile: profile, status: profileStatus) VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Text(accountLabel ?? profile.id) @@ -3039,63 +2959,49 @@ private struct LinearExtensionSettingsView: View { } .adaptiveToolbarButton() } - Button { - Task { - await reconnectLinearProfile( - profile.id, - setDefault: profile.isDefault - ) + if model.hackAccountState?.authenticated == true { + Button { + Task { + await reconnectLinearProfile( + profile.id, + setDefault: profile.isDefault + ) + } + } label: { + Label("Reconnect", systemImage: "arrow.clockwise.circle") } - } label: { - Label("Reconnect", systemImage: "arrow.clockwise.circle") - } - .adaptiveToolbarButtonProminent() - .disabled( - isAuthenticating || - disconnectingLinearProfiles.contains(profile.id) - ) - Button(role: .destructive) { - Task { await disconnectLinearProfile(profile.id) } - } label: { - Label("Disconnect", systemImage: "trash") + .adaptiveToolbarButtonProminent() + .disabled( + isAuthenticating || + disconnectingLinearProfiles.contains(profile.id) + ) + Button(role: .destructive) { + Task { await disconnectLinearProfile(profile.id) } + } label: { + Label("Disconnect", systemImage: "trash") + } + .adaptiveToolbarButton() + .disabled( + isAuthenticating || + disconnectingLinearProfiles.contains(profile.id) + ) } - .adaptiveToolbarButton() - .disabled( - isAuthenticating || - disconnectingLinearProfiles.contains(profile.id) - ) } - Text("profile id: \(profile.id)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - if let accountLabel, !accountLabel.isEmpty { - Text("account: \(accountLabel)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } if let accountEmail, !accountEmail.isEmpty { - Text("email: \(accountEmail)") + Text(accountEmail) .font(.mono(.caption2)) .foregroundStyle(.secondary) .textSelection(.enabled) } - Text("auth: \(profile.authRef) • service: \(profile.service)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - Text("api: \(profile.apiUrl)") + Text("Profile \(profile.id)") .font(.mono(.caption2)) - .foregroundStyle(.secondary) - if let accountId, !accountId.isEmpty { - Text("account id: \(accountId)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } + .foregroundStyle(.tertiary) if let profileStatus, let tokenExpiresAt = profileStatus.tokenExpiresAt, !tokenExpiresAt.isEmpty { - Text("token expires: \(tokenExpiresAt)") + Text("Token expires \(tokenExpiresAt)") .font(.mono(.caption2)) .foregroundStyle(.tertiary) } @@ -3106,123 +3012,72 @@ private struct LinearExtensionSettingsView: View { } } } - Text( - "Disconnect removes the stored token, keeps the profile record for reconnect later, and leaves any existing project routing in place until you switch it." - ) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) } } GlassCard(title: "Sync fields", systemImage: "arrow.triangle.branch") { VStack(alignment: .leading, spacing: 12) { - Text(linearSyncPolicySummary) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text("Manual sync stays the default. These toggles control which fields Hack will translate when you run sync.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 10) { - Toggle("Sync labels", isOn: $syncLabels) - .toggleStyle(.switch) - .onChange(of: syncLabels) { _, newValue in - guard !suppressSyncToggleChange else { return } - Task { - await saveLinearSyncToggle( - key: "labels", - value: newValue, - successMessage: newValue ? "Linear label sync enabled." : "Linear label sync disabled." - ) - } - } - Text( - "Translate Linear labels and Hack categories during manual sync. Review is still needed when names diverge or one side has no clean parallel." + syncToggleRow( + title: "Sync labels", + help: "Translate Linear labels and Hack categories during manual sync.", + isOn: $syncLabels + ) { newValue in + await saveLinearSyncToggle( + key: "labels", + value: newValue, + successMessage: newValue ? "Linear label sync enabled." : "Linear label sync disabled." ) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) } - - VStack(alignment: .leading, spacing: 10) { - Toggle("Sync statuses", isOn: $syncStatuses) - .toggleStyle(.switch) - .onChange(of: syncStatuses) { _, newValue in - guard !suppressSyncToggleChange else { return } - Task { - await saveLinearSyncToggle( - key: "statuses", - value: newValue, - successMessage: newValue ? "Linear status sync enabled." : "Linear status sync disabled." - ) - } - } - Text( - "Translate status/state when both systems expose a matching workflow step. Conflicts on origin-owned status stay review-needed instead of auto-overwriting." + syncToggleRow( + title: "Sync statuses", + help: "Translate workflow state when both sides expose a matching status.", + isOn: $syncStatuses + ) { newValue in + await saveLinearSyncToggle( + key: "statuses", + value: newValue, + successMessage: newValue ? "Linear status sync enabled." : "Linear status sync disabled." ) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) } - - VStack(alignment: .leading, spacing: 10) { - Toggle("Sync dependencies", isOn: $syncDependencies) - .toggleStyle(.switch) - .onChange(of: syncDependencies) { _, newValue in - guard !suppressSyncToggleChange else { return } - Task { - await saveLinearSyncToggle( - key: "dependencies", - value: newValue, - successMessage: newValue ? "Linear dependency sync enabled." : "Linear dependency sync disabled." - ) - } - } - Text( - "Translate dependency and sub-issue style relationships when the counterpart ticket exists. Missing or ambiguous links stay review-needed." + syncToggleRow( + title: "Sync dependencies", + help: "Translate dependency and sub-issue style relationships when the counterpart ticket exists.", + isOn: $syncDependencies + ) { newValue in + await saveLinearSyncToggle( + key: "dependencies", + value: newValue, + successMessage: newValue ? "Linear dependency sync enabled." : "Linear dependency sync disabled." ) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) } - - VStack(alignment: .leading, spacing: 10) { - Toggle("Sync project mapping", isOn: $syncProjects) - .toggleStyle(.switch) - .onChange(of: syncProjects) { _, newValue in - guard !suppressSyncToggleChange else { return } - Task { - await saveLinearSyncToggle( - key: "projects", - value: newValue, - successMessage: newValue ? "Linear project sync enabled." : "Linear project sync disabled." - ) - } - } - Text( - "Allow Hack projects to read and write the paired Linear project binding. The account choice still comes from the global default or a per-project override." + syncToggleRow( + title: "Sync project mapping", + help: "Allow Hack projects to read and write the paired Linear project binding.", + isOn: $syncProjects + ) { newValue in + await saveLinearSyncToggle( + key: "projects", + value: newValue, + successMessage: newValue ? "Linear project sync enabled." : "Linear project sync disabled." ) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) } - - InlineCallout( - tone: .warn, - title: "Review still needed", - message: linearSyncReviewSummary, - actions: [] - ) + HStack(alignment: .center, spacing: 6) { + Text("Review still stays manual") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + Image(systemName: "info.circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + .help(linearSyncReviewSummary) + Spacer() + } } } GlassCard(title: "Assignee mapping", systemImage: "person.crop.rectangle.stack") { VStack(alignment: .leading, spacing: 12) { - InlineCallout( - tone: .neutral, - title: "Best-effort assignee translation", - message: "Use explicit mappings when Hack assignee names do not line up cleanly with Linear users. Sync uses the profile and optional team scope here before it falls back to fuzzy email/name matching.", - actions: [] - ) - if linearProfiles.isEmpty { - Text("Connect a Linear provider account before managing assignee mappings.") + Text("Connect an account to add assignee mappings.") .font(.mono(.caption)) .foregroundStyle(.secondary) } else { @@ -3251,14 +3106,6 @@ private struct LinearExtensionSettingsView: View { Spacer() - Button { - Task { await refreshLinearAssigneeMappings() } - } label: { - Label("Reload", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButton() - .disabled(isLoadingAssigneeMappings || isSavingAssigneeMapping) - if isLoadingAssigneeMappings || isSavingAssigneeMapping { ProgressView() .controlSize(.small) @@ -3344,10 +3191,6 @@ private struct LinearExtensionSettingsView: View { TextField("Linear user email", text: $assigneeMappingLinearUserEmail) .textFieldStyle(.roundedBorder) - Text("Provide at least one Linear user field. Email is usually the safest stable fallback when user ids are not obvious.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - HStack(spacing: 8) { Button { Task { await saveLinearAssigneeMapping() } @@ -3382,7 +3225,6 @@ private struct LinearExtensionSettingsView: View { } .task { await loadConfigFromDisk() - await model.refreshHackAccountState(force: false, updateErrorMessage: false) } .onChange(of: assigneeMappingProfile) { _, _ in Task { await refreshLinearAssigneeMappings() } @@ -3400,6 +3242,29 @@ private struct LinearExtensionSettingsView: View { return diagnostics?.defaultProfile ?? "" } + private func syncToggleRow( + title: String, + help: String, + isOn: Binding, + save: @escaping @MainActor (Bool) async -> Void + ) -> some View { + HStack(alignment: .center, spacing: 10) { + Toggle(title, isOn: isOn) + .toggleStyle(.switch) + .onChange(of: isOn.wrappedValue) { _, newValue in + guard !suppressSyncToggleChange else { return } + Task { + await save(newValue) + } + } + Image(systemName: "info.circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + .help(help) + Spacer() + } + } + private var linearProfiles: [LinearProfileSummary] { (diagnostics?.profiles ?? []).sorted { lhs, rhs in lhs.id.localizedCaseInsensitiveCompare(rhs.id) == .orderedAscending @@ -3938,7 +3803,7 @@ private struct GitHubExtensionSettingsView: View { SettingsSectionHeader( breadcrumb: "Settings / GitHub", title: "GitHub", - subtitle: "Connect accounts and app installs, then choose the default profile for automation." + subtitle: nil ) GlassCard(title: "Extension status", systemImage: "chevron.left.forwardslash.chevron.right") { HStack(alignment: .center, spacing: 8) { @@ -3968,12 +3833,6 @@ private struct GitHubExtensionSettingsView: View { .controlSize(.small) } } - - if let lastDiagnosticsRefreshAt { - Text("Last checked \(lastDiagnosticsRefreshAt.formatted(date: .abbreviated, time: .shortened))") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } } GlassCard(title: "System Git identity", systemImage: "person.crop.circle.badge.checkmark") { @@ -4018,9 +3877,6 @@ private struct GitHubExtensionSettingsView: View { GlassCard(title: "Accounts", systemImage: "person.2.badge.gearshape") { HStack(alignment: .center, spacing: 10) { - Text("Saved profiles decide which GitHub account or app install a project or node uses.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) Spacer() if model.hackAccountState?.authenticated == true { Button { @@ -4040,12 +3896,6 @@ private struct GitHubExtensionSettingsView: View { } } - if model.hackAccountState?.authenticated != true { - Text("Sign in to Hack to connect shared accounts.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if isAuthenticating { StatusPill(text: "Waiting for browser callback", tone: .good) } else if let authFlowStatus { @@ -4079,10 +3929,6 @@ private struct GitHubExtensionSettingsView: View { profile: profile, status: profileStatus ) - let accountId = remoteAccountId( - profile: profile, - status: profileStatus - ) VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Text(accountHandle.map { "@\($0)" } ?? profile.id) @@ -4101,55 +3947,36 @@ private struct GitHubExtensionSettingsView: View { } Spacer() if !profile.isDefault { - Button { - Task { await saveGitHubDefaultProfile(profile.id) } - } label: { - Label("Set default", systemImage: "star") + if !(profile.mode.lowercased() == "app" && profile.installationId?.isEmpty != false) { + Button { + Task { await saveGitHubDefaultProfile(profile.id) } + } label: { + Label("Set default", systemImage: "star") + } + .adaptiveToolbarButton() } - .adaptiveToolbarButton() } } - Text("profile id: \(profile.id)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) if let accountHandle, !accountHandle.isEmpty { let accountNameSuffix = accountName.map { " (\($0))" } ?? "" - Text("account: @\(accountHandle)\(accountNameSuffix)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - if profile.mode.lowercased() == "app" { - Text( - profile.installationId?.isEmpty == false - ? "permissions: GitHub App installation scoped to selected repos/org" - : "permissions: install the GitHub App to enable repo-scoped access" - ) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } else { - Text("permissions: OAuth token scopes from connected account") + Text("@\(accountHandle)\(accountNameSuffix)") .font(.mono(.caption2)) .foregroundStyle(.secondary) } - Text("auth: \(profile.authRef) • service: \(profile.service)") + Text("Profile \(profile.id)") .font(.mono(.caption2)) - .foregroundStyle(.secondary) - if let accountId, !accountId.isEmpty { - Text("account id: \(accountId)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } + .foregroundStyle(.tertiary) if let profileStatus, let tokenExpiresAt = profileStatus.tokenExpiresAt, !tokenExpiresAt.isEmpty { - Text("token expires: \(tokenExpiresAt)") + Text("Token expires \(tokenExpiresAt)") .font(.mono(.caption2)) .foregroundStyle(.tertiary) } if let installation = profile.installationId, !installation.isEmpty { - Text("installation: \(installation)") + Text("Installation \(installation)") .font(.mono(.caption2)) .foregroundStyle(.tertiary) } @@ -4176,7 +4003,6 @@ private struct GitHubExtensionSettingsView: View { } .task { await loadConfigFromDisk() - await model.refreshHackAccountState(force: false, updateErrorMessage: false) } .onChange(of: model.githubOAuthDeepLinkContext) { _, deepLink in guard let deepLink else { return } From cc64baa7cd2881ca25fc902ef96717d4e98a298c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 13:26:29 -0500 Subject: [PATCH 17/54] Fix Hack auth browser login handoff --- apps/macos/App/HackDesktopApp.swift | 8 +-- .../DashboardFeature/DashboardModel.swift | 39 +++++++++++ .../HackCLIService/HackCLIClient.swift | 12 +++- apps/macos/README.md | 3 +- .../src/modules/better-auth/shell-plugin.ts | 68 ++++++++++++++++--- .../auth-broker/tests/session-auth.test.ts | 55 +++++++++++++++ src/commands/auth.ts | 11 ++- src/lib/auth-session.ts | 7 +- tests/auth-command.test.ts | 68 ++++++++++++++++++- 9 files changed, 249 insertions(+), 22 deletions(-) diff --git a/apps/macos/App/HackDesktopApp.swift b/apps/macos/App/HackDesktopApp.swift index 6eed10cfc..2e7a264b4 100644 --- a/apps/macos/App/HackDesktopApp.swift +++ b/apps/macos/App/HackDesktopApp.swift @@ -108,9 +108,9 @@ struct HackDesktopApp: App { @MainActor private func handleIncomingDeepLink(url: URL) { - guard model.ingestGitHubOAuthDeepLink(url: url) else { - return - } + let handledHackAuth = model.ingestHackAuthDeepLink(url: url) + let handledGitHub = model.ingestGitHubOAuthDeepLink(url: url) + guard handledHackAuth || handledGitHub else { return } #if os(macOS) NSApp.activate(ignoringOtherApps: true) @@ -119,7 +119,7 @@ struct HackDesktopApp: App { name: .hackSettingsRequested, object: nil, userInfo: [ - "pane": "github", + "pane": handledHackAuth ? "account" : "github", ] ) } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 8f9113107..eb0946e1d 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -1167,6 +1167,33 @@ public final class DashboardModel { } } + @discardableResult + public func ingestHackAuthDeepLink(url: URL) -> Bool { + guard isHackAuthCompletionDeepLink(url: url) else { + return false + } + errorMessage = nil + statusMessage = "Hack auth callback received. Finalizing…" + Task { @MainActor [weak self] in + guard let self else { return } + for attempt in 0..<6 { + await self.refreshHackAccountState(force: true, updateErrorMessage: false) + if let state = self.hackAccountState, + state.authenticated || state.tokenStored + { + self.statusMessage = state.authenticated + ? "Hack account connected." + : "Hack auth session stored locally." + return + } + if attempt < 5 { + try? await Task.sleep(for: .seconds(1)) + } + } + } + return true + } + @discardableResult public func ingestGitHubOAuthDeepLink(url: URL) -> Bool { guard let context = parseGitHubOAuthDeepLink(url: url) else { @@ -1191,6 +1218,18 @@ public final class DashboardModel { githubOAuthDeepLinkContext = nil } + private func isHackAuthCompletionDeepLink(url: URL) -> Bool { + guard url.scheme?.lowercased() == "hack" else { + return false + } + + let host = url.host?.lowercased() ?? "" + let path = normalizedPath(url.path) + return (host == "auth" && path == "/complete") + || (host == "complete" && path == "/") + || (host == "complete" && path.isEmpty) + } + private func parseGitHubOAuthDeepLink(url: URL) -> GitHubOAuthDeepLinkContext? { guard url.scheme?.lowercased() == "hack" else { return nil diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 4254adde9..aafb109c8 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -101,6 +101,8 @@ public actor HackCLIClient { let authenticated: Bool? let brokerBaseUrl: String? let authorizeUrl: String? + let error: String? + let nextStep: String? } private struct HackAuthLogoutEnvelope: Decodable { @@ -894,10 +896,16 @@ public actor HackCLIClient { } public func loginHackAccount() async throws { - let result = try await run(["auth", "login", "--json"], allowNonZeroExit: true) + let result = try await run( + ["auth", "login", "--json", "--redirect", "hack://auth/complete"], + allowNonZeroExit: true + ) let payload = try decodeJsonOrThrow(HackAuthLoginEnvelope.self, result: result) guard payload.ok else { - throw HackCLIError.network("Hack auth login did not complete.") + let message = normalized(payload.error) + ?? normalized(payload.nextStep) + ?? "Hack auth login did not complete." + throw HackCLIError.network(message) } } diff --git a/apps/macos/README.md b/apps/macos/README.md index 7a6d3e834..3c8892456 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -17,6 +17,7 @@ Native macOS app for managing local hack projects and daemon status. - GitHub account connect now runs browser OAuth through the auth-broker endpoint surface and imports tokens into local profile keychain storage. - GitHub connect now supports authorize+install flow hints from the broker and can keep polling in "install required" state until a GitHub App installation is selected. - Browser callback pages can deep-link back to the app (`hack://auth/github/callback?...`) to immediately refocus and finalize active connect flows. +- Hack account browser sign-in can deep-link back to the app (`hack://auth/complete`) to refocus the account/settings UI after Better Auth finishes in the browser. - Cloud OAuth remains separate from local gateway/daemon bearer-token auth (transport auth boundary). - Node topology pane includes a controller-host mode toggle, interactive network graph canvas (node selection, typed edge labels, draggable node layout persisted per controller profile, auto-tidy reflow, expandable full map), primary/default node view, authorized node management, connectivity probes, and pairing request inbox approval actions. - Tailscale settings now include a dedicated bootstrap auth-key panel; saving there writes the shared key used by private Railway bootstrap (`controlPlane.extensions["dance.hack.tailscale"].config.authKey`, mirrored to Railway compatibility key) with optional `HACK_TAILSCALE_AUTH_KEY` fallback. @@ -71,7 +72,7 @@ For GitHub browser OAuth, the app defaults to: Set `HACK_AUTH_BROKER_URL` to override and pin a custom broker endpoint. The app registers URL scheme `hack` and handles `hack://auth/github/callback` -deep links for GitHub OAuth return-to-app UX. +and `hack://auth/complete` deep links for browser return-to-app UX. ## Data source diff --git a/services/auth-broker/src/modules/better-auth/shell-plugin.ts b/services/auth-broker/src/modules/better-auth/shell-plugin.ts index 656e8f6e0..4d77771e0 100644 --- a/services/auth-broker/src/modules/better-auth/shell-plugin.ts +++ b/services/auth-broker/src/modules/better-auth/shell-plugin.ts @@ -404,17 +404,26 @@ export function createBetterAuthShellPlugin({ }), }), script: resolvedSession.session - ? renderProviderActionScript({ - callbackUrl: buildAccountPageUrl({ - publicBaseUrl: config.publicBaseUrl, - flowId: query.flowId, - deviceCode: query.deviceCode, - returnUrl, + ? [ + renderProviderActionScript({ + callbackUrl: buildAccountPageUrl({ + publicBaseUrl: config.publicBaseUrl, + flowId: query.flowId, + deviceCode: query.deviceCode, + returnUrl, + }), + autoProviderId: null, + mode: "link", }), - autoProviderId: null, - mode: "link", - }) - : undefined, + renderLifecycleAutoReturnScript({ + lifecycle, + }), + ] + .filter(Boolean) + .join("\n") + : renderLifecycleAutoReturnScript({ + lifecycle, + }) || undefined, }); }, { @@ -733,6 +742,31 @@ function renderCompletionBody(input: { )}">Open app.`; } +function renderLifecycleAutoReturnScript(input: { + readonly lifecycle: SessionFlowLifecycle; +}): string { + if ( + (input.lifecycle.state !== "ready" && + input.lifecycle.state !== "claimed") || + !shouldAutoReturnToDesktop({ + returnUrl: input.lifecycle.returnUrl, + }) + ) { + return ""; + } + + return ` +window.requestAnimationFrame(() => { + if (authStatus) { + authStatus.textContent = "Returning to Hack…"; + } + window.setTimeout(() => { + window.location.assign(${JSON.stringify(input.lifecycle.returnUrl)}); + }, 180); +}); +`; +} + function renderProviderActionGrid(input: { readonly providers: readonly BetterAuthSocialProvider[]; readonly callbackUrl: string; @@ -1119,6 +1153,20 @@ function normalizeSafeReturnUrl(input: { } } +function shouldAutoReturnToDesktop(input: { + readonly returnUrl: string | null; +}): boolean { + if (!input.returnUrl) { + return false; + } + try { + const candidate = new URL(input.returnUrl); + return candidate.protocol === "hack:"; + } catch { + return false; + } +} + function resolvePageBaseUrl(publicBaseUrl: string): string { return publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`; } diff --git a/services/auth-broker/tests/session-auth.test.ts b/services/auth-broker/tests/session-auth.test.ts index 19bd57522..35f5f38d9 100644 --- a/services/auth-broker/tests/session-auth.test.ts +++ b/services/auth-broker/tests/session-auth.test.ts @@ -240,4 +240,59 @@ describe("broker Hack session auth flow", () => { expect(mePayload.session?.teamId).toBe("team-123"); }); }); + + test("auth account page auto-returns to the desktop app when a hack redirect is supplied", async () => { + await withManagementTokenSecret("session-auth-return-secret", async () => { + const session = { + user: { + id: "user-123", + email: "hack@example.com", + emailVerified: true, + name: "Hack User", + }, + session: { + id: "sess-123", + userId: "user-123", + token: "session-token", + activeOrganizationId: "org-123", + activeTeamId: "team-123", + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + } as unknown as BetterAuthSession; + + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(session), + }); + + const startResponse = await app.handle( + new Request( + "http://localhost/v1/auth/session/start?redirect=hack://auth/complete" + ) + ); + const startPayload = ( + (await startResponse.json()) as SessionStartFlowResponse + ).flow; + const completeResponse = await app.handle( + new Request(startPayload.authorizeUrl) + ); + const accountLocation = completeResponse.headers.get("location"); + expect(accountLocation).toContain( + "redirect=hack%3A%2F%2Fauth%2Fcomplete" + ); + if (!accountLocation) { + return; + } + + const accountResponse = await app.handle(new Request(accountLocation)); + const html = await accountResponse.text(); + expect(html).toContain("Open app"); + expect(html).toContain("hack://auth/complete"); + expect(html).toContain("Returning to Hack"); + expect(html).toContain("window.setTimeout"); + }); + }); }); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 845c4b525..243db4f65 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -30,11 +30,19 @@ const optBrokerUrl = defineOption({ description: "Override the Hack auth broker base URL", } as const); +const optRedirect = defineOption({ + name: "redirect", + type: "string", + long: "--redirect", + valueHint: "", + description: "Return to this URL after browser sign-in finishes", +} as const); + const loginSpec = defineCommand({ name: "login", summary: "Open a browser and sign in to Hack auth", group: "Global", - options: [optJson, optNoOpen, optBrokerUrl] as const, + options: [optJson, optNoOpen, optBrokerUrl, optRedirect] as const, positionals: [] as const, subcommands: [] as const, } as const); @@ -94,6 +102,7 @@ async function handleAuthLogin({ }); const start = await startHackAuthSessionFlow({ baseUrl: brokerBaseUrl, + redirectUrl: args.options.redirect, }); if (!start.ok) { return writeFailure({ diff --git a/src/lib/auth-session.ts b/src/lib/auth-session.ts index 8390672f9..9517c0595 100644 --- a/src/lib/auth-session.ts +++ b/src/lib/auth-session.ts @@ -150,13 +150,18 @@ export async function deleteHackAuthSession(input?: { */ export async function startHackAuthSessionFlow(input?: { readonly baseUrl?: string; + readonly redirectUrl?: string; readonly fetchImpl?: FetchLike; }): Promise> { const baseUrl = resolveHackAuthBrokerBaseUrl({ override: input?.baseUrl, }); + const startUrl = new URL("/v1/auth/session/start", `${baseUrl}/`); + if (input?.redirectUrl?.trim()) { + startUrl.searchParams.set("redirect", input.redirectUrl.trim()); + } const response = await requestBrokerJson({ - url: `${baseUrl}/v1/auth/session/start`, + url: startUrl.toString(), routePath: "/v1/auth/session/start", fetchImpl: input?.fetchImpl, }); diff --git a/tests/auth-command.test.ts b/tests/auth-command.test.ts index a00d12ff6..fb116e028 100644 --- a/tests/auth-command.test.ts +++ b/tests/auth-command.test.ts @@ -145,7 +145,7 @@ test("auth login opens browser, claims token, and stores the auth session", asyn "https://auth.hack.broker/auth?flowId=flow-123&deviceCode=device-123", deviceCode: "device-123", pollUrl: "https://auth.hack.broker/v1/auth/session/flows/flow-123", - expiresAt: "2026-03-06T18:00:00.000Z", + expiresAt: "2099-03-06T18:00:00.000Z", socialProviders: [{ id: "github", label: "GitHub" }], }, }); @@ -159,7 +159,7 @@ test("auth login opens browser, claims token, and stores the auth session", asyn status: { status: "claimed", managementToken: "hack-session-token", - managementTokenExpiresAt: "2026-03-06T19:00:00.000Z", + managementTokenExpiresAt: "2099-03-06T19:00:00.000Z", }, }); } @@ -209,7 +209,7 @@ test("auth login opens browser, claims token, and stores the auth session", asyn const stored = await readStoredTokenEnvelope(); expect(stored?.token).toBe("hack-session-token"); - expect(stored?.expiresAt).toBe("2026-03-06T19:00:00.000Z"); + expect(stored?.expiresAt).toBe("2099-03-06T19:00:00.000Z"); const payload = JSON.parse(result.stdout) as { readonly ok: boolean; @@ -239,6 +239,68 @@ test("auth login opens browser, claims token, and stores the auth session", asyn expect(payload.activeTeam?.slug).toBe("cli"); }); +test("auth login forwards a desktop return URL to the broker session start route", async () => { + fetchImpl = async (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if ( + url === + "https://auth.hack.broker/v1/auth/session/start?redirect=hack%3A%2F%2Fauth%2Fcomplete" + ) { + return Response.json({ + ok: true, + flow: { + flowId: "flow-redirect", + authorizeUrl: + "https://auth.hack.broker/auth?flowId=flow-redirect&deviceCode=device-redirect&redirect=hack%3A%2F%2Fauth%2Fcomplete", + deviceCode: "device-redirect", + pollUrl: + "https://auth.hack.broker/v1/auth/session/flows/flow-redirect", + expiresAt: "2099-03-06T18:00:00.000Z", + socialProviders: [{ id: "github", label: "GitHub" }], + }, + }); + } + if ( + url === + "https://auth.hack.broker/v1/auth/session/flows/flow-redirect?claim=1&deviceCode=device-redirect" + ) { + return Response.json({ + ok: true, + status: { + status: "claimed", + managementToken: "hack-session-token", + }, + }); + } + if (url === "https://auth.hack.broker/v1/auth/me") { + return Response.json({ + ok: true, + authenticated: true, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }; + + const result = await runCliWithCapturedIo({ + argv: ["auth", "login", "--json", "--redirect", "hack://auth/complete"], + }); + + expect(result.exitCode).toBe(0); + expect(fetchCalls.map((call) => call.url)).toEqual([ + "https://auth.hack.broker/v1/auth/session/start?redirect=hack%3A%2F%2Fauth%2Fcomplete", + "https://auth.hack.broker/v1/auth/session/flows/flow-redirect?claim=1&deviceCode=device-redirect", + "https://auth.hack.broker/v1/auth/me", + ]); + expect(openUrlCalls).toEqual([ + "https://auth.hack.broker/auth?flowId=flow-redirect&deviceCode=device-redirect&redirect=hack%3A%2F%2Fauth%2Fcomplete", + ]); +}); + test("auth whoami uses stored token against broker /v1/auth/me", async () => { await writeStoredTokenEnvelope({ token: "hack-session-token" }); From 2a989403643a8496712168b9d803f8c65255459e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 14:22:03 -0500 Subject: [PATCH 18/54] Fix Hack auth login flow and polish auth shell --- services/auth-broker/.env.example | 13 +- services/auth-broker/README.md | 52 +- services/auth-broker/src/app.ts | 3 +- services/auth-broker/src/better-auth.ts | 366 +++++++-- services/auth-broker/src/db.ts | 4 +- services/auth-broker/src/db/schema.ts | 237 +++++- .../src/modules/better-auth/plugin.ts | 17 +- .../src/modules/better-auth/session.ts | 6 +- .../src/modules/better-auth/shell-plugin.ts | 739 ++++++++++++++---- .../tests/better-auth-schema.test.ts | 21 + services/auth-broker/tests/index.test.ts | 7 +- .../auth-broker/tests/session-auth.test.ts | 4 +- 12 files changed, 1208 insertions(+), 261 deletions(-) create mode 100644 services/auth-broker/tests/better-auth-schema.test.ts diff --git a/services/auth-broker/.env.example b/services/auth-broker/.env.example index d61146ca6..9122b4b21 100644 --- a/services/auth-broker/.env.example +++ b/services/auth-broker/.env.example @@ -1,7 +1,5 @@ -# GitHub OAuth app credentials -# Used for: -# - GitHub provider OAuth routes (`/v1/auth/github/*`, `/gh/*`) -# - GitHub as a Better Auth social login method in `/auth` +# GitHub OAuth app credentials for the provider integration flow. +# Better Auth login can reuse these, but dedicated BETTER_AUTH_GITHUB_* creds are preferred. GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= # Optional OAuth scope hint; default is read:user,user:email,read:org. @@ -47,7 +45,12 @@ BETTER_AUTH_URL=https://auth.hack.broker # Comma-separated first-party origins allowed to call Better Auth. BETTER_AUTH_TRUSTED_ORIGINS=http://127.0.0.1:8080 # Optional Better Auth social login providers shown in `/auth`. -# GitHub uses the GitHub OAuth app credentials above. +# These are preferred over the generic provider OAuth creds when set. +BETTER_AUTH_GITHUB_CLIENT_ID= +BETTER_AUTH_GITHUB_CLIENT_SECRET= +BETTER_AUTH_GOOGLE_CLIENT_ID= +BETTER_AUTH_GOOGLE_CLIENT_SECRET= +# Compatibility fallbacks for Better Auth social login when dedicated creds are not set. GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= # Optional callback-side user bootstrap helpers. These do not relax verified-email diff --git a/services/auth-broker/README.md b/services/auth-broker/README.md index ef0647f0d..a0d6bdce9 100644 --- a/services/auth-broker/README.md +++ b/services/auth-broker/README.md @@ -189,20 +189,24 @@ The auth shell is provider-driven and env-driven. ### GitHub -`GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` are currently dual-use: +`BETTER_AUTH_GITHUB_CLIENT_ID` and `BETTER_AUTH_GITHUB_CLIENT_SECRET` are the +preferred creds for Hack sign-in with GitHub. -- they enable GitHub OAuth provider routes for integration flows -- they also enable GitHub as a Hack login method in `/auth` +If those are not set, the broker falls back to `GITHUB_CLIENT_ID` and +`GITHUB_CLIENT_SECRET`. -Because of that, product surfaces must still distinguish: +Product surfaces must still distinguish: - `Sign in to Hack with GitHub` - `Connect GitHub integration` ### Google -`GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are now relevant because they -enable Google as a Hack login method in the Better Auth shell. +`BETTER_AUTH_GOOGLE_CLIENT_ID` and `BETTER_AUTH_GOOGLE_CLIENT_SECRET` are the +preferred creds for Google sign-in in the Better Auth shell. + +If those are not set, the broker falls back to `GOOGLE_CLIENT_ID` and +`GOOGLE_CLIENT_SECRET`. Google is currently: @@ -247,8 +251,8 @@ Hack account ownership, not on provider identity. ## Environment -1. `GITHUB_CLIENT_ID` (required; enables GitHub provider OAuth and GitHub social sign-in) -2. `GITHUB_CLIENT_SECRET` (required; enables GitHub provider OAuth and GitHub social sign-in) +1. `GITHUB_CLIENT_ID` (required for GitHub provider OAuth routes) +2. `GITHUB_CLIENT_SECRET` (required for GitHub provider OAuth routes) 3. `GITHUB_SCOPES` (optional; default: `read:user,user:email,read:org`) 4. `GITHUB_APP_ID` (optional; returned in flow metadata for app-mode binding) 5. `GITHUB_APP_SLUG` (optional; used to build install URL) @@ -264,20 +268,24 @@ Hack account ownership, not on provider identity. 15. `BETTER_AUTH_SECRET` (required to enable Better Auth runtime) 16. `BETTER_AUTH_URL` (optional base URL override) 17. `BETTER_AUTH_TRUSTED_ORIGINS` (optional comma-separated origins) -18. `GOOGLE_CLIENT_ID` (optional; enables Google social sign-in in `/auth`) -19. `GOOGLE_CLIENT_SECRET` (optional; enables Google social sign-in in `/auth`) -20. `BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from GitHub email if no match exists) -21. `BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from Linear email if no match exists) -22. `HACK_LINEAR_CLIENT_ID` (recommended Linear OAuth client id) -23. `HACK_LINEAR_SECRET` (optional Linear OAuth client secret; PKCE can run without it) -24. `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional app token for agent/system automations) -25. `HACK_LINEAR_WEBHOOK_SECRET` (recommended Linear webhook signing secret) -26. `HACK_LINEAR_SCOPES` (optional; default: `read,write,app:mentionable,app:assignable`) -27. `HACK_LINEAR_OAUTH_ACTOR` (optional; default: `app` for Linear agent/app installs) -28. `HACK_LINEAR_REDIRECT_URI` (default: `${AUTH_BROKER_PUBLIC_BASE_URL}/linear/callback`) -29. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) -30. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) -31. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) +18. `BETTER_AUTH_GITHUB_CLIENT_ID` (optional; preferred GitHub social login client id for `/auth`) +19. `BETTER_AUTH_GITHUB_CLIENT_SECRET` (optional; preferred GitHub social login client secret for `/auth`) +20. `BETTER_AUTH_GOOGLE_CLIENT_ID` (optional; preferred Google social login client id for `/auth`) +21. `BETTER_AUTH_GOOGLE_CLIENT_SECRET` (optional; preferred Google social login client secret for `/auth`) +22. `GOOGLE_CLIENT_ID` (optional fallback; enables Google social sign-in in `/auth` when dedicated Better Auth creds are not set) +23. `GOOGLE_CLIENT_SECRET` (optional fallback; enables Google social sign-in in `/auth` when dedicated Better Auth creds are not set) +24. `BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from GitHub email if no match exists) +25. `BETTER_AUTH_LINEAR_AUTO_PROVISION_USERS` (optional boolean; when true, callback can create a Better Auth user from Linear email if no match exists) +26. `HACK_LINEAR_CLIENT_ID` (recommended Linear OAuth client id) +27. `HACK_LINEAR_SECRET` (optional Linear OAuth client secret; PKCE can run without it) +28. `HACK_LINEAR_DEVELOPER_APP_TOKEN` (optional app token for agent/system automations) +29. `HACK_LINEAR_WEBHOOK_SECRET` (recommended Linear webhook signing secret) +30. `HACK_LINEAR_SCOPES` (optional; default: `read,write,app:mentionable,app:assignable`) +31. `HACK_LINEAR_OAUTH_ACTOR` (optional; default: `app` for Linear agent/app installs) +32. `HACK_LINEAR_REDIRECT_URI` (default: `${AUTH_BROKER_PUBLIC_BASE_URL}/linear/callback`) +33. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) +34. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) +35. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) 32. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) 33. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) diff --git a/services/auth-broker/src/app.ts b/services/auth-broker/src/app.ts index b2292d049..e729072f2 100644 --- a/services/auth-broker/src/app.ts +++ b/services/auth-broker/src/app.ts @@ -146,7 +146,8 @@ export function createAuthBrokerApp({ betterAuthRuntime, }) ) - .onStart(() => { + .onStart(async () => { + await betterAuthRuntime.ready; const intervalMs = Math.max(config.flowSweepIntervalMs, 5000); flowSweepTimer = setInterval(() => { flowStore.pruneExpired(); diff --git a/services/auth-broker/src/better-auth.ts b/services/auth-broker/src/better-auth.ts index 0449e382a..90f5c9b86 100644 --- a/services/auth-broker/src/better-auth.ts +++ b/services/auth-broker/src/better-auth.ts @@ -1,12 +1,14 @@ -import { neon } from "@neondatabase/serverless"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { organization } from "better-auth/plugins"; -import { drizzle } from "drizzle-orm/neon-http"; +import { organization as organizationPlugin } from "better-auth/plugins"; +import { sql } from "drizzle-orm"; import { z } from "zod"; +import { authBrokerSchema } from "./db/schema.ts"; +import { createDbClient } from "./db.ts"; type BetterAuthInstance = ReturnType; -type AuthBrokerDbClient = ReturnType; +type AuthBrokerDbClient = ReturnType; +type BetterAuthEnv = z.infer; export type BetterAuthSocialProvider = { readonly id: string; @@ -24,6 +26,7 @@ type BetterAuthRuntime = { readonly reason?: string; readonly auth?: BetterAuthInstance; readonly db?: AuthBrokerDbClient; + readonly ready?: Promise; readonly socialProviders?: readonly BetterAuthSocialProvider[]; readonly accountLinkingPolicy?: BetterAuthAccountLinkingPolicy; }; @@ -33,8 +36,12 @@ const betterAuthEnvSchema = z.object({ BETTER_AUTH_SECRET: z.string().min(1).optional(), BETTER_AUTH_URL: z.string().url().optional(), BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(), + BETTER_AUTH_GITHUB_CLIENT_ID: z.string().min(1).optional(), + BETTER_AUTH_GITHUB_CLIENT_SECRET: z.string().min(1).optional(), GITHUB_CLIENT_ID: z.string().min(1).optional(), GITHUB_CLIENT_SECRET: z.string().min(1).optional(), + BETTER_AUTH_GOOGLE_CLIENT_ID: z.string().min(1).optional(), + BETTER_AUTH_GOOGLE_CLIENT_SECRET: z.string().min(1).optional(), GOOGLE_CLIENT_ID: z.string().min(1).optional(), GOOGLE_CLIENT_SECRET: z.string().min(1).optional(), }); @@ -46,54 +53,31 @@ const betterAuthEnvSchema = z.object({ * broker routes can still start in local/dev modes. */ export function createBetterAuthRuntimeFromEnv(): BetterAuthRuntime { - const env = betterAuthEnvSchema.parse({ - DATABASE_URL: process.env.DATABASE_URL, - BETTER_AUTH_SECRET: - process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET, - BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, - BETTER_AUTH_TRUSTED_ORIGINS: process.env.BETTER_AUTH_TRUSTED_ORIGINS, - GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, - GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, - }); - - if (!env.DATABASE_URL) { + const env = readBetterAuthEnv(); + const requiredConfig = resolveBetterAuthRequiredConfig(env); + if (!requiredConfig.ok) { return { enabled: false, - reason: "DATABASE_URL is not configured.", - }; - } - if (!env.BETTER_AUTH_SECRET) { - return { - enabled: false, - reason: "BETTER_AUTH_SECRET is not configured.", + reason: requiredConfig.reason, }; } const trustedOrigins = parseCsv(env.BETTER_AUTH_TRUSTED_ORIGINS); - const githubEnabled = Boolean( - env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET - ); - const googleEnabled = Boolean( - env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET - ); - const socialProviders = [ - ...(githubEnabled ? [{ id: "github", label: "GitHub" }] : []), - ...(googleEnabled ? [{ id: "google", label: "Google" }] : []), - ] as const satisfies readonly BetterAuthSocialProvider[]; + const socialProviderConfig = resolveBetterAuthSocialProviderConfig(env); const accountLinkingPolicy = { requireVerifiedEmail: true, allowDifferentEmails: false, trustedProviders: [], } as const satisfies BetterAuthAccountLinkingPolicy; - const db = createAuthBrokerDbClient({ databaseUrl: env.DATABASE_URL }); + const db = createDbClient({ databaseUrl: requiredConfig.databaseUrl }); + const ready = ensureBetterAuthTables({ db }); const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", + schema: authBrokerSchema, }), - secret: env.BETTER_AUTH_SECRET, + secret: requiredConfig.authSecret, ...(env.BETTER_AUTH_URL ? { baseURL: env.BETTER_AUTH_URL } : {}), ...(trustedOrigins.length > 0 ? { trustedOrigins } : {}), emailAndPassword: { @@ -106,30 +90,13 @@ export function createBetterAuthRuntimeFromEnv(): BetterAuthRuntime { trustedProviders: [...accountLinkingPolicy.trustedProviders], }, }, - ...(socialProviders.length > 0 + ...(socialProviderConfig.providerOptions ? { - socialProviders: { - ...(githubEnabled - ? { - github: { - clientId: env.GITHUB_CLIENT_ID ?? "", - clientSecret: env.GITHUB_CLIENT_SECRET ?? "", - }, - } - : {}), - ...(googleEnabled - ? { - google: { - clientId: env.GOOGLE_CLIENT_ID ?? "", - clientSecret: env.GOOGLE_CLIENT_SECRET ?? "", - }, - } - : {}), - }, + socialProviders: socialProviderConfig.providerOptions, } : {}), plugins: [ - organization({ + organizationPlugin({ membershipLimit: 500, teams: { enabled: true, @@ -147,13 +114,120 @@ export function createBetterAuthRuntimeFromEnv(): BetterAuthRuntime { enabled: true, auth, db, - socialProviders, + ready, + socialProviders: socialProviderConfig.socialProviders, accountLinkingPolicy, }; } export type { BetterAuthRuntime }; +export async function ensureBetterAuthRuntimeReady( + runtime: BetterAuthRuntime +): Promise { + await runtime.ready; +} + +function readBetterAuthEnv(): BetterAuthEnv { + return betterAuthEnvSchema.parse({ + DATABASE_URL: process.env.DATABASE_URL, + BETTER_AUTH_SECRET: + process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET, + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, + BETTER_AUTH_TRUSTED_ORIGINS: process.env.BETTER_AUTH_TRUSTED_ORIGINS, + BETTER_AUTH_GITHUB_CLIENT_ID: process.env.BETTER_AUTH_GITHUB_CLIENT_ID, + BETTER_AUTH_GITHUB_CLIENT_SECRET: + process.env.BETTER_AUTH_GITHUB_CLIENT_SECRET, + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, + BETTER_AUTH_GOOGLE_CLIENT_ID: process.env.BETTER_AUTH_GOOGLE_CLIENT_ID, + BETTER_AUTH_GOOGLE_CLIENT_SECRET: + process.env.BETTER_AUTH_GOOGLE_CLIENT_SECRET, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + }); +} + +function resolveBetterAuthRequiredConfig(env: BetterAuthEnv): + | { + readonly ok: true; + readonly databaseUrl: string; + readonly authSecret: string; + } + | { + readonly ok: false; + readonly reason: string; + } { + if (!env.DATABASE_URL) { + return { + ok: false, + reason: "DATABASE_URL is not configured.", + }; + } + if (!env.BETTER_AUTH_SECRET) { + return { + ok: false, + reason: "BETTER_AUTH_SECRET is not configured.", + }; + } + return { + ok: true, + databaseUrl: env.DATABASE_URL, + authSecret: env.BETTER_AUTH_SECRET, + }; +} + +function resolveBetterAuthSocialProviderConfig(env: BetterAuthEnv): { + readonly socialProviders: readonly BetterAuthSocialProvider[]; + readonly providerOptions: { + readonly github?: { + readonly clientId: string; + readonly clientSecret: string; + }; + readonly google?: { + readonly clientId: string; + readonly clientSecret: string; + }; + } | null; +} { + const resolvedGitHubClientId = + env.BETTER_AUTH_GITHUB_CLIENT_ID ?? env.GITHUB_CLIENT_ID; + const resolvedGitHubClientSecret = + env.BETTER_AUTH_GITHUB_CLIENT_SECRET ?? env.GITHUB_CLIENT_SECRET; + const resolvedGoogleClientId = + env.BETTER_AUTH_GOOGLE_CLIENT_ID ?? env.GOOGLE_CLIENT_ID; + const resolvedGoogleClientSecret = + env.BETTER_AUTH_GOOGLE_CLIENT_SECRET ?? env.GOOGLE_CLIENT_SECRET; + const providerOptions = { + ...(resolvedGitHubClientId && resolvedGitHubClientSecret + ? { + github: { + clientId: resolvedGitHubClientId, + clientSecret: resolvedGitHubClientSecret, + }, + } + : {}), + ...(resolvedGoogleClientId && resolvedGoogleClientSecret + ? { + google: { + clientId: resolvedGoogleClientId, + clientSecret: resolvedGoogleClientSecret, + }, + } + : {}), + }; + const socialProviders = [ + ...(providerOptions.github ? [{ id: "github", label: "GitHub" }] : []), + ...(providerOptions.google ? [{ id: "google", label: "Google" }] : []), + ] as const satisfies readonly BetterAuthSocialProvider[]; + + return { + socialProviders, + providerOptions: + Object.keys(providerOptions).length > 0 ? providerOptions : null, + }; +} + function parseCsv(value: string | undefined): string[] { if (!value) { return []; @@ -164,11 +238,175 @@ function parseCsv(value: string | undefined): string[] { .filter((item) => item.length > 0); } -/** - * Create a local Drizzle client for auth-broker without relying on workspace-only packages. - * - * This keeps Railway path-based deploys self-contained under `services/auth-broker`. - */ -function createAuthBrokerDbClient(input: { readonly databaseUrl: string }) { - return drizzle(neon(input.databaseUrl)); +async function ensureBetterAuthTables(input: { + readonly db: AuthBrokerDbClient; +}): Promise { + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "user" ( + "id" text PRIMARY KEY, + "name" text NOT NULL, + "email" text NOT NULL UNIQUE, + "email_verified" boolean NOT NULL DEFAULT false, + "image" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() + ) + `); + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "organization" ( + "id" text PRIMARY KEY, + "name" text NOT NULL, + "slug" text NOT NULL UNIQUE, + "logo" text, + "metadata" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz DEFAULT now() + ) + `); + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "team" ( + "id" text PRIMARY KEY, + "name" text NOT NULL, + "organization_id" text NOT NULL REFERENCES "organization"("id"), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz DEFAULT now() + ) + `); + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "session" ( + "id" text PRIMARY KEY, + "expires_at" timestamptz NOT NULL, + "token" text NOT NULL UNIQUE, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, + "active_organization_id" text, + "active_team_id" text + ) + `); + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "account" ( + "id" text PRIMARY KEY, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamptz, + "refresh_token_expires_at" timestamptz, + "scope" text, + "password" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() + ) + `); + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "verification" ( + "id" text PRIMARY KEY, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamptz NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() + ) + `); + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "member" ( + "id" text PRIMARY KEY, + "organization_id" text NOT NULL REFERENCES "organization"("id"), + "user_id" text NOT NULL REFERENCES "user"("id"), + "role" text NOT NULL DEFAULT 'member', + "created_at" timestamptz NOT NULL DEFAULT now() + ) + `); + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "invitation" ( + "id" text PRIMARY KEY, + "organization_id" text NOT NULL REFERENCES "organization"("id"), + "email" text NOT NULL, + "role" text, + "team_id" text, + "status" text NOT NULL DEFAULT 'pending', + "expires_at" timestamptz NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "inviter_id" text NOT NULL REFERENCES "user"("id") + ) + `); + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS "team_member" ( + "id" text PRIMARY KEY, + "team_id" text NOT NULL REFERENCES "team"("id"), + "user_id" text NOT NULL REFERENCES "user"("id"), + "created_at" timestamptz DEFAULT now() + ) + `); + + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "session_user_id_idx" ON "session" ("user_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE UNIQUE INDEX IF NOT EXISTS "account_provider_account_idx" ON "account" ("provider_id", "account_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "account_user_id_idx" ON "account" ("user_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "member_organization_id_idx" ON "member" ("organization_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "member_user_id_idx" ON "member" ("user_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE UNIQUE INDEX IF NOT EXISTS "member_organization_user_idx" ON "member" ("organization_id", "user_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "team_organization_id_idx" ON "team" ("organization_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "invitation_organization_id_idx" ON "invitation" ("organization_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "invitation_email_idx" ON "invitation" ("email")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "team_member_team_id_idx" ON "team_member" ("team_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS "team_member_user_id_idx" ON "team_member" ("user_id")' + ) + ); + await input.db.execute( + sql.raw( + 'CREATE UNIQUE INDEX IF NOT EXISTS "team_member_team_user_idx" ON "team_member" ("team_id", "user_id")' + ) + ); } diff --git a/services/auth-broker/src/db.ts b/services/auth-broker/src/db.ts index aaaff8a27..dbb7138aa 100644 --- a/services/auth-broker/src/db.ts +++ b/services/auth-broker/src/db.ts @@ -1,8 +1,8 @@ import { neon } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-http"; -import * as schema from "./db/schema.ts"; +import { authBrokerSchema } from "./db/schema.ts"; export function createDbClient(input: { readonly databaseUrl: string }) { - return drizzle(neon(input.databaseUrl), { schema }); + return drizzle(neon(input.databaseUrl), { schema: authBrokerSchema }); } diff --git a/services/auth-broker/src/db/schema.ts b/services/auth-broker/src/db/schema.ts index 4cc67746b..654699982 100644 --- a/services/auth-broker/src/db/schema.ts +++ b/services/auth-broker/src/db/schema.ts @@ -1,4 +1,220 @@ -import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { + boolean, + index, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").notNull().default(false), + image: text("image"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + activeOrganizationId: text("active_organization_id"), + activeTeamId: text("active_team_id"), + }, + (table) => ({ + userIdIndex: index("session_user_id_idx").on(table.userId), + }) +); + +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at", { + withTimezone: true, + }), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { + withTimezone: true, + }), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + userIdIndex: index("account_user_id_idx").on(table.userId), + providerAccountUnique: uniqueIndex("account_provider_account_idx").on( + table.providerId, + table.accountId + ), + }) +); + +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + identifierIndex: index("verification_identifier_idx").on(table.identifier), + }) +); + +export const organization = pgTable( + "organization", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + metadata: text("metadata"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + slugIndex: uniqueIndex("organization_slug_idx").on(table.slug), + }) +); + +export const member = pgTable( + "member", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id), + userId: text("user_id") + .notNull() + .references(() => user.id), + role: text("role").notNull().default("member"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + organizationIdIndex: index("member_organization_id_idx").on( + table.organizationId + ), + userIdIndex: index("member_user_id_idx").on(table.userId), + uniqueMembership: uniqueIndex("member_organization_user_idx").on( + table.organizationId, + table.userId + ), + }) +); + +export const invitation = pgTable( + "invitation", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id), + email: text("email").notNull(), + role: text("role"), + teamId: text("team_id"), + status: text("status").notNull().default("pending"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + inviterId: text("inviter_id") + .notNull() + .references(() => user.id), + }, + (table) => ({ + organizationIdIndex: index("invitation_organization_id_idx").on( + table.organizationId + ), + emailIndex: index("invitation_email_idx").on(table.email), + }) +); + +export const team = pgTable( + "team", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + organizationIdIndex: index("team_organization_id_idx").on( + table.organizationId + ), + }) +); + +export const teamMember = pgTable( + "team_member", + { + id: text("id").primaryKey(), + teamId: text("team_id") + .notNull() + .references(() => team.id), + userId: text("user_id") + .notNull() + .references(() => user.id), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + teamIdIndex: index("team_member_team_id_idx").on(table.teamId), + userIdIndex: index("team_member_user_id_idx").on(table.userId), + uniqueMembership: uniqueIndex("team_member_team_user_idx").on( + table.teamId, + table.userId + ), + }) +); export const linearConnections = pgTable("linear_connections", { id: uuid("id").defaultRandom().primaryKey(), @@ -69,3 +285,22 @@ export const linearSyncSubscriptions = pgTable("linear_sync_subscriptions", { .notNull() .defaultNow(), }); + +export const betterAuthSchema = { + user, + session, + account, + verification, + organization, + member, + invitation, + team, + teamMember, +} as const; + +export const authBrokerSchema = { + ...betterAuthSchema, + linearConnections, + linearWebhookEvents, + linearSyncSubscriptions, +} as const; diff --git a/services/auth-broker/src/modules/better-auth/plugin.ts b/services/auth-broker/src/modules/better-auth/plugin.ts index 1c3ecadd7..80e2cdb7a 100644 --- a/services/auth-broker/src/modules/better-auth/plugin.ts +++ b/services/auth-broker/src/modules/better-auth/plugin.ts @@ -1,6 +1,9 @@ import { Elysia } from "elysia"; -import type { BetterAuthRuntime } from "../../better-auth.ts"; +import { + type BetterAuthRuntime, + ensureBetterAuthRuntimeReady, +} from "../../better-auth.ts"; type CreateBetterAuthPluginOptions = { readonly runtime: BetterAuthRuntime; @@ -27,7 +30,7 @@ export function createBetterAuthPlugin({ }: CreateBetterAuthPluginOptions) { return new Elysia({ name: "hack-auth-broker.better-auth", - }).all("/api/auth/*", ({ request, set }) => { + }).all("/api/auth/*", async ({ request, set }) => { if (!(runtime.enabled && runtime.auth)) { set.status = 503; return { @@ -42,6 +45,16 @@ export function createBetterAuthPlugin({ error: `Unsupported method: ${request.method}`, } as const; } + try { + await ensureBetterAuthRuntimeReady(runtime); + } catch (error) { + set.status = 503; + return { + ok: false, + error: "better_auth_storage_unavailable", + message: error instanceof Error ? error.message : String(error), + } as const; + } return runtime.auth.handler(request); }); } diff --git a/services/auth-broker/src/modules/better-auth/session.ts b/services/auth-broker/src/modules/better-auth/session.ts index 133d455db..5a68cd9ee 100644 --- a/services/auth-broker/src/modules/better-auth/session.ts +++ b/services/auth-broker/src/modules/better-auth/session.ts @@ -1,4 +1,7 @@ -import type { BetterAuthRuntime } from "../../better-auth.ts"; +import { + type BetterAuthRuntime, + ensureBetterAuthRuntimeReady, +} from "../../better-auth.ts"; import { verifyBrokerManagementToken } from "./management-token.ts"; const AUTHORIZATION_BEARER_PATTERN = /^Bearer\s+(.+)$/i; @@ -52,6 +55,7 @@ export async function resolveBetterAuthSession(input: { session: null, }; } + await ensureBetterAuthRuntimeReady(input.runtime); const session = await input.runtime.auth.api.getSession({ headers: input.request.headers, }); diff --git a/services/auth-broker/src/modules/better-auth/shell-plugin.ts b/services/auth-broker/src/modules/better-auth/shell-plugin.ts index 4d77771e0..86e8a7dde 100644 --- a/services/auth-broker/src/modules/better-auth/shell-plugin.ts +++ b/services/auth-broker/src/modules/better-auth/shell-plugin.ts @@ -7,6 +7,7 @@ import type { BetterAuthRuntime, BetterAuthSocialProvider, } from "../../better-auth.ts"; +import { ensureBetterAuthRuntimeReady } from "../../better-auth.ts"; import type { BrokerConfig } from "../../config.ts"; import { type FlowStore, hashDeviceCode } from "../../flow-store.ts"; import { issueBrokerManagementToken } from "./management-token.ts"; @@ -220,6 +221,16 @@ export function createBetterAuthShellPlugin({ error: runtime.reason ?? "Better Auth is not configured.", } as const; } + try { + await ensureBetterAuthRuntimeReady(runtime); + } catch (error) { + set.status = 503; + return { + ok: false, + error: "better_auth_storage_unavailable", + message: error instanceof Error ? error.message : String(error), + } as const; + } const rawSession = await runtime.auth.api.getSession({ headers: request.headers, }); @@ -272,6 +283,8 @@ export function createBetterAuthShellPlugin({ if (!(runtime.enabled && runtime.auth)) { return renderHtmlPage({ title: "Hack auth unavailable", + heading: "Hack auth unavailable", + subtitle: "Shared Hack sign-in is not available right now.", body: renderStateCard({ eyebrow: "Auth unavailable", title: "Hack auth is not configured", @@ -282,6 +295,23 @@ export function createBetterAuthShellPlugin({ }), }); } + try { + await ensureBetterAuthRuntimeReady(runtime); + } catch (error) { + return renderHtmlPage({ + title: "Hack auth unavailable", + heading: "Hack auth unavailable", + subtitle: "Shared Hack sign-in is not available right now.", + body: renderLifecycleMessage({ + eyebrow: "Unavailable", + title: "Auth storage is unavailable", + body: escapeHtml( + error instanceof Error ? error.message : String(error) + ), + tone: "danger", + }), + }); + } const preferredProviderId = normalizeProviderId({ value: query.provider, @@ -309,40 +339,21 @@ export function createBetterAuthShellPlugin({ deviceCode: query.deviceCode, returnUrl, }); - const autoProviderId = - preferredProviderId ?? - (socialProviders.length === 1 - ? (socialProviders[0]?.id ?? null) - : null); + const authLanding = buildAuthLandingPresentation({ + socialProviders, + preferredProviderId, + callbackUrl, + flowId: query.flowId, + deviceCode: query.deviceCode, + }); return renderHtmlPage({ title: "Hack auth", - body: [ - renderStateCard({ - eyebrow: "Sign in", - title: "Connect Hack auth", - body: "Use a configured provider to open a first-party Hack session in this browser.", - tone: "neutral", - }), - socialProviders.length > 0 - ? renderProviderActionGrid({ - providers: socialProviders, - callbackUrl, - mode: "sign-in", - }) - : renderStateCard({ - eyebrow: "No providers", - title: "No social login providers are configured", - body: "Configure GitHub or Google credentials for Better Auth, then reload this page.", - tone: "muted", - }), - renderFlowHint({ - flowId: query.flowId, - deviceCode: query.deviceCode, - }), - ].join(""), + heading: authLanding.heading, + subtitle: authLanding.subtitle, + body: authLanding.body, script: renderProviderActionScript({ callbackUrl, - autoProviderId, + autoProviderId: authLanding.autoProviderId, mode: "sign-in", }), }); @@ -362,6 +373,8 @@ export function createBetterAuthShellPlugin({ if (!(runtime.enabled && runtime.auth)) { return renderHtmlPage({ title: "Hack account unavailable", + heading: "Hack account unavailable", + subtitle: "This browser cannot finish the Hack session right now.", body: renderStateCard({ eyebrow: "Auth unavailable", title: "Hack auth is not configured", @@ -372,6 +385,23 @@ export function createBetterAuthShellPlugin({ }), }); } + try { + await ensureBetterAuthRuntimeReady(runtime); + } catch (error) { + return renderHtmlPage({ + title: "Hack account unavailable", + heading: "Hack account unavailable", + subtitle: "This browser cannot finish the Hack session right now.", + body: renderLifecycleMessage({ + eyebrow: "Unavailable", + title: "Auth storage is unavailable", + body: escapeHtml( + error instanceof Error ? error.message : String(error) + ), + tone: "danger", + }), + }); + } const rawSession = await runtime.auth.api.getSession({ headers: request.headers, @@ -391,6 +421,10 @@ export function createBetterAuthShellPlugin({ }); return renderHtmlPage({ title: "Hack account", + heading: resolvedSession.session ? "Hack account" : "Finish sign-in", + subtitle: resolvedSession.session + ? "This browser is now signed in to Hack." + : "Complete sign-in to connect this browser to Hack.", body: renderAccountBody({ session: resolvedSession.session, socialProviders, @@ -624,66 +658,50 @@ function renderAccountBody(input: { readonly accountPageUrl: string; }): string { const sessionSummary = input.session - ? renderStateCard({ - eyebrow: "Signed in", - title: escapeHtml( - input.session.name ?? input.session.email ?? "Hack account" - ), - body: [ - input.session.email - ? `Email: ${escapeHtml(input.session.email)}` - : null, - `User ID: ${escapeHtml(input.session.userId)}`, - input.session.organizationId - ? `Organization: ${escapeHtml(input.session.organizationId)}` - : null, - input.session.teamId - ? `Team: ${escapeHtml(input.session.teamId)}` - : null, - ] - .filter(Boolean) - .join("
"), - tone: "neutral", + ? renderAccountSummary({ + session: input.session, + lifecycle: input.lifecycle, }) - : renderStateCard({ + : renderLifecycleMessage({ eyebrow: "Not signed in", - title: "Complete sign-in to finish setup", - body: "Use the provider buttons below or return to the auth landing page to open a browser session first.", + title: "Finish sign-in to continue", + body: "Use a provider below to open your Hack session in this browser.", tone: "warning", }); - const lifecycleCard = renderLifecycleCard({ + const lifecycleCard = renderLifecycleMessageForFlow({ lifecycle: input.lifecycle, accountPageUrl: input.accountPageUrl, }); - const linkingCard = input.session - ? [ - renderStateCard({ - eyebrow: "Linked providers", - title: "Link another provider", - body: renderLinkingPolicy(input.accountLinkingPolicy), - tone: "muted", - }), - renderProviderActionGrid({ - providers: input.socialProviders, - callbackUrl: input.accountPageUrl, - mode: "link", - }), - ].join("") - : ""; - - return [sessionSummary, lifecycleCard, linkingCard].join(""); + const linkingCard = + input.session && input.socialProviders.length > 0 + ? [ + `
`, + ``, + renderProviderActionGrid({ + providers: input.socialProviders, + callbackUrl: input.accountPageUrl, + mode: "link", + }), + `
Linking details

${renderLinkingPolicy( + input.accountLinkingPolicy + )}

`, + "
", + ].join("") + : ""; + + return [sessionSummary, lifecycleCard, linkingCard].filter(Boolean).join(""); } -function renderLifecycleCard(input: { +function renderLifecycleMessageForFlow(input: { readonly lifecycle: SessionFlowLifecycle; readonly accountPageUrl: string; }): string { if (input.lifecycle.state === "none") { - return renderStateCard({ - eyebrow: "Ready", - title: "Hack session is active", - body: "You can return to the app now, or link another provider from this account page.", + return renderLifecycleMessage({ + eyebrow: "Session active", + title: "You are signed in", + body: "Return to Hack or link another sign-in method from this page.", tone: "neutral", }); } @@ -694,9 +712,9 @@ function renderLifecycleCard(input: { deviceCode: input.lifecycle.deviceCode, returnUrl: input.lifecycle.returnUrl, }); - return renderStateCard({ + return renderLifecycleMessage({ eyebrow: "Pending", - title: "Finish sign-in to complete this session flow", + title: "Finish sign-in to complete this setup", body: `Return to the auth landing page and continue with a provider.`, @@ -704,9 +722,9 @@ function renderLifecycleCard(input: { }); } if (input.lifecycle.state === "ready") { - return renderStateCard({ + return renderLifecycleMessage({ eyebrow: "Ready", - title: "Hack auth is ready to claim", + title: "Hack is ready", body: renderCompletionBody({ returnUrl: input.lifecycle.returnUrl, }), @@ -714,16 +732,16 @@ function renderLifecycleCard(input: { }); } if (input.lifecycle.state === "claimed") { - return renderStateCard({ + return renderLifecycleMessage({ eyebrow: "Claimed", - title: "This session flow was already claimed", + title: "Hack is already connected", body: renderCompletionBody({ returnUrl: input.lifecycle.returnUrl, }), tone: "muted", }); } - return renderStateCard({ + return renderLifecycleMessage({ eyebrow: "Unavailable", title: escapeHtml(input.lifecycle.title), body: escapeHtml(input.lifecycle.message), @@ -735,11 +753,79 @@ function renderCompletionBody(input: { readonly returnUrl: string | null; }): string { if (!input.returnUrl) { - return "Return to Hack and finish the local setup flow."; + return "You can return to Hack now."; } - return `Return to Hack and finish the local setup flow. Open app.`; + return `Return to Hack when you're ready. ${renderActionLink({ + href: input.returnUrl, + label: "Open Hack", + })}`; +} + +function renderAccountSummary(input: { + readonly session: NonNullable< + Awaited>["session"] + >; + readonly lifecycle: SessionFlowLifecycle; +}): string { + const identity = input.session.name ?? input.session.email ?? "Hack account"; + const avatarLabel = identity.slice(0, 1).toUpperCase(); + const meta = [ + input.session.organizationId + ? renderMetaPill({ + label: "Org", + value: input.session.organizationId, + }) + : "", + input.session.teamId + ? renderMetaPill({ + label: "Team", + value: input.session.teamId, + }) + : "", + ] + .filter(Boolean) + .join(""); + + return `
+ +
+ +

${escapeHtml(identity)}

+ ${ + input.session.email + ? `

${escapeHtml(input.session.email)}

` + : "" + } + ${meta ? `
${meta}
` : ""} + ${ + input.lifecycle.state === "ready" || input.lifecycle.state === "claimed" + ? `

This browser is linked to your Hack account.

` + : "" + } +
+
`; +} + +function renderMetaPill(input: { + readonly label: string; + readonly value: string; +}): string { + return `${escapeHtml( + input.label + )}${escapeHtml(input.value)}`; +} + +function renderLifecycleMessage(input: { + readonly eyebrow: string; + readonly title: string; + readonly body: string; + readonly tone: "neutral" | "muted" | "warning" | "success" | "danger"; +}): string { + return `
+

${escapeHtml(input.eyebrow)}

+

${input.title}

+

${input.body}

+
`; } function renderLifecycleAutoReturnScript(input: { @@ -767,23 +853,133 @@ window.requestAnimationFrame(() => { `; } +function buildAuthLandingPresentation(input: { + readonly socialProviders: readonly BetterAuthSocialProvider[]; + readonly preferredProviderId: string | null; + readonly callbackUrl: string; + readonly flowId?: string; + readonly deviceCode?: string; +}): { + readonly heading: string; + readonly subtitle: string; + readonly body: string; + readonly autoProviderId: string | null; +} { + const autoProviderId = + input.preferredProviderId ?? + (input.socialProviders.length === 1 + ? (input.socialProviders[0]?.id ?? null) + : null); + const autoProvider = autoProviderId + ? (input.socialProviders.find( + (provider) => provider.id === autoProviderId + ) ?? null) + : null; + const bodySections = [ + renderAuthLandingPrimarySection({ + socialProviders: input.socialProviders, + autoProvider, + callbackUrl: input.callbackUrl, + }), + renderFlowHint({ + flowId: input.flowId, + deviceCode: input.deviceCode, + }), + ].filter(Boolean); + + return { + heading: autoProvider + ? `Continue in ${autoProvider.label}` + : "Sign in to Hack", + subtitle: autoProvider + ? "Hack will bring you back to the app when sign-in is done." + : "Choose a sign-in method for this Mac.", + body: bodySections.join(""), + autoProviderId, + }; +} + +function renderAuthLandingPrimarySection(input: { + readonly socialProviders: readonly BetterAuthSocialProvider[]; + readonly autoProvider: BetterAuthSocialProvider | null; + readonly callbackUrl: string; +}): string { + if (input.socialProviders.length === 0) { + return renderStateCard({ + eyebrow: "No providers", + title: "No sign-in providers are configured", + body: "Configure GitHub or Google for Better Auth, then try again.", + tone: "muted", + }); + } + if (input.autoProvider) { + return renderAutoRedirectPanel({ + provider: input.autoProvider, + callbackUrl: input.callbackUrl, + mode: "sign-in", + }); + } + return renderProviderActionGrid({ + providers: input.socialProviders, + callbackUrl: input.callbackUrl, + mode: "sign-in", + }); +} + function renderProviderActionGrid(input: { readonly providers: readonly BetterAuthSocialProvider[]; readonly callbackUrl: string; readonly mode: "sign-in" | "link"; }): string { return `
${input.providers - .map( - (provider) => - `` + .map((provider) => + renderProviderActionButton({ + provider, + callbackUrl: input.callbackUrl, + mode: input.mode, + }) ) .join("")}
`; } +function renderAutoRedirectPanel(input: { + readonly provider: BetterAuthSocialProvider; + readonly callbackUrl: string; + readonly mode: "sign-in" | "link"; +}): string { + return `
+

This page is linked to your Mac

+

Redirecting to ${escapeHtml(input.provider.label)}

+

If nothing happens, continue manually.

+ ${renderProviderActionButton({ + provider: input.provider, + callbackUrl: input.callbackUrl, + mode: input.mode, + compact: true, + })} +
`; +} + +function renderProviderActionButton(input: { + readonly provider: BetterAuthSocialProvider; + readonly callbackUrl: string; + readonly mode: "sign-in" | "link"; + readonly compact?: boolean; +}): string { + return ``; +} + function renderFlowHint(input: { readonly flowId?: string; readonly deviceCode?: string; @@ -791,12 +987,11 @@ function renderFlowHint(input: { if (!(input.flowId && input.deviceCode)) { return ""; } - return renderStateCard({ - eyebrow: "Device flow", - title: "This browser session is linked to a local client", - body: `Flow: ${escapeHtml(input.flowId)}`, - tone: "muted", - }); + return `
+ Details +

Linked to this Mac.

+

Flow ${escapeHtml(input.flowId)}

+
`; } function renderProviderActionScript(input: { @@ -825,6 +1020,7 @@ async function runAuthAction(button) { return; } button.disabled = true; + button.classList.add("is-loading"); if (authStatus) { authStatus.textContent = mode === "link" ? "Linking provider..." : "Redirecting to provider..."; } @@ -840,13 +1036,19 @@ async function runAuthAction(button) { callbackURL, }), }); - const payload = await response.json().catch(() => null); + const rawText = await response.text(); + let payload = null; + try { + payload = rawText ? JSON.parse(rawText) : null; + } catch {} if (!response.ok) { throw new Error( payload?.message || payload?.error || payload?.code || - "Auth request failed" + (mode === "link" + ? "Couldn't link this provider right now." + : "Couldn't start sign-in right now.") ); } const nextUrl = @@ -860,6 +1062,7 @@ async function runAuthAction(button) { error instanceof Error ? error.message : String(error); } button.disabled = false; + button.classList.remove("is-loading"); } } @@ -884,8 +1087,8 @@ if (autoProviderId) { function renderLinkingPolicy(policy: BetterAuthAccountLinkingPolicy): string { const parts = [ policy.requireVerifiedEmail - ? "Verified provider email is required for implicit account linking." - : "Implicit account linking can use unverified provider email.", + ? "Verified provider email is required for implicit linking." + : "Unverified provider email can be used for implicit linking.", policy.allowDifferentEmails ? "Different provider emails are allowed." : "Provider email must match the existing Hack account email.", @@ -906,15 +1109,26 @@ function renderStateCard(input: { readonly body: string; readonly tone: "neutral" | "muted" | "warning" | "success" | "danger"; }): string { - return `
-

${escapeHtml(input.eyebrow)}

+ return `
+

${escapeHtml(input.eyebrow)}

${input.title}

${input.body}

`; } +function renderActionLink(input: { + readonly href: string; + readonly label: string; +}): string { + return `${escapeHtml( + input.label + )}`; +} + function renderHtmlPage(input: { readonly title: string; + readonly heading?: string; + readonly subtitle?: string; readonly body: string; readonly script?: string; }): Response { @@ -928,15 +1142,17 @@ function renderHtmlPage(input: {
-

Hack auth

-

First-party session setup for Hack CLI and Hack Desktop.

- ${input.body} -

+
+

Hack

+

${escapeHtml(input.heading ?? input.title)}

+ ${ + input.subtitle + ? `

${escapeHtml(input.subtitle)}

` + : "" + } +
+ ${input.body} +
+

+
${input.script ? `` : ""} diff --git a/services/auth-broker/tests/better-auth-schema.test.ts b/services/auth-broker/tests/better-auth-schema.test.ts new file mode 100644 index 000000000..1021120cd --- /dev/null +++ b/services/auth-broker/tests/better-auth-schema.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; + +import { authBrokerSchema, betterAuthSchema } from "../src/db/schema.ts"; + +describe("auth broker Better Auth schema", () => { + test("exports the required Better Auth core and organization tables", () => { + expect(Object.keys(betterAuthSchema).sort()).toEqual([ + "account", + "invitation", + "member", + "organization", + "session", + "team", + "teamMember", + "user", + "verification", + ]); + expect(authBrokerSchema.verification).toBeDefined(); + expect(authBrokerSchema.teamMember).toBeDefined(); + }); +}); diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 9d8f5be13..3edf9b1fa 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -428,8 +428,9 @@ describe("auth broker github flow routes", () => { ); expect(response.status).toBe(200); const html = await response.text(); - expect(html).toContain("Connect Hack auth"); + expect(html).toContain("Continue in GitHub"); expect(html).toContain("Continue with GitHub"); + expect(html).toContain("If nothing happens"); expect(html).toContain("/api/auth/sign-in/social"); }); @@ -487,8 +488,8 @@ describe("auth broker github flow routes", () => { ); expect(accountResponse.status).toBe(200); const accountHtml = await accountResponse.text(); - expect(accountHtml).toContain("Hack auth is ready to claim"); - expect(accountHtml).toContain("Open app"); + expect(accountHtml).toContain("Hack is ready"); + expect(accountHtml).toContain("Open Hack"); const claimResponse = await app.handle( new Request( diff --git a/services/auth-broker/tests/session-auth.test.ts b/services/auth-broker/tests/session-auth.test.ts index 35f5f38d9..f5a4ba796 100644 --- a/services/auth-broker/tests/session-auth.test.ts +++ b/services/auth-broker/tests/session-auth.test.ts @@ -177,7 +177,7 @@ describe("broker Hack session auth flow", () => { const accountResponse = await app.handle(new Request(completeLocation)); expect(accountResponse.status).toBe(200); const completeHtml = await accountResponse.text(); - expect(completeHtml).toContain("Hack auth is ready to claim"); + expect(completeHtml).toContain("Hack is ready"); const pollResponse = await app.handle( new Request( @@ -289,7 +289,7 @@ describe("broker Hack session auth flow", () => { const accountResponse = await app.handle(new Request(accountLocation)); const html = await accountResponse.text(); - expect(html).toContain("Open app"); + expect(html).toContain("Open Hack"); expect(html).toContain("hack://auth/complete"); expect(html).toContain("Returning to Hack"); expect(html).toContain("window.setTimeout"); From 672e306a1dc8ec5bf939b0b0e4ee54f2628f831f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 16:38:19 -0500 Subject: [PATCH 19/54] Flatten Hack auth handoff shell --- services/auth-broker/README.md | 4 +- .../src/modules/better-auth/shell-plugin.ts | 136 +++++++----------- services/auth-broker/tests/index.test.ts | 11 +- 3 files changed, 63 insertions(+), 88 deletions(-) diff --git a/services/auth-broker/README.md b/services/auth-broker/README.md index a0d6bdce9..5efbfc441 100644 --- a/services/auth-broker/README.md +++ b/services/auth-broker/README.md @@ -286,8 +286,8 @@ Hack account ownership, not on provider identity. 33. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) 34. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) 35. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) -32. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) -33. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) +36. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) +37. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) `services/auth-broker/.env.example` reflects the recommended grouping: diff --git a/services/auth-broker/src/modules/better-auth/shell-plugin.ts b/services/auth-broker/src/modules/better-auth/shell-plugin.ts index 86e8a7dde..3d34dae1c 100644 --- a/services/auth-broker/src/modules/better-auth/shell-plugin.ts +++ b/services/auth-broker/src/modules/better-auth/shell-plugin.ts @@ -353,7 +353,6 @@ export function createBetterAuthShellPlugin({ body: authLanding.body, script: renderProviderActionScript({ callbackUrl, - autoProviderId: authLanding.autoProviderId, mode: "sign-in", }), }); @@ -446,7 +445,6 @@ export function createBetterAuthShellPlugin({ deviceCode: query.deviceCode, returnUrl, }), - autoProviderId: null, mode: "link", }), renderLifecycleAutoReturnScript({ @@ -863,23 +861,23 @@ function buildAuthLandingPresentation(input: { readonly heading: string; readonly subtitle: string; readonly body: string; - readonly autoProviderId: string | null; } { - const autoProviderId = + const primaryProviderId = input.preferredProviderId ?? (input.socialProviders.length === 1 ? (input.socialProviders[0]?.id ?? null) : null); - const autoProvider = autoProviderId + const primaryProvider = primaryProviderId ? (input.socialProviders.find( - (provider) => provider.id === autoProviderId + (provider) => provider.id === primaryProviderId ) ?? null) : null; const bodySections = [ renderAuthLandingPrimarySection({ socialProviders: input.socialProviders, - autoProvider, + primaryProvider, callbackUrl: input.callbackUrl, + isDeviceLinked: Boolean(input.flowId && input.deviceCode), }), renderFlowHint({ flowId: input.flowId, @@ -888,21 +886,20 @@ function buildAuthLandingPresentation(input: { ].filter(Boolean); return { - heading: autoProvider - ? `Continue in ${autoProvider.label}` - : "Sign in to Hack", - subtitle: autoProvider - ? "Hack will bring you back to the app when sign-in is done." - : "Choose a sign-in method for this Mac.", + heading: "Sign in to Hack", + subtitle: + input.flowId && input.deviceCode + ? "This request is linked to your Mac." + : "Use your Hack account for shared remote features.", body: bodySections.join(""), - autoProviderId, }; } function renderAuthLandingPrimarySection(input: { readonly socialProviders: readonly BetterAuthSocialProvider[]; - readonly autoProvider: BetterAuthSocialProvider | null; + readonly primaryProvider: BetterAuthSocialProvider | null; readonly callbackUrl: string; + readonly isDeviceLinked: boolean; }): string { if (input.socialProviders.length === 0) { return renderStateCard({ @@ -912,11 +909,12 @@ function renderAuthLandingPrimarySection(input: { tone: "muted", }); } - if (input.autoProvider) { - return renderAutoRedirectPanel({ - provider: input.autoProvider, + if (input.primaryProvider) { + return renderSingleProviderPanel({ + provider: input.primaryProvider, callbackUrl: input.callbackUrl, mode: "sign-in", + isDeviceLinked: input.isDeviceLinked, }); } return renderProviderActionGrid({ @@ -942,20 +940,22 @@ function renderProviderActionGrid(input: { .join("")}`; } -function renderAutoRedirectPanel(input: { +function renderSingleProviderPanel(input: { readonly provider: BetterAuthSocialProvider; readonly callbackUrl: string; readonly mode: "sign-in" | "link"; + readonly isDeviceLinked: boolean; }): string { return `
-

This page is linked to your Mac

-

Redirecting to ${escapeHtml(input.provider.label)}

-

If nothing happens, continue manually.

+

${ + input.isDeviceLinked + ? "Continue in the browser to finish setup in Hack Desktop." + : "Continue in the browser to open your Hack account." + }

${renderProviderActionButton({ provider: input.provider, callbackUrl: input.callbackUrl, mode: input.mode, - compact: true, })}
`; } @@ -964,12 +964,13 @@ function renderProviderActionButton(input: { readonly provider: BetterAuthSocialProvider; readonly callbackUrl: string; readonly mode: "sign-in" | "link"; - readonly compact?: boolean; }): string { return ``; } +function renderProviderMark(input: { + readonly providerId: string; + readonly providerLabel: string; +}): string { + if (input.providerId === "github") { + return ``; + } + return escapeHtml(input.providerLabel.slice(0, 1)); +} + function renderProviderActionScript(input: { readonly callbackUrl: string; readonly mode: "sign-in" | "link"; @@ -1359,6 +1370,11 @@ function renderHtmlPage(input: { font-weight: 700; } + .provider-mark svg { + width: 100%; + height: 100%; + } + #auth-status { min-height: 1.25rem; margin: 0.55rem 0 0; @@ -1467,15 +1483,19 @@ function renderHtmlPage(input: { .theme-handoff .providers { width: 100%; + display: flex; + justify-content: center; } .theme-handoff .provider-button { border: 1px solid rgba(255, 255, 255, 0.45); - border-radius: 0.4rem; + border-radius: 0; + width: auto; + min-width: min(20rem, calc(100vw - 4rem)); background: transparent; color: #f5f5f5; - padding: 1rem 1.1rem; - font-size: 0.96rem; + padding: 0.82rem 1rem; + font-size: 0.92rem; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; @@ -1495,11 +1515,11 @@ function renderHtmlPage(input: { } .theme-handoff .provider-mark { - width: 1.1rem; - height: 1.1rem; + width: 0.95rem; + height: 0.95rem; background: transparent; - border: 1px solid #2563eb; - color: #2563eb; + border: none; + color: #f5f5f5; font-size: 0.62rem; } From bcbb390c699bf1b1b3ef763e5d6563b1286aea7c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Mar 2026 19:04:05 -0500 Subject: [PATCH 22/54] Unify Hack auth shell and isolate debug deep links --- apps/macos/App/Info.plist | 6 +- apps/macos/Config/Base.xcconfig | 1 + apps/macos/Config/Debug.xcconfig | 1 + .../DashboardFeature/DashboardModel.swift | 34 +- .../HackCLIService/HackCLIClient.swift | 37 ++- apps/macos/README.md | 4 +- services/auth-broker/src/flow-store.ts | 2 + .../src/modules/better-auth/shell-plugin.ts | 314 +++++++++--------- .../src/modules/github-oauth/model.ts | 1 + .../src/modules/github-oauth/service.ts | 26 +- services/auth-broker/src/types.ts | 1 + services/auth-broker/tests/deeplink.test.ts | 13 + services/auth-broker/tests/index.test.ts | 3 +- .../auth-broker/tests/session-auth.test.ts | 54 ++- 14 files changed, 330 insertions(+), 167 deletions(-) diff --git a/apps/macos/App/Info.plist b/apps/macos/App/Info.plist index 92aa47215..f69bd13e3 100644 --- a/apps/macos/App/Info.plist +++ b/apps/macos/App/Info.plist @@ -21,10 +21,14 @@ dance.hack.desktop CFBundleURLSchemes - hack + $(HACK_URL_SCHEME) + HackAuthCompletionURL + $(HACK_URL_SCHEME)://auth/complete + HackGitHubOAuthCallbackURL + $(HACK_URL_SCHEME)://auth/github/callback CFBundlePackageType APPL CFBundleShortVersionString diff --git a/apps/macos/Config/Base.xcconfig b/apps/macos/Config/Base.xcconfig index 0e7bab678..e51660a7f 100644 --- a/apps/macos/Config/Base.xcconfig +++ b/apps/macos/Config/Base.xcconfig @@ -7,6 +7,7 @@ CODE_SIGN_ENTITLEMENTS = App/HackDesktop.entitlements PRODUCT_NAME = Hack Desktop PRODUCT_BUNDLE_IDENTIFIER = dance.hack.desktop +HACK_URL_SCHEME = hack MACOSX_DEPLOYMENT_TARGET = 14.0 MARKETING_VERSION = 1.17.0 CURRENT_PROJECT_VERSION = $(MARKETING_VERSION) diff --git a/apps/macos/Config/Debug.xcconfig b/apps/macos/Config/Debug.xcconfig index f2f056858..94e201c99 100644 --- a/apps/macos/Config/Debug.xcconfig +++ b/apps/macos/Config/Debug.xcconfig @@ -1,3 +1,4 @@ #include "Base.xcconfig" +HACK_URL_SCHEME = hack-dev SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index eb0946e1d..7d1053c4c 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -1219,7 +1219,7 @@ public final class DashboardModel { } private func isHackAuthCompletionDeepLink(url: URL) -> Bool { - guard url.scheme?.lowercased() == "hack" else { + guard isRegisteredHackDeepLinkScheme(url.scheme) else { return false } @@ -1231,7 +1231,7 @@ public final class DashboardModel { } private func parseGitHubOAuthDeepLink(url: URL) -> GitHubOAuthDeepLinkContext? { - guard url.scheme?.lowercased() == "hack" else { + guard isRegisteredHackDeepLinkScheme(url.scheme) else { return nil } @@ -1269,6 +1269,36 @@ public final class DashboardModel { ) } + private func isRegisteredHackDeepLinkScheme(_ scheme: String?) -> Bool { + guard + let normalizedScheme = scheme? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !normalizedScheme.isEmpty + else { + return false + } + return registeredHackDeepLinkSchemes.contains(normalizedScheme) + } + + private var registeredHackDeepLinkSchemes: Set { + if + let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") + as? [[String: Any]] + { + let schemes = urlTypes + .flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } + .compactMap { + let trimmed = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + if !schemes.isEmpty { + return Set(schemes) + } + } + return ["hack", "hack-dev"] + } + private func normalizedPath(_ path: String) -> String { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasSuffix("/") && trimmed.count > 1 { diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index aafb109c8..c1f2405c5 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -896,8 +896,9 @@ public actor HackCLIClient { } public func loginHackAccount() async throws { + let redirectURL = desktopAuthCompletionURL() let result = try await run( - ["auth", "login", "--json", "--redirect", "hack://auth/complete"], + ["auth", "login", "--json", "--redirect", redirectURL], allowNonZeroExit: true ) let payload = try decodeJsonOrThrow(HackAuthLoginEnvelope.self, result: result) @@ -941,6 +942,7 @@ public actor HackCLIClient { profileId: String, setDefault: Bool ) async throws -> GitHubOAuthFlowStartResponse { + let desktopRedirectURL = desktopGitHubOAuthCallbackURL() var lastError: String? = nil for candidate in resolveAuthServerCandidates() { guard @@ -952,6 +954,7 @@ public actor HackCLIClient { URLQueryItem(name: "setDefault", value: setDefault ? "1" : "0"), URLQueryItem(name: "set_default", value: setDefault ? "1" : "0"), URLQueryItem(name: "requireInstallation", value: "1"), + URLQueryItem(name: "desktopRedirectUrl", value: desktopRedirectURL), ] ) else { @@ -1686,6 +1689,34 @@ public actor HackCLIClient { return components.url } + private func desktopAuthCompletionURL() -> String { + bundleString(forInfoDictionaryKey: "HackAuthCompletionURL") + ?? "\(desktopURLScheme())://auth/complete" + } + + private func desktopGitHubOAuthCallbackURL() -> String { + bundleString(forInfoDictionaryKey: "HackGitHubOAuthCallbackURL") + ?? "\(desktopURLScheme())://auth/github/callback" + } + + private func desktopURLScheme() -> String { + if + let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") + as? [[String: Any]] + { + for urlType in urlTypes { + if + let schemes = urlType["CFBundleURLSchemes"] as? [String], + let scheme = schemes.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !scheme.isEmpty + { + return scheme + } + } + } + return "hack" + } + private func buildAuthURLWithQuery( urlString: String, queryItems: [URLQueryItem] @@ -2112,6 +2143,10 @@ public actor HackCLIClient { return trimmed.isEmpty ? nil : trimmed } + private func bundleString(forInfoDictionaryKey key: String) -> String? { + normalized(Bundle.main.object(forInfoDictionaryKey: key) as? String) + } + private func resolveAuthServerCandidates() -> [String] { var candidates: [String] = [] var seen = Set() diff --git a/apps/macos/README.md b/apps/macos/README.md index 3c8892456..d6dd111c5 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -71,8 +71,8 @@ For GitHub browser OAuth, the app defaults to: Set `HACK_AUTH_BROKER_URL` to override and pin a custom broker endpoint. -The app registers URL scheme `hack` and handles `hack://auth/github/callback` -and `hack://auth/complete` deep links for browser return-to-app UX. +Release builds register URL scheme `hack`. Debug/Xcode builds register `hack-dev` +so browser auth returns target the running debug app instead of any installed release copy. ## Data source diff --git a/services/auth-broker/src/flow-store.ts b/services/auth-broker/src/flow-store.ts index 5d75b29a5..6660e8074 100644 --- a/services/auth-broker/src/flow-store.ts +++ b/services/auth-broker/src/flow-store.ts @@ -384,6 +384,7 @@ function parsePersistedFlow(input: { const deviceCodeHash = asNonEmptyString(input.value.deviceCodeHash); const authorizeUrl = asNonEmptyString(input.value.authorizeUrl); const redirectUri = asNonEmptyString(input.value.redirectUri); + const desktopRedirectUrl = asOptionalString(input.value.desktopRedirectUrl); const codeVerifier = asOptionalString(input.value.codeVerifier); const status = asFlowStatus(input.value.status); const setDefault = @@ -439,6 +440,7 @@ function parsePersistedFlow(input: { createdAtMs, expiresAtMs, redirectUri, + ...(desktopRedirectUrl ? { desktopRedirectUrl } : {}), status, ...(account ? { account } : {}), ...(installationId ? { installationId } : {}), diff --git a/services/auth-broker/src/modules/better-auth/shell-plugin.ts b/services/auth-broker/src/modules/better-auth/shell-plugin.ts index e6cfc3d14..2e0f4631d 100644 --- a/services/auth-broker/src/modules/better-auth/shell-plugin.ts +++ b/services/auth-broker/src/modules/better-auth/shell-plugin.ts @@ -14,7 +14,7 @@ import { issueBrokerManagementToken } from "./management-token.ts"; import { resolveBetterAuthSession } from "./session.ts"; const SESSION_FLOW_PROFILE_ID = "session"; -const SAFE_RETURN_PROTOCOLS = new Set(["hack:"]); +const SAFE_RETURN_PROTOCOLS = new Set(["hack:", "hack-dev:"]); const BetterAuthShellModel = { startQuery: t.Object({ @@ -422,15 +422,16 @@ export function createBetterAuthShellPlugin({ }); return renderHtmlPage({ title: "Hack account", - heading: resolvedSession.session ? "Hack account" : "Finish sign-in", + brand: "HACK", + theme: "handoff", + heading: undefined, subtitle: resolvedSession.session - ? "This browser is now signed in to Hack." - : "Complete sign-in to connect this browser to Hack.", + ? "Signed in to Hack." + : "Sign in to Hack.", body: renderAccountBody({ session: resolvedSession.session, socialProviders, lifecycle, - accountLinkingPolicy: getAccountLinkingPolicy({ runtime }), accountPageUrl: buildAccountPageUrl({ publicBaseUrl: config.publicBaseUrl, flowId: query.flowId, @@ -654,7 +655,6 @@ function renderAccountBody(input: { >["session"]; readonly socialProviders: readonly BetterAuthSocialProvider[]; readonly lifecycle: SessionFlowLifecycle; - readonly accountLinkingPolicy: BetterAuthAccountLinkingPolicy; readonly accountPageUrl: string; }): string { const sessionSummary = input.session @@ -662,85 +662,100 @@ function renderAccountBody(input: { session: input.session, lifecycle: input.lifecycle, }) - : renderLifecycleMessage({ - eyebrow: "Not signed in", - title: "Finish sign-in to continue", - body: "Use a provider below to open your Hack session in this browser.", - tone: "warning", - }); + : ""; const lifecycleCard = renderLifecycleMessageForFlow({ lifecycle: input.lifecycle, - accountPageUrl: input.accountPageUrl, + hasSession: Boolean(input.session), }); - const linkingCard = - input.session && input.socialProviders.length > 0 - ? [ - `
`, - ``, - renderProviderActionGrid({ - providers: input.socialProviders, - callbackUrl: input.accountPageUrl, - mode: "link", - }), - `
Linking details

${renderLinkingPolicy( - input.accountLinkingPolicy - )}

`, - "
", - ].join("") - : ""; + let linkingCard = ""; + if (input.socialProviders.length > 0) { + if (input.session) { + linkingCard = [ + `
`, + ``, + renderProviderActionGrid({ + providers: input.socialProviders, + callbackUrl: input.accountPageUrl, + mode: "link", + }), + "
", + ].join(""); + } else { + linkingCard = [ + `
`, + renderProviderActionGrid({ + providers: input.socialProviders, + callbackUrl: input.accountPageUrl, + mode: "sign-in", + }), + "
", + ].join(""); + } + } return [sessionSummary, lifecycleCard, linkingCard].filter(Boolean).join(""); } function renderLifecycleMessageForFlow(input: { readonly lifecycle: SessionFlowLifecycle; - readonly accountPageUrl: string; + readonly hasSession: boolean; }): string { if (input.lifecycle.state === "none") { - return renderLifecycleMessage({ - eyebrow: "Session active", - title: "You are signed in", - body: "Return to Hack or link another sign-in method from this page.", - tone: "neutral", - }); + return input.hasSession + ? renderLifecycleMessage({ + eyebrow: "Ready", + title: "Signed in to Hack.", + body: "Manage sign-in methods from this page.", + tone: "neutral", + }) + : ""; } if (input.lifecycle.state === "sign_in_required") { - const retryUrl = buildAuthShellUrlFromAccountPageUrl({ - accountPageUrl: input.accountPageUrl, - flowId: input.lifecycle.flowId, - deviceCode: input.lifecycle.deviceCode, - returnUrl: input.lifecycle.returnUrl, - }); return renderLifecycleMessage({ - eyebrow: "Pending", - title: "Finish sign-in to complete this setup", - body: `Return to the auth landing page and continue with a provider.`, - tone: "warning", + eyebrow: "Linked", + title: "This request is linked to this Mac.", + body: "Continue with a provider below to finish setup.", + tone: "neutral", }); } if (input.lifecycle.state === "ready") { return renderLifecycleMessage({ eyebrow: "Ready", - title: "Hack is ready", + title: "Connected to this Mac.", body: renderCompletionBody({ returnUrl: input.lifecycle.returnUrl, }), - tone: "success", + tone: "neutral", }); } if (input.lifecycle.state === "claimed") { return renderLifecycleMessage({ - eyebrow: "Claimed", - title: "Hack is already connected", + eyebrow: "Ready", + title: "Hack is already connected.", body: renderCompletionBody({ returnUrl: input.lifecycle.returnUrl, }), - tone: "muted", + tone: "neutral", }); } + if (input.hasSession) { + return input.lifecycle.returnUrl + ? renderLifecycleMessage({ + eyebrow: "Ready", + title: "Signed in to Hack.", + body: renderCompletionBody({ + returnUrl: input.lifecycle.returnUrl, + }), + tone: "neutral", + }) + : renderLifecycleMessage({ + eyebrow: "Ready", + title: "Signed in to Hack.", + body: "Manage sign-in methods from this page.", + tone: "neutral", + }); + } return renderLifecycleMessage({ eyebrow: "Unavailable", title: escapeHtml(input.lifecycle.title), @@ -768,35 +783,19 @@ function renderAccountSummary(input: { readonly lifecycle: SessionFlowLifecycle; }): string { const identity = input.session.name ?? input.session.email ?? "Hack account"; - const avatarLabel = identity.slice(0, 1).toUpperCase(); - const meta = [ - input.session.organizationId - ? renderMetaPill({ - label: "Org", - value: input.session.organizationId, - }) - : "", - input.session.teamId - ? renderMetaPill({ - label: "Team", - value: input.session.teamId, - }) - : "", + const detail = [ + input.session.email, + input.session.organizationId ? `Org ${input.session.organizationId}` : null, + input.session.teamId ? `Team ${input.session.teamId}` : null, ] - .filter(Boolean) - .join(""); + .filter((value): value is string => Boolean(value)) + .join(" • "); return `
-
- +

${escapeHtml(identity)}

- ${ - input.session.email - ? `

${escapeHtml(input.session.email)}

` - : "" - } - ${meta ? `
${meta}
` : ""} + ${detail ? `

${escapeHtml(detail)}

` : ""} ${ input.lifecycle.state === "ready" || input.lifecycle.state === "claimed" ? `

This browser is linked to your Hack account.

` @@ -806,15 +805,6 @@ function renderAccountSummary(input: {
`; } -function renderMetaPill(input: { - readonly label: string; - readonly value: string; -}): string { - return `${escapeHtml( - input.label - )}${escapeHtml(input.value)}`; -} - function renderLifecycleMessage(input: { readonly eyebrow: string; readonly title: string; @@ -1070,25 +1060,6 @@ for (const button of authButtons) { `; } -function renderLinkingPolicy(policy: BetterAuthAccountLinkingPolicy): string { - const parts = [ - policy.requireVerifiedEmail - ? "Verified provider email is required for implicit linking." - : "Unverified provider email can be used for implicit linking.", - policy.allowDifferentEmails - ? "Different provider emails are allowed." - : "Provider email must match the existing Hack account email.", - ]; - if (policy.trustedProviders.length > 0) { - parts.push( - `Trusted providers: ${policy.trustedProviders - .map((providerId) => escapeHtml(providerId)) - .join(", ")}.` - ); - } - return parts.join(" "); -} - function renderStateCard(input: { readonly eyebrow: string; readonly title: string; @@ -1203,24 +1174,12 @@ function renderHtmlPage(input: { } .summary { - display: flex; + display: grid; gap: 0.95rem; - align-items: flex-start; padding: 0 0 1rem; border-bottom: 1px solid var(--line); } - .avatar { - width: 2.5rem; - height: 2.5rem; - border-radius: 999px; - display: grid; - place-items: center; - background: var(--accent); - color: white; - font-weight: 700; - } - .summary-copy h2, .message h2 { margin: 0; @@ -1272,28 +1231,6 @@ function renderHtmlPage(input: { text-transform: uppercase; } - .meta-row { - display: flex; - gap: 0.45rem; - flex-wrap: wrap; - margin-top: 0.25rem; - } - - .meta-pill { - display: inline-flex; - align-items: center; - gap: 0.45rem; - padding: 0.28rem 0.55rem; - border-radius: 999px; - background: rgba(15, 23, 42, 0.05); - color: var(--text); - font-size: 0.83rem; - } - - .meta-pill span { - color: var(--muted); - } - .message { padding: 0.95rem 1rem; border: 1px solid transparent; @@ -1467,7 +1404,7 @@ function renderHtmlPage(input: { .theme-handoff .stack { margin-top: 1.35rem; - gap: 1rem; + gap: 1.15rem; } .theme-handoff .hero { @@ -1523,6 +1460,84 @@ function renderHtmlPage(input: { font-size: 0.62rem; } + .theme-handoff .summary { + justify-items: center; + text-align: center; + gap: 0.4rem; + padding: 0; + border-bottom: none; + } + + .theme-handoff .summary-copy { + justify-items: center; + gap: 0.35rem; + } + + .theme-handoff .summary-copy h2 { + font-size: 1.35rem; + line-height: 1.05; + color: #f5f5f5; + } + + .theme-handoff .summary-detail, + .theme-handoff .summary-status { + color: #71717a; + font-size: 0.9rem; + } + + .theme-handoff .section { + justify-items: center; + text-align: center; + } + + .theme-handoff .section-label { + color: #71717a; + } + + .theme-handoff .message { + display: grid; + gap: 0.35rem; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + text-align: center; + } + + .theme-handoff .message h2 { + font-size: 1rem; + line-height: 1.3; + color: #f5f5f5; + } + + .theme-handoff .message p { + margin: 0; + color: #9ca3af; + } + + .theme-handoff .message-eyebrow { + margin: 0; + color: #71717a; + } + + .theme-handoff .action-link { + margin-left: 0.5rem; + padding: 0.82rem 1rem; + border-radius: 0; + border-color: rgba(255, 255, 255, 0.24); + background: transparent; + color: #f5f5f5; + font-size: 0.92rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .theme-handoff .action-link:hover { + text-decoration: none; + background: rgba(255, 255, 255, 0.04); + } + .theme-handoff #auth-status { margin-top: 1rem; text-align: center; @@ -1585,21 +1600,6 @@ function buildAuthShellUrl(input: { return url.toString(); } -function buildAuthShellUrlFromAccountPageUrl(input: { - readonly accountPageUrl: string; - readonly flowId: string; - readonly deviceCode: string; - readonly returnUrl: string | null; -}): string { - const accountPageUrl = new URL(input.accountPageUrl); - return buildAuthShellUrl({ - publicBaseUrl: accountPageUrl.origin, - flowId: input.flowId, - deviceCode: input.deviceCode, - ...(input.returnUrl ? { returnUrl: input.returnUrl } : {}), - }); -} - function buildAccountPageUrl(input: { readonly publicBaseUrl: string; readonly flowId?: string; @@ -1659,7 +1659,7 @@ function shouldAutoReturnToDesktop(input: { } try { const candidate = new URL(input.returnUrl); - return candidate.protocol === "hack:"; + return SAFE_RETURN_PROTOCOLS.has(candidate.protocol); } catch { return false; } diff --git a/services/auth-broker/src/modules/github-oauth/model.ts b/services/auth-broker/src/modules/github-oauth/model.ts index a03ddeb55..858f562d0 100644 --- a/services/auth-broker/src/modules/github-oauth/model.ts +++ b/services/auth-broker/src/modules/github-oauth/model.ts @@ -14,6 +14,7 @@ export const GitHubOAuthModel = { setDefault: t.Optional(t.String()), redirect: t.Optional(t.String()), requireInstallation: t.Optional(t.String()), + desktopRedirectUrl: t.Optional(t.String()), }), callbackQuery: t.Object({ code: t.Optional(t.String()), diff --git a/services/auth-broker/src/modules/github-oauth/service.ts b/services/auth-broker/src/modules/github-oauth/service.ts index 049666286..826ec14ab 100644 --- a/services/auth-broker/src/modules/github-oauth/service.ts +++ b/services/auth-broker/src/modules/github-oauth/service.ts @@ -40,7 +40,7 @@ export function createFlow(input: { readonly flowStore: FlowStore; readonly query: Pick< GitHubOAuthModel["startQuery"], - "profile" | "setDefault" | "requireInstallation" + "profile" | "setDefault" | "requireInstallation" | "desktopRedirectUrl" >; }): StartFlowPayload { const nowMs = Date.now(); @@ -50,6 +50,9 @@ export function createFlow(input: { const profileId = normalizeText(input.query.profile) ?? "default"; const setDefault = isTruthy(input.query.setDefault); const requireInstallation = isTruthy(input.query.requireInstallation); + const desktopRedirectUrl = normalizeDesktopRedirectUrl( + input.query.desktopRedirectUrl + ); const expiresAtMs = nowMs + input.config.flowTtlMs; const authorizeUrl = buildAuthorizeUrl({ authorizeUrl: input.config.githubAuthorizeUrl, @@ -77,6 +80,7 @@ export function createFlow(input: { createdAtMs: nowMs, expiresAtMs, redirectUri: input.config.githubRedirectUri, + ...(desktopRedirectUrl ? { desktopRedirectUrl } : {}), status: "pending", }); @@ -285,6 +289,7 @@ export async function handleGitHubCallback(input: { flowId: flow.id, profileId: flow.profileId, status: installationId ? "complete" : "install_required", + ...(flow.desktopRedirectUrl ? { baseUrl: flow.desktopRedirectUrl } : {}), ...(installationId ? { installationId } : {}), }), }; @@ -362,9 +367,10 @@ export function buildHackDesktopDeepLink(input: { readonly flowId: string; readonly profileId: string; readonly status: string; + readonly baseUrl?: string; readonly installationId?: string; }): string { - const url = new URL("hack://auth/github/callback"); + const url = new URL(input.baseUrl ?? "hack://auth/github/callback"); url.searchParams.set("flowId", input.flowId); url.searchParams.set("profileId", input.profileId); url.searchParams.set("status", input.status); @@ -374,6 +380,22 @@ export function buildHackDesktopDeepLink(input: { return url.toString(); } +function normalizeDesktopRedirectUrl(value: string | undefined): string | null { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + try { + const url = new URL(normalized); + if (url.protocol !== "hack:" && url.protocol !== "hack-dev:") { + return null; + } + return url.toString(); + } catch { + return null; + } +} + function renderCallbackPage(input: { readonly title: string; readonly body: string; diff --git a/services/auth-broker/src/types.ts b/services/auth-broker/src/types.ts index 118f0460a..225952acf 100644 --- a/services/auth-broker/src/types.ts +++ b/services/auth-broker/src/types.ts @@ -44,6 +44,7 @@ export type OAuthFlow = { readonly createdAtMs: number; readonly expiresAtMs: number; readonly redirectUri: string; + readonly desktopRedirectUrl?: string; installationId?: string; status: FlowStatus; account?: OAuthFlowAccount; diff --git a/services/auth-broker/tests/deeplink.test.ts b/services/auth-broker/tests/deeplink.test.ts index 9d95cc3af..d0dab2e7f 100644 --- a/services/auth-broker/tests/deeplink.test.ts +++ b/services/auth-broker/tests/deeplink.test.ts @@ -29,4 +29,17 @@ describe("github oauth callback deep link", () => { const url = new URL(deepLink); expect(url.searchParams.get("installationId")).toBe("123456"); }); + + test("uses a custom desktop callback URL when provided", () => { + const deepLink = buildHackDesktopDeepLink({ + flowId: "flow_custom", + profileId: "debug", + status: "complete", + baseUrl: "hack-dev://auth/github/callback", + }); + const url = new URL(deepLink); + expect(url.protocol).toBe("hack-dev:"); + expect(url.hostname).toBe("auth"); + expect(url.pathname).toBe("/github/callback"); + }); }); diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index e9eb4b427..628dad8a9 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -494,7 +494,8 @@ describe("auth broker github flow routes", () => { ); expect(accountResponse.status).toBe(200); const accountHtml = await accountResponse.text(); - expect(accountHtml).toContain("Hack is ready"); + expect(accountHtml).toContain("Connected to this Mac."); + expect(accountHtml).toContain(">HACK<"); expect(accountHtml).toContain("Open Hack"); const claimResponse = await app.handle( diff --git a/services/auth-broker/tests/session-auth.test.ts b/services/auth-broker/tests/session-auth.test.ts index f5a4ba796..fd45b813e 100644 --- a/services/auth-broker/tests/session-auth.test.ts +++ b/services/auth-broker/tests/session-auth.test.ts @@ -177,7 +177,8 @@ describe("broker Hack session auth flow", () => { const accountResponse = await app.handle(new Request(completeLocation)); expect(accountResponse.status).toBe(200); const completeHtml = await accountResponse.text(); - expect(completeHtml).toContain("Hack is ready"); + expect(completeHtml).toContain("Connected to this Mac."); + expect(completeHtml).toContain(">HACK<"); const pollResponse = await app.handle( new Request( @@ -295,4 +296,55 @@ describe("broker Hack session auth flow", () => { expect(html).toContain("window.setTimeout"); }); }); + + test("auth account page accepts debug deep links and keeps the dark handoff shell", async () => { + const session = { + user: { + id: "user-debug", + email: "debug@example.com", + emailVerified: true, + name: "Debug User", + }, + session: { + id: "sess-debug", + userId: "user-debug", + token: "session-token", + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + } as unknown as BetterAuthSession; + + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(session), + }); + + const startResponse = await app.handle( + new Request( + "http://localhost/v1/auth/session/start?redirect=hack-dev://auth/complete" + ) + ); + const startPayload = ( + (await startResponse.json()) as SessionStartFlowResponse + ).flow; + const completeResponse = await app.handle( + new Request(startPayload.authorizeUrl) + ); + const accountLocation = completeResponse.headers.get("location"); + expect(accountLocation).toContain( + "redirect=hack-dev%3A%2F%2Fauth%2Fcomplete" + ); + if (!accountLocation) { + return; + } + + const accountResponse = await app.handle(new Request(accountLocation)); + const html = await accountResponse.text(); + expect(html).toContain(">HACK<"); + expect(html).toContain("Signed in to Hack."); + expect(html).toContain("hack-dev://auth/complete"); + expect(html).not.toContain("Session not found"); + }); }); From 62f06fea9d5628cf267a6bb536a83925f19de928 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 14:33:00 -0500 Subject: [PATCH 23/54] Fix Linear remote connection persistence and repair UX --- .../DashboardFeature/DashboardModel.swift | 15 + .../SettingsOverlayView.swift | 269 ++++++++++++++---- .../HackCLIService/HackCLIClient.swift | 17 ++ .../Sources/HackDesktopModels/Models.swift | 59 ++++ services/auth-broker/src/db/ensure-columns.ts | 109 +++++++ .../src/modules/linear-connections/service.ts | 92 +++--- .../src/modules/linear-sync-store/service.ts | 112 +++++--- .../auth-broker/tests/ensure-columns.test.ts | 98 +++++++ .../tests/linear-store-schema.test.ts | 76 +++++ .../extensions/linear/commands.ts | 239 ++++++++++++++++ tests/linear-commands.test.ts | 23 ++ 11 files changed, 983 insertions(+), 126 deletions(-) create mode 100644 services/auth-broker/src/db/ensure-columns.ts create mode 100644 services/auth-broker/tests/ensure-columns.test.ts create mode 100644 services/auth-broker/tests/linear-store-schema.test.ts diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 7d1053c4c..2f05df1b3 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -777,6 +777,21 @@ public final class DashboardModel { } } + public func listLinearConnections( + profileId: String? = nil, + organizationId: String? = nil + ) async -> LinearConnectionsResponse? { + do { + return try await client.listLinearConnections( + profileId: profileId, + organizationId: organizationId + ) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + public func inspectLinearStatus(profileId: String? = nil) async -> LinearStatusResponse? { do { return try await client.inspectLinearStatus(profileId: profileId) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index 92963bd2c..21890aebd 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -2839,6 +2839,7 @@ private struct LinearExtensionSettingsView: View { @State private var suppressSyncToggleChange = false @State private var enabled = false @State private var diagnostics: LinearProfilesResponse? = nil + @State private var remoteConnections: LinearConnectionsResponse? = nil @State private var profileStatusById: [String: LinearStatusResponse] = [:] @State private var defaultProfile = "" @State private var syncLabels = false @@ -2873,8 +2874,8 @@ private struct LinearExtensionSettingsView: View { HStack(alignment: .center, spacing: 8) { StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) StatusPill( - text: "\(linearProfiles.count) provider account\(linearProfiles.count == 1 ? "" : "s")", - tone: linearProfiles.isEmpty ? .neutral : .good + text: "\(remoteLinearConnections.count) remote account\(remoteLinearConnections.count == 1 ? "" : "s")", + tone: remoteLinearConnections.isEmpty ? .neutral : .good ) if !resolvedDefaultProfile.isEmpty { StatusPill( @@ -2933,85 +2934,158 @@ private struct LinearExtensionSettingsView: View { } } - if linearProfiles.isEmpty { - Text("No accounts connected yet.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 10) { - ForEach(linearProfiles, id: \.id) { profile in - let profileStatus = profileStatusById[profile.id] - let accountLabel = linearAccountLabel(profile: profile, status: profileStatus) - let accountEmail = linearAccountEmail(profile: profile, status: profileStatus) - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(accountLabel ?? profile.id) - .font(.mono(.subheadline, weight: .semibold)) - if profile.isDefault { - StatusPill(text: "Default profile", tone: .good) - } - Spacer() - if !profile.isDefault { - Button { - Task { await saveLinearDefaultProfile(profile.id) } - } label: { - Label("Set default", systemImage: "star") + if model.hackAccountState?.authenticated == true { + if remoteLinearConnections.isEmpty && localOnlyLinearProfiles.isEmpty { + Text("No Hack-account Linear connections yet.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + + if !remoteLinearConnections.isEmpty { + VStack(alignment: .leading, spacing: 10) { + ForEach(remoteLinearConnections) { connection in + let profileId = connection.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let localProfile = linearProfiles.first(where: { $0.id == profileId }) + let accountLabel = + connection.accountName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? connection.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? connection.accountId?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? profileId + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(accountLabel) + .font(.mono(.subheadline, weight: .semibold)) + if !profileId.isEmpty { + StatusPill(text: "Profile \(profileId)", tone: .neutral) + } + if !profileId.isEmpty && resolvedDefaultProfile == profileId { + StatusPill(text: "Default profile", tone: .good) + } + StatusPill( + text: localProfile == nil ? "Needs local token on this Mac" : "Saved on this Mac", + tone: localProfile == nil ? .warn : .good + ) + Spacer() + if !profileId.isEmpty && resolvedDefaultProfile != profileId { + Button { + Task { await saveLinearDefaultProfile(profileId) } + } label: { + Label("Set default", systemImage: "star") + } + .adaptiveToolbarButton() } - .adaptiveToolbarButton() - } - if model.hackAccountState?.authenticated == true { Button { Task { await reconnectLinearProfile( - profile.id, - setDefault: profile.isDefault + profileId.isEmpty ? nextLinearProfileId() : profileId, + setDefault: resolvedDefaultProfile == profileId ) } } label: { - Label("Reconnect", systemImage: "arrow.clockwise.circle") + Label( + localProfile == nil ? "Link this Mac" : "Reconnect", + systemImage: "arrow.clockwise.circle" + ) } .adaptiveToolbarButtonProminent() .disabled( isAuthenticating || - disconnectingLinearProfiles.contains(profile.id) + (!profileId.isEmpty && disconnectingLinearProfiles.contains(profileId)) ) - Button(role: .destructive) { - Task { await disconnectLinearProfile(profile.id) } + if let localProfile { + Button(role: .destructive) { + Task { await disconnectLinearProfile(localProfile.id) } + } label: { + Label("Remove local token", systemImage: "trash") + } + .adaptiveToolbarButton() + .disabled( + isAuthenticating || + disconnectingLinearProfiles.contains(localProfile.id) + ) + } + } + + if let email = connection.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines), + !email.isEmpty + { + Text(email) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if let teamId = connection.teamId?.trimmingCharacters(in: .whitespacesAndNewlines), + !teamId.isEmpty + { + Text("Linear team \(teamId)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } else if let organizationId = connection.organizationId?.trimmingCharacters(in: .whitespacesAndNewlines), + !organizationId.isEmpty + { + Text("Linear organization \(organizationId)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + if connection.id != remoteLinearConnections.last?.id { + Divider() + .opacity(0.2) + } + } + } + } + + if !localOnlyLinearProfiles.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Local-only on this Mac") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + ForEach(localOnlyLinearProfiles, id: \.id) { profile in + let profileStatus = profileStatusById[profile.id] + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(linearAccountLabel(profile: profile, status: profileStatus) ?? profile.id) + .font(.mono(.subheadline, weight: .semibold)) + StatusPill(text: "Profile \(profile.id)", tone: .neutral) + StatusPill(text: "Not linked to Hack account yet", tone: .warn) + Spacer() + Button { + Task { + await reconnectLinearProfile( + profile.id, + setDefault: profile.isDefault + ) + } } label: { - Label("Disconnect", systemImage: "trash") + Label("Link account", systemImage: "plus.circle") } - .adaptiveToolbarButton() + .adaptiveToolbarButtonProminent() .disabled( isAuthenticating || disconnectingLinearProfiles.contains(profile.id) ) } + if let accountEmail = linearAccountEmail(profile: profile, status: profileStatus), + !accountEmail.isEmpty + { + Text(accountEmail) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } } - - if let accountEmail, !accountEmail.isEmpty { - Text(accountEmail) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - Text("Profile \(profile.id)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - if let profileStatus, - let tokenExpiresAt = profileStatus.tokenExpiresAt, - !tokenExpiresAt.isEmpty - { - Text("Token expires \(tokenExpiresAt)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - if profile.id != linearProfiles.last?.id { - Divider() - .opacity(0.2) } } } + } else if linearProfiles.isEmpty { + Text("Sign in to Hack to inspect remote Linear connections.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + Text("This Mac still has \(linearProfiles.count) local Linear profile\(linearProfiles.count == 1 ? "" : "s"), but remote account-owned connections require Hack sign-in.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) } } @@ -3271,8 +3345,27 @@ private struct LinearExtensionSettingsView: View { } } + private var remoteLinearConnections: [LinearRemoteConnection] { + (remoteConnections?.connections ?? []).sorted { lhs, rhs in + let left = lhs.updatedAt.trimmingCharacters(in: .whitespacesAndNewlines) + let right = rhs.updatedAt.trimmingCharacters(in: .whitespacesAndNewlines) + return left.localizedCompare(right) == .orderedDescending + } + } + + private var localOnlyLinearProfiles: [LinearProfileSummary] { + linearProfiles.filter { profile in + !remoteLinearConnections.contains(where: { + $0.profileId?.caseInsensitiveCompare(profile.id) == .orderedSame + }) + } + } + private func nextLinearProfileId() -> String { - let existing = Set(linearProfiles.map { $0.id.lowercased() }) + let existing = Set( + linearProfiles.map { $0.id.lowercased() } + + remoteLinearConnections.compactMap { $0.profileId?.lowercased() } + ) if !existing.contains("default") { return "default" } @@ -3327,7 +3420,16 @@ private struct LinearExtensionSettingsView: View { private func refreshLinearDiagnostics() async { isLoadingDiagnostics = true defer { isLoadingDiagnostics = false } - diagnostics = await model.inspectLinearProfiles() + let hackAccountAuthenticated = model.hackAccountState?.authenticated == true + async let localProfilesTask = model.inspectLinearProfiles() + async let remoteConnectionsTask: LinearConnectionsResponse? = { + guard hackAccountAuthenticated else { + return nil + } + return await model.listLinearConnections() + }() + diagnostics = await localProfilesTask + remoteConnections = await remoteConnectionsTask if let diagnostics { defaultProfile = diagnostics.defaultProfile } @@ -3575,6 +3677,14 @@ private struct LinearExtensionSettingsView: View { return "none" } guard let profile = linearProfiles.first(where: { $0.id == trimmed }) else { + if let connection = remoteLinearConnections.first(where: { + $0.profileId?.caseInsensitiveCompare(trimmed) == .orderedSame + }) { + return connection.accountName + ?? connection.accountEmail + ?? connection.accountId + ?? trimmed + } return trimmed } let status = profileStatusById[trimmed] @@ -3648,7 +3758,9 @@ private struct LinearExtensionSettingsView: View { let generatedProfileId = defaultLinearProfileNeedsRepair && !repairDefaultProfileId.isEmpty ? repairDefaultProfileId : nextLinearProfileId() - let setAsDefault = linearProfiles.isEmpty || defaultLinearProfileNeedsRepair + let setAsDefault = + (linearProfiles.isEmpty && remoteLinearConnections.isEmpty) || + defaultLinearProfileNeedsRepair authPollingTask = Task { await connectLinearAccountViaBrowser( profileId: generatedProfileId, @@ -3713,6 +3825,7 @@ private struct LinearExtensionSettingsView: View { let formatter = ISO8601DateFormatter() let expiresAtDate = formatter.date(from: started.expiresAt) + var pendingPollCount = 0 while !Task.isCancelled { if let expiresAtDate, Date() >= expiresAtDate { @@ -3739,6 +3852,40 @@ private struct LinearExtensionSettingsView: View { } authFlowStatus = flowStatus + if flowStatus.status == "pending" { + pendingPollCount += 1 + if pendingPollCount.isMultiple(of: 4), + model.hackAccountState?.authenticated == true, + let refreshedRemoteConnections = await model.listLinearConnections() + { + remoteConnections = refreshedRemoteConnections + } + if pendingPollCount == 8 { + if remoteLinearConnections.contains(where: { + $0.profileId?.caseInsensitiveCompare(profile) == .orderedSame + }) { + message = + "Linear already appears connected on this Hack account for \(profile). If the browser opened a manage/install page instead of returning here, finish there or revoke the existing Linear app token, then retry linking this Mac." + } else { + message = + "Still waiting for Linear to return to Hack. If the browser opened a manage/install page, finish there and then return here." + } + } + if pendingPollCount >= 16 { + if remoteLinearConnections.contains(where: { + $0.profileId?.caseInsensitiveCompare(profile) == .orderedSame + }) { + message = + "Linear is already linked on this Hack account for \(profile), but this Mac did not receive a fresh callback token. Close the browser and use Link this Mac if you need a local token cached here." + } else { + message = + "Linear did not return a callback for \(profile). If Linear shows Hack already installed, revoke the existing Hack app authorization in Linear and reconnect once so Hack can persist it on this account." + } + await refreshLinearDiagnostics() + authFlowStatus = nil + return + } + } if await handleLinearOAuthFlowStatus(flowStatus) { return } diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index c1f2405c5..dc4d4f447 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -499,6 +499,23 @@ public actor HackCLIClient { return try decodeJsonOrThrow(LinearProfilesResponse.self, result: result) } + public func listLinearConnections( + profileId: String? = nil, + organizationId: String? = nil + ) async throws -> LinearConnectionsResponse { + var args = ["x", "linear", "connections", "--json"] + if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--profile", profileId]) + } + if let organizationId, + !organizationId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + args.append(contentsOf: ["--organization-id", organizationId]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearConnectionsResponse.self, result: result) + } + public func inspectLinearStatus(profileId: String? = nil) async throws -> LinearStatusResponse { var args = ["x", "linear", "status", "--json"] if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 42f5a335a..14f763aa2 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -1294,6 +1294,65 @@ public struct LinearProfilesResponse: Decodable, Hashable { } } +public struct LinearRemoteConnection: Decodable, Hashable, Identifiable { + public let id: String + public let profileId: String? + public let accountId: String? + public let accountName: String? + public let accountEmail: String? + public let authRef: String? + public let betterAuthUserId: String? + public let betterAuthOrganizationId: String? + public let betterAuthTeamId: String? + public let organizationId: String? + public let teamId: String? + public let createdAt: String + public let updatedAt: String + + public init( + id: String, + profileId: String?, + accountId: String?, + accountName: String?, + accountEmail: String?, + authRef: String?, + betterAuthUserId: String?, + betterAuthOrganizationId: String?, + betterAuthTeamId: String?, + organizationId: String?, + teamId: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.profileId = profileId + self.accountId = accountId + self.accountName = accountName + self.accountEmail = accountEmail + self.authRef = authRef + self.betterAuthUserId = betterAuthUserId + self.betterAuthOrganizationId = betterAuthOrganizationId + self.betterAuthTeamId = betterAuthTeamId + self.organizationId = organizationId + self.teamId = teamId + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct LinearConnectionsResponse: Decodable, Hashable { + public let accessControlMode: String? + public let connections: [LinearRemoteConnection] + + public init( + accessControlMode: String?, + connections: [LinearRemoteConnection] + ) { + self.accessControlMode = accessControlMode + self.connections = connections + } +} + public struct LinearStatusResponse: Decodable, Hashable { public let extensionId: String public let selectedProfile: String diff --git a/services/auth-broker/src/db/ensure-columns.ts b/services/auth-broker/src/db/ensure-columns.ts new file mode 100644 index 000000000..634fc6abd --- /dev/null +++ b/services/auth-broker/src/db/ensure-columns.ts @@ -0,0 +1,109 @@ +import { sql } from "drizzle-orm"; + +import type { createDbClient } from "../db.ts"; + +type DbClient = ReturnType; +const SQL_IDENTIFIER_REGEX = /^[a-z_][a-z0-9_]*$/i; + +export type TableColumnSpec = { + readonly name: string; + readonly definition: string; +}; + +export function createTableColumnsEnsurer(input: { + readonly db: DbClient; + readonly tableName: string; + readonly columns: readonly TableColumnSpec[]; +}) { + let promise: Promise | null = null; + return async () => { + if (!promise) { + promise = ensureTableColumns(input).catch((error) => { + promise = null; + throw error; + }); + } + await promise; + }; +} + +export async function ensureTableColumns(input: { + readonly db: DbClient; + readonly tableName: string; + readonly columns: readonly TableColumnSpec[]; +}): Promise { + const existingColumns = await listTableColumns({ + db: input.db, + tableName: input.tableName, + }); + for (const column of input.columns) { + if (existingColumns.has(column.name)) { + continue; + } + await input.db.execute( + sql.raw( + `ALTER TABLE ${quoteIdentifier(input.tableName)} ADD COLUMN ${quoteIdentifier(column.name)} ${column.definition}` + ) + ); + } +} + +async function listTableColumns(input: { + readonly db: DbClient; + readonly tableName: string; +}): Promise> { + const result = await input.db.execute(sql` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ${input.tableName} + `); + const names = new Set(); + for (const row of readResultRows(result)) { + const name = readStringField({ + record: row, + key: "column_name", + }); + if (name) { + names.add(name); + } + } + return names; +} + +function readResultRows(result: unknown): readonly Record[] { + if (Array.isArray(result)) { + return result.filter(isRecord); + } + if (!isRecord(result)) { + return []; + } + const rows = result.rows; + if (!Array.isArray(rows)) { + return []; + } + return rows.filter(isRecord); +} + +function readStringField(input: { + readonly record: Record; + readonly key: string; +}): string | null { + const value = input.record[input.key]; + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function quoteIdentifier(value: string): string { + if (!SQL_IDENTIFIER_REGEX.test(value)) { + throw new Error(`Invalid SQL identifier: ${value}`); + } + return `"${value}"`; +} diff --git a/services/auth-broker/src/modules/linear-connections/service.ts b/services/auth-broker/src/modules/linear-connections/service.ts index 12993db25..87bfedb9f 100644 --- a/services/auth-broker/src/modules/linear-connections/service.ts +++ b/services/auth-broker/src/modules/linear-connections/service.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { and, desc, eq, type SQL, sql } from "drizzle-orm"; +import { createTableColumnsEnsurer } from "../../db/ensure-columns.ts"; import { linearConnections } from "../../db/schema.ts"; import { createDbClient } from "../../db.ts"; @@ -157,13 +158,12 @@ export function createLinearConnectionStoreFromDb(input: { readonly databaseUrl: string; }): LinearConnectionStore { const db = createDbClient({ databaseUrl: input.databaseUrl }); - const ensureOwnershipColumns = createOwnershipColumnsEnsurer({ + const ensureTable = createLinearConnectionsTableEnsurer({ db, - tableName: "linear_connections", }); return { upsertConnection: async (connection) => { - await ensureOwnershipColumns(); + await ensureTable(); const connectionKey = buildConnectionKey(connection); const now = new Date(); const metadataJson = JSON.stringify( @@ -220,7 +220,7 @@ export function createLinearConnectionStoreFromDb(input: { }, listConnections: async (input = {}) => { - await ensureOwnershipColumns(); + await ensureTable(); const filters = buildListFilters({ input }); const rows = filters.length === 0 @@ -239,7 +239,7 @@ export function createLinearConnectionStoreFromDb(input: { }, resolveWebhookOwnership: async ({ profileId, organizationId }) => { - await ensureOwnershipColumns(); + await ensureTable(); const normalizedProfileId = normalizeText(profileId); if (normalizedProfileId) { const rows = await db @@ -283,6 +283,61 @@ export function createLinearConnectionStoreFromDb(input: { }; } +function createLinearConnectionsTableEnsurer(input: { + readonly db: ReturnType; +}) { + const ensureOwnershipColumns = createTableColumnsEnsurer({ + db: input.db, + tableName: "linear_connections", + columns: [ + { + name: "better_auth_organization_id", + definition: "text", + }, + { + name: "better_auth_team_id", + definition: "text", + }, + ], + }); + let promise: Promise | null = null; + return async (): Promise => { + if (!promise) { + promise = ensureLinearConnectionsTable({ db: input.db }) + .then(() => ensureOwnershipColumns()) + .catch((error) => { + promise = null; + throw error; + }); + } + await promise; + }; +} + +export async function ensureLinearConnectionsTable(input: { + readonly db: ReturnType; +}): Promise { + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS linear_connections ( + id uuid PRIMARY KEY, + connection_key text NOT NULL UNIQUE, + profile_id text, + account_id text, + account_name text, + account_email text, + auth_ref text, + better_auth_user_id text, + better_auth_organization_id text, + better_auth_team_id text, + organization_id text, + team_id text, + metadata_json text NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); +} + function buildConnectionKey(input: UpsertLinearConnectionInput): string { const profileId = normalizeText(input.profileId); if (profileId) { @@ -510,33 +565,6 @@ export function readStoredConnectionOwnership(input: { } } -function createOwnershipColumnsEnsurer(input: { - readonly db: ReturnType; - readonly tableName: "linear_connections"; -}) { - let promise: Promise | null = null; - return async () => { - promise ??= ensureOwnershipColumns(input); - await promise; - }; -} - -async function ensureOwnershipColumns(input: { - readonly db: ReturnType; - readonly tableName: "linear_connections"; -}) { - await input.db.execute( - sql.raw( - `ALTER TABLE "${input.tableName}" ADD COLUMN IF NOT EXISTS "better_auth_organization_id" text` - ) - ); - await input.db.execute( - sql.raw( - `ALTER TABLE "${input.tableName}" ADD COLUMN IF NOT EXISTS "better_auth_team_id" text` - ) - ); -} - function normalizeText(value: unknown): string | null { if (typeof value !== "string") { return null; diff --git a/services/auth-broker/src/modules/linear-sync-store/service.ts b/services/auth-broker/src/modules/linear-sync-store/service.ts index 1cbf74973..b517fcaca 100644 --- a/services/auth-broker/src/modules/linear-sync-store/service.ts +++ b/services/auth-broker/src/modules/linear-sync-store/service.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { and, desc, eq, type SQL, sql } from "drizzle-orm"; +import { createTableColumnsEnsurer } from "../../db/ensure-columns.ts"; import { linearWebhookEvents } from "../../db/schema.ts"; import { createDbClient } from "../../db.ts"; @@ -166,9 +167,8 @@ export function createLinearSyncStoreFromDb(input: { const db = createDbClient({ databaseUrl: input.databaseUrl, }); - const ensureOwnershipColumns = createOwnershipColumnsEnsurer({ + const ensureTable = createLinearWebhookEventsTableEnsurer({ db, - tableName: "linear_webhook_events", }); return { recordWebhookDelivery: async ({ @@ -190,7 +190,7 @@ export function createLinearSyncStoreFromDb(input: { organizationId, teamId, }) => { - await ensureOwnershipColumns(); + await ensureTable(); const resolvedDeliveryKey = normalizeText(deliveryKey) ?? buildDeliveryKey({ @@ -250,7 +250,7 @@ export function createLinearSyncStoreFromDb(input: { }, listWebhookDeliveries: async (input = {}) => { - await ensureOwnershipColumns(); + await ensureTable(); const filters = buildListFilters({ input }); const rows = filters.length === 0 @@ -271,7 +271,7 @@ export function createLinearSyncStoreFromDb(input: { }, getWebhookDelivery: async ({ deliveryId }) => { - await ensureOwnershipColumns(); + await ensureTable(); const rows = await db .select() .from(linearWebhookEvents) @@ -281,7 +281,7 @@ export function createLinearSyncStoreFromDb(input: { }, markWebhookDeliveryApplied: async ({ deliveryId, claimedBy }) => { - await ensureOwnershipColumns(); + await ensureTable(); const now = new Date(); const updated = await db .update(linearWebhookEvents) @@ -301,6 +301,79 @@ export function createLinearSyncStoreFromDb(input: { }; } +function createLinearWebhookEventsTableEnsurer(input: { + readonly db: ReturnType; +}) { + const ensureOwnershipColumns = createTableColumnsEnsurer({ + db: input.db, + tableName: "linear_webhook_events", + columns: [ + { + name: "better_auth_organization_id", + definition: "text", + }, + { + name: "better_auth_team_id", + definition: "text", + }, + { + name: "owner_team_id", + definition: "text", + }, + { + name: "claimed_by", + definition: "text", + }, + { + name: "applied_at", + definition: "timestamp with time zone", + }, + ], + }); + let promise: Promise | null = null; + return async (): Promise => { + if (!promise) { + promise = ensureLinearWebhookEventsTable({ db: input.db }) + .then(() => ensureOwnershipColumns()) + .catch((error) => { + promise = null; + throw error; + }); + } + await promise; + }; +} + +export async function ensureLinearWebhookEventsTable(input: { + readonly db: ReturnType; +}): Promise { + await input.db.execute(sql` + CREATE TABLE IF NOT EXISTS linear_webhook_events ( + id uuid PRIMARY KEY, + delivery_key text NOT NULL UNIQUE, + profile_id text, + project_id text, + team_id text, + issue_id text, + issue_identifier text, + event_type text, + action text, + status text NOT NULL DEFAULT 'pending', + payload_json text NOT NULL DEFAULT '{}', + apply_error text, + claimed_by text, + better_auth_user_id text, + better_auth_organization_id text, + better_auth_team_id text, + organization_id text, + owner_team_id text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + applied_at timestamptz + ) + `); +} + export function toWebhookDelivery(input: { readonly row: typeof linearWebhookEvents.$inferSelect; }): LinearWebhookDelivery { @@ -530,33 +603,6 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } -function createOwnershipColumnsEnsurer(input: { - readonly db: ReturnType; - readonly tableName: "linear_webhook_events"; -}) { - let promise: Promise | null = null; - return async () => { - promise ??= ensureOwnershipColumns(input); - await promise; - }; -} - -async function ensureOwnershipColumns(input: { - readonly db: ReturnType; - readonly tableName: "linear_webhook_events"; -}) { - await input.db.execute( - sql.raw( - `ALTER TABLE "${input.tableName}" ADD COLUMN IF NOT EXISTS "better_auth_organization_id" text` - ) - ); - await input.db.execute( - sql.raw( - `ALTER TABLE "${input.tableName}" ADD COLUMN IF NOT EXISTS "better_auth_team_id" text` - ) - ); -} - function normalizeText(value: unknown): string | null { if (typeof value !== "string") { return null; diff --git a/services/auth-broker/tests/ensure-columns.test.ts b/services/auth-broker/tests/ensure-columns.test.ts new file mode 100644 index 000000000..121cc49d1 --- /dev/null +++ b/services/auth-broker/tests/ensure-columns.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; + +import { + createTableColumnsEnsurer, + ensureTableColumns, +} from "../src/db/ensure-columns.ts"; +import type { createDbClient } from "../src/db.ts"; + +type DbClient = ReturnType; + +function createMockDb(input: { + readonly execute: (query: unknown, index: number) => Promise; +}): DbClient { + let callIndex = 0; + return { + execute: (async (query: unknown) => { + callIndex += 1; + return await input.execute(query, callIndex); + }) as DbClient["execute"], + } as DbClient; +} + +test("ensureTableColumns skips ALTER TABLE when columns already exist", async () => { + const calls: unknown[] = []; + const db = createMockDb({ + execute: (query) => { + calls.push(query); + return Promise.resolve({ + rows: [ + { column_name: "better_auth_organization_id" }, + { column_name: "better_auth_team_id" }, + ], + }); + }, + }); + + await ensureTableColumns({ + db, + tableName: "linear_connections", + columns: [ + { name: "better_auth_organization_id", definition: "text" }, + { name: "better_auth_team_id", definition: "text" }, + ], + }); + + expect(calls).toHaveLength(1); +}); + +test("ensureTableColumns adds only missing columns", async () => { + const calls: unknown[] = []; + const db = createMockDb({ + execute: (query, index) => { + calls.push(query); + if (index === 1) { + return Promise.resolve({ + rows: [{ column_name: "better_auth_organization_id" }], + }); + } + return Promise.resolve({ rows: [] }); + }, + }); + + await ensureTableColumns({ + db, + tableName: "linear_connections", + columns: [ + { name: "better_auth_organization_id", definition: "text" }, + { name: "better_auth_team_id", definition: "text" }, + ], + }); + + expect(calls).toHaveLength(2); +}); + +test("createTableColumnsEnsurer retries after a failed migration attempt", async () => { + let alterAttempts = 0; + const db = createMockDb({ + execute: (_query, index) => { + if (index === 1 || index === 3) { + return Promise.resolve({ rows: [] }); + } + alterAttempts += 1; + if (alterAttempts === 1) { + return Promise.reject(new Error("temporary migration failure")); + } + return Promise.resolve({ rows: [] }); + }, + }); + const ensure = createTableColumnsEnsurer({ + db, + tableName: "linear_connections", + columns: [{ name: "better_auth_team_id", definition: "text" }], + }); + + await expect(ensure()).rejects.toThrow("temporary migration failure"); + await expect(ensure()).resolves.toBeUndefined(); + expect(alterAttempts).toBe(2); +}); diff --git a/services/auth-broker/tests/linear-store-schema.test.ts b/services/auth-broker/tests/linear-store-schema.test.ts new file mode 100644 index 000000000..fc86ca8d0 --- /dev/null +++ b/services/auth-broker/tests/linear-store-schema.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; + +import type { createDbClient } from "../src/db.ts"; +import { ensureLinearConnectionsTable } from "../src/modules/linear-connections/service.ts"; +import { ensureLinearWebhookEventsTable } from "../src/modules/linear-sync-store/service.ts"; + +type DbClient = ReturnType; + +function createMockDb(input: { + readonly execute: (query: unknown, index: number) => Promise; +}): DbClient { + let callIndex = 0; + return { + execute: (async (query: unknown) => { + callIndex += 1; + return await input.execute(query, callIndex); + }) as DbClient["execute"], + } as DbClient; +} + +function stringifyQuery(query: unknown): string { + if (query && typeof query === "object" && "queryChunks" in query) { + const chunks = (query as { queryChunks: unknown[] }).queryChunks; + return chunks + .map((chunk) => { + if (chunk && typeof chunk === "object" && "value" in chunk) { + const value = (chunk as { value: unknown }).value; + if (Array.isArray(value)) { + return value.join(""); + } + if (typeof value === "string") { + return value; + } + } + return String(chunk); + }) + .join(" "); + } + return String(query); +} + +test("ensureLinearConnectionsTable creates the broker connection table", async () => { + const queries: string[] = []; + const db = createMockDb({ + execute: (query) => { + queries.push(stringifyQuery(query)); + return Promise.resolve({ rows: [] }); + }, + }); + + await ensureLinearConnectionsTable({ db }); + + expect(queries).toHaveLength(1); + expect(queries[0]).toContain("CREATE TABLE IF NOT EXISTS linear_connections"); + expect(queries[0]).toContain("connection_key text NOT NULL UNIQUE"); + expect(queries[0]).toContain("better_auth_organization_id text"); +}); + +test("ensureLinearWebhookEventsTable creates the broker webhook delivery table", async () => { + const queries: string[] = []; + const db = createMockDb({ + execute: (query) => { + queries.push(stringifyQuery(query)); + return Promise.resolve({ rows: [] }); + }, + }); + + await ensureLinearWebhookEventsTable({ db }); + + expect(queries).toHaveLength(1); + expect(queries[0]).toContain( + "CREATE TABLE IF NOT EXISTS linear_webhook_events" + ); + expect(queries[0]).toContain("delivery_key text NOT NULL UNIQUE"); + expect(queries[0]).toContain("applied_at timestamptz"); +}); diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index fb30b1cc5..1be1948c6 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -362,6 +362,64 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return catalog.selectedProfileMissing ? 1 : 0; }, }, + { + name: "connections", + summary: "List broker-owned Linear connections for the current Hack account", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseConnectionsArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const connections = await listLinearConnections({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + organizationId: parsed.value.organizationId, + }); + if (!connections.ok) { + ctx.logger.error({ message: connections.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(connections.data, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear broker connections", + entries: [ + ["access_control_mode", connections.data.accessControlMode ?? ""], + ["connections", String(connections.data.connections.length)], + ], + }); + if (connections.data.connections.length === 0) { + await display.panel({ + title: "No broker-owned Linear connections", + lines: [ + "No Linear accounts are persisted on this Hack account yet, or this account does not have access to them.", + ], + tone: "info", + }); + return 0; + } + + await display.table({ + columns: ["Profile", "Account", "Email", "Owner", "Updated"], + rows: connections.data.connections.map((connection) => [ + connection.profileId ?? "", + connection.accountName ?? connection.accountId ?? "", + connection.accountEmail ?? "", + describeLinearConnectionOwner(connection), + connection.updatedAt, + ]), + }); + return 0; + }, + }, { name: "use", summary: "Set global default Linear profile", @@ -3218,6 +3276,28 @@ type BrokerListDeliveriesPayload = { readonly deliveries: readonly LinearDeliverySummary[]; }; +type LinearConnectionSummary = { + readonly id: string; + readonly profileId: string | null; + readonly accountId: string | null; + readonly accountName: string | null; + readonly accountEmail: string | null; + readonly authRef: string | null; + readonly betterAuthUserId: string | null; + readonly betterAuthOrganizationId: string | null; + readonly betterAuthTeamId: string | null; + readonly organizationId: string | null; + readonly teamId: string | null; + readonly metadata: Record; + readonly createdAt: string; + readonly updatedAt: string; +}; + +type BrokerListConnectionsPayload = { + readonly accessControlMode?: string; + readonly connections: readonly LinearConnectionSummary[]; +}; + type BrokerApplyDeliveryPayload = { readonly profileId?: string; readonly deliveryId: string; @@ -5712,6 +5792,61 @@ async function fetchJson(input: { } } +async function listLinearConnections(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; + readonly organizationId?: string; +}): Promise< + | { readonly ok: true; readonly data: BrokerListConnectionsPayload } + | { readonly ok: false; readonly error: string } +> { + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + if (!brokerAuth.ok) { + return brokerAuth; + } + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const url = new URL( + "/v1/auth/linear/connections", + `${brokerConfig.baseUrl}/` + ); + if (input.profileId) { + url.searchParams.set("profileId", input.profileId); + } + if (input.organizationId) { + url.searchParams.set("organizationId", input.organizationId); + } + const response = await fetchJson({ + url: url.toString(), + init: { + headers: brokerAuth.headers, + }, + }); + if (!response.ok) { + return { + ok: false, + error: normalizeBrokerProtectedLinearError({ + error: response.error, + profileId: brokerAuth.profileId, + }), + }; + } + const payload = parseLinearConnectionsPayload({ + payload: response.value, + }); + if (!payload) { + return { + ok: false, + error: "Linear connection payload was invalid.", + }; + } + return { ok: true, data: payload }; +} + async function listLinearDeliveries(input: { readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; readonly profileId?: string; @@ -6127,6 +6262,60 @@ function normalizeBrokerProtectedLinearError(input: { return error; } +function parseLinearConnectionsPayload(input: { + readonly payload: unknown; +}): BrokerListConnectionsPayload | null { + if (!isRecord(input.payload)) { + return null; + } + if (!Array.isArray(input.payload.connections)) { + return null; + } + const connections = input.payload.connections + .map((value) => parseLinearConnectionSummary({ value })) + .filter((value): value is LinearConnectionSummary => value !== null); + return { + ...(typeof input.payload.accessControlMode === "string" + ? { accessControlMode: input.payload.accessControlMode } + : {}), + connections, + }; +} + +function parseLinearConnectionSummary(input: { + readonly value: unknown; +}): LinearConnectionSummary | null { + if (!isRecord(input.value)) { + return null; + } + const id = readOptionalString(input.value.id); + const createdAt = readOptionalString(input.value.createdAt); + const updatedAt = readOptionalString(input.value.updatedAt); + if (!id || !createdAt || !updatedAt) { + return null; + } + return { + id, + profileId: readOptionalString(input.value.profileId), + accountId: readOptionalString(input.value.accountId), + accountName: readOptionalString(input.value.accountName), + accountEmail: readOptionalString(input.value.accountEmail), + authRef: readOptionalString(input.value.authRef), + betterAuthUserId: readOptionalString(input.value.betterAuthUserId), + betterAuthOrganizationId: readOptionalString( + input.value.betterAuthOrganizationId + ), + betterAuthTeamId: readOptionalString(input.value.betterAuthTeamId), + organizationId: readOptionalString(input.value.organizationId), + teamId: readOptionalString(input.value.teamId), + metadata: isRecord(input.value.metadata) + ? input.value.metadata + : ({} as Record), + createdAt, + updatedAt, + }; +} + function isLinearBrokerProtectedAuthError(input: { readonly error: string; }): boolean { @@ -6140,6 +6329,19 @@ function isLinearBrokerProtectedAuthError(input: { ); } +function describeLinearConnectionOwner(input: LinearConnectionSummary): string { + if (input.betterAuthTeamId) { + return `team:${input.betterAuthTeamId}`; + } + if (input.betterAuthOrganizationId) { + return `org:${input.betterAuthOrganizationId}`; + } + if (input.betterAuthUserId) { + return `user:${input.betterAuthUserId}`; + } + return "legacy"; +} + function parseLinearDeliveriesListPayload(input: { readonly payload: unknown; readonly profileId?: string; @@ -6644,6 +6846,12 @@ type ProfilesArgs = { json: boolean; }; +type ConnectionsArgs = { + profileId?: string; + organizationId?: string; + json: boolean; +}; + type UseArgs = { profileId: string; }; @@ -7249,6 +7457,36 @@ function parseProfilesArgs(input: { return { ok: true, value }; } +function parseConnectionsArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ConnectionsArgs } + | { readonly ok: false; readonly error: string } { + const value: ConnectionsArgs = { json: false }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + "organization-id": "organizationId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + function parseUseArgs(input: { readonly args: readonly string[]; }): @@ -7624,6 +7862,7 @@ export const __testOnly = { parseAutosyncSubscriptionsArgs, parseAssigneeMappingsArgs, parseConnectArgs, + parseConnectionsArgs, parseDeliveriesArgs, parseRunAutosyncArgs, parseProjectBindArgs, diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index ec95b22ab..5c1d3a7ba 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -160,6 +160,29 @@ test("parseSyncProjectArgs parses owner filter and limits", () => { }); }); +test("parseConnectionsArgs parses broker connection filters", () => { + const parsed = __testOnly.parseConnectionsArgs({ + args: [ + "--profile", + "work", + "--organization-id", + "shared-org", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + organizationId: "shared-org", + json: true, + }); +}); + test("parseRunAutosyncArgs parses profile, route filters, and limit", () => { const parsed = __testOnly.parseRunAutosyncArgs({ args: [ From f58c5d126cdd9ad88ec3136f55506697f369a9bd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 14:50:33 -0500 Subject: [PATCH 24/54] Add Linear browser handoff design and plan docs --- ...er-handoff-and-connection-repair-design.md | 199 ++++++++++ ...wser-handoff-and-connection-repair-plan.md | 368 ++++++++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-design.md create mode 100644 docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-plan.md diff --git a/docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-design.md b/docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-design.md new file mode 100644 index 000000000..b5c76aace --- /dev/null +++ b/docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-design.md @@ -0,0 +1,199 @@ +# Linear Browser Handoff And Connection Repair Design + +## Context + +Hack account login now uses a broker-backed browser handoff flow with a dedicated auth shell, Better Auth session ownership, and desktop deep-link return. GitHub OAuth uses the same model for provider connection, but Linear still uses an older callback path at `/linear/callback` with a bespoke completion page and weaker error handling. + +That mismatch is now causing two concrete failures: + +1. Production Linear reconnects can fail with a host-level `502` instead of a Hack-branded error page. +2. Desktop Linear settings can get stuck in `Waiting for browser auth` because the browser callback contract is weaker than the GitHub path. + +A second constraint is now explicit from product UX work: the app should not force users to reason about `remote connection` versus `local token/profile` unless they are using detailed diagnostics. Default UI should collapse to `connected` or `not connected` and recover smoothly when a repair is needed. + +## Goals + +- Unify Linear browser callback behavior with the GitHub OAuth contract. +- Ensure Linear callback failures always render a Hack page instead of leaking raw infrastructure errors. +- Persist Hack-account-owned Linear connection state reliably in the broker. +- Keep local token/profile claim behavior for the current sync engine, but hide that implementation detail from normal UI. +- Make desktop and CLI stop hanging on browser flows that have already failed or gone stale. + +## Non-Goals + +- Replacing the current local Linear token envelope with broker-primary token execution. +- Solving full cross-device provider-token portability in this pass. +- Reworking Linear’s upstream authorization/install UX. + +## Approaches Considered + +### 1. Full callback unification with GitHub pattern (recommended) + +Linear start flow stores the same desktop redirect context as GitHub. The callback exchanges the token, persists broker-owned connection state, marks the flow complete, and renders a Hack completion/error page with an `Open Hack` action. Desktop and CLI still claim local token state from the flow, but the browser contract becomes authoritative for success and failure. + +Pros: +- Fixes the live `502` problem at the root. +- Makes GitHub and Linear auth behavior consistent. +- Preserves current sync-engine assumptions. +- Gives a clean base for later token portability work. + +Cons: +- Requires touching broker, CLI, and macOS flow handling in one pass. + +### 2. Patch the existing Linear callback only + +Keep Linear on its older contract, add top-level exception handling, and restyle the page to match the newer auth shell. + +Pros: +- Smaller code change. + +Cons: +- Preserves two OAuth callback models. +- Leaves desktop/browser state transitions more fragile. +- Does not simplify future maintenance. + +### 3. Make browser callback remote-only and defer local access to later repair + +Treat browser callback as remote connection creation only. Desktop later seeds local token state from the broker. + +Pros: +- Architecturally cleaner long term. + +Cons: +- Too much scope for the current fix. +- Pulls provider-token portability into an already-broken live path. + +## Recommended Design + +Adopt approach 1: Linear should follow the same browser handoff model as GitHub, with remote connection persistence in the broker and local token claim still supported for this Mac. + +## Architecture + +### Broker Flow Lifecycle + +#### Start + +`/v1/auth/linear/start` should continue returning: +- `authorizeUrl` +- `pollUrl` +- `deviceCode` +- `flowId` +- `expiresAt` + +It should additionally persist the same desktop redirect context pattern used by GitHub so the callback page can reliably return focus to Hack. + +#### Callback + +`/linear/callback` becomes a strict browser-completion route: +- validate flow state +- exchange authorization code for token +- fetch Linear identity +- resolve Better Auth linkage +- persist/update `linear_connections` +- mark flow complete with claimable local token data +- render Hack-branded completion page with `Open Hack` + +The route must never surface an uncaught exception to Cloudflare. + +#### Claim + +Desktop and CLI continue using the existing poll-and-claim behavior so the current local sync engine keeps working. This preserves compatibility while letting the browser flow become more reliable. + +### State Model + +Internally, Linear connection state remains split into: +- broker-owned Hack-account connection state +- local claimed token/profile state for the current Mac + +Externally, normal UI should flatten this into a simple connection story: +- `Connected` +- `Connecting` +- `Needs attention` +- `Not connected` + +Remote/local detail should only appear in diagnostics, CLI JSON, or explicit recovery messaging. + +## UX Rules + +### Browser Pages + +Linear callback pages should use the same design system as GitHub auth pages: +- dark, flat full-page shell +- centered `HACK` +- minimal copy +- outline actions only +- no bubbly cards or heavy explanatory blocks + +There are two page classes: +- handoff pages: one primary action (`Continue with Linear` or `Open Hack`) +- recovery pages: one short status line plus `Try again` or `Open Hack` + +### Desktop + +Desktop should stop presenting the browser flow as indefinitely pending. + +If broker flow state becomes: +- `complete` or `claimed`: show connected state +- `error`: show compact repair state +- `expired`: show reconnect state + +Default desktop copy should avoid exposing remote/local implementation detail unless recovery is needed. Example: +- normal: `Connected` +- repair: `Connected on Hack, local access needs repair` + +### CLI + +Human-facing CLI output should also stay simple by default, while `--json` continues to expose remote/local specifics for debugging and automation. + +## Failure Handling + +The Linear callback handler needs a top-level failure boundary. + +Any unexpected exception should: +- mark the flow errored when possible +- return a Hack-branded `Connection failed` page +- avoid raw `502` host errors + +Expected failures should become explicit Hack pages: +- missing state +- expired session +- provider denied access +- token exchange failed +- identity lookup failed +- connection persistence/claim prep failed + +## Testing + +### Broker + +Add coverage for: +- successful Linear callback completion +- token exchange failure renders Hack error page +- unexpected callback exception renders Hack error page instead of surfacing host failure +- callback page includes `Open Hack` when desktop redirect is present +- persisted connection row is written before flow completion is reported + +### CLI/Desktop + +Add coverage for: +- Linear start flow carrying desktop redirect context +- poll loop stopping cleanly on `error` and `expired` +- UI state mapping from broker flow status to compact connected/repair states + +### Live Verification + +After deploy: +- sign in to Hack +- revoke existing Linear install +- reconnect Linear +- confirm browser callback lands on Hack-branded completion page +- confirm app returns from browser and shows connected state +- confirm `hack x linear connections --json` shows persisted remote connection + +## Rollout + +1. Unify broker Linear callback contract and error handling. +2. Update desktop/CLI to use the same callback semantics as GitHub. +3. Simplify Linear settings state model to binary default UX with repair-only detail. +4. Deploy and re-run live reconnect flow. +5. Revisit full provider-token portability after the callback contract is stable. diff --git a/docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-plan.md b/docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-plan.md new file mode 100644 index 000000000..69dc393d7 --- /dev/null +++ b/docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-plan.md @@ -0,0 +1,368 @@ +# Linear Browser Handoff And Connection Repair Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Unify Linear OAuth with the GitHub browser handoff contract, eliminate raw callback failures, and make desktop/CLI show a simple connected-or-not-connected state with repair paths. + +**Architecture:** Keep the current broker-owned remote connection plus local claimed token model, but make the browser callback authoritative for success and failure. Linear start and callback should carry desktop redirect context, persist remote connection state before flow completion, and always render a Hack-branded completion or error page instead of leaking infrastructure errors. + +**Tech Stack:** Bun, Elysia, Better Auth, macOS SwiftUI, Hack CLI, Railway + +--- + +### Task 1: Capture current Linear callback behavior with failing broker tests + +**Files:** +- Modify: `services/auth-broker/tests/index.test.ts` +- Modify: `services/auth-broker/tests/session-auth.test.ts` +- Create: `services/auth-broker/tests/linear-callback-routes.test.ts` + +**Step 1: Write the failing test** + +Add focused tests for: +- successful `GET /linear/callback` with a valid mocked flow returns Hack-branded HTML and `200` +- callback exception returns Hack-branded HTML and non-`502` host-safe error page +- callback with desktop redirect context includes `Open Hack` + +**Step 2: Run test to verify it fails** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts +``` + +Expected: FAIL because current Linear callback does not yet share the GitHub callback contract or hard failure boundary. + +**Step 3: Write minimal implementation** + +Touch only the Linear callback route and service until the test targets exist. + +**Step 4: Run test to verify it passes** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/tests/index.test.ts services/auth-broker/tests/session-auth.test.ts services/auth-broker/tests/linear-callback-routes.test.ts services/auth-broker/src/modules/linear-oauth/service.ts services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts services/auth-broker/src/linear.ts services/auth-broker/src/flow-store.ts services/auth-broker/src/types.ts +git commit -m "test: cover linear callback handoff contract" +``` + +### Task 2: Make Linear start flow carry desktop redirect context like GitHub + +**Files:** +- Modify: `services/auth-broker/src/modules/linear-oauth/model.ts` +- Modify: `services/auth-broker/src/modules/linear-oauth/service.ts` +- Modify: `services/auth-broker/src/flow-store.ts` +- Modify: `services/auth-broker/src/types.ts` +- Test: `services/auth-broker/tests/linear-callback-routes.test.ts` + +**Step 1: Write the failing test** + +Add a test that starts a Linear flow with a desktop redirect URL and verifies the persisted flow stores it. + +**Step 2: Run test to verify it fails** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts +``` + +Expected: FAIL because Linear start query/model does not yet mirror GitHub desktop redirect handling. + +**Step 3: Write minimal implementation** + +- Accept `desktopRedirectUrl` in Linear start query +- Normalize it using the same `hack:` / `hack-dev:` rules as GitHub +- Persist it on the flow + +**Step 4: Run test to verify it passes** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/src/modules/linear-oauth/model.ts services/auth-broker/src/modules/linear-oauth/service.ts services/auth-broker/src/flow-store.ts services/auth-broker/src/types.ts services/auth-broker/tests/linear-callback-routes.test.ts +git commit -m "feat: add desktop redirect support to linear oauth flows" +``` + +### Task 3: Unify Linear callback completion page and hard error handling + +**Files:** +- Modify: `services/auth-broker/src/modules/linear-oauth/service.ts` +- Modify: `services/auth-broker/src/modules/better-auth/shell-plugin.ts` (only if shared rendering helpers are needed) +- Test: `services/auth-broker/tests/linear-callback-routes.test.ts` + +**Step 1: Write the failing test** + +Add tests for: +- `Open Hack` action present on successful Linear callback when desktop redirect exists +- provider and unexpected failures render Hack HTML pages instead of raw host failures + +**Step 2: Run test to verify it fails** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts +``` + +Expected: FAIL + +**Step 3: Write minimal implementation** + +- Add top-level try/catch around Linear callback processing +- Reuse GitHub-style deep-link building semantics for `Open Hack` +- Replace old bespoke callback HTML with the same dark minimal callback language used elsewhere + +**Step 4: Run test to verify it passes** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/src/modules/linear-oauth/service.ts services/auth-broker/src/modules/better-auth/shell-plugin.ts services/auth-broker/tests/linear-callback-routes.test.ts +git commit -m "feat: unify linear callback browser handoff" +``` + +### Task 4: Persist remote Linear connection before reporting completion + +**Files:** +- Modify: `services/auth-broker/src/modules/linear-oauth/service.ts` +- Modify: `services/auth-broker/src/modules/linear-connections/service.ts` +- Test: `services/auth-broker/tests/linear-store-schema.test.ts` +- Test: `services/auth-broker/tests/linear-callback-routes.test.ts` + +**Step 1: Write the failing test** + +Add a test that verifies successful callback writes a connection row and only then exposes completed flow state. + +**Step 2: Run test to verify it fails** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts tests/linear-store-schema.test.ts +``` + +Expected: FAIL + +**Step 3: Write minimal implementation** + +Ensure the callback path persists or updates the connection row before final completion response generation, while still handling persistence failure as a Hack-rendered flow error instead of a host crash. + +**Step 4: Run test to verify it passes** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts tests/linear-store-schema.test.ts +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/src/modules/linear-oauth/service.ts services/auth-broker/src/modules/linear-connections/service.ts services/auth-broker/tests/linear-callback-routes.test.ts services/auth-broker/tests/linear-store-schema.test.ts +git commit -m "feat: persist linear connections during callback completion" +``` + +### Task 5: Make CLI Linear flow use the unified desktop redirect contract + +**Files:** +- Modify: `src/control-plane/extensions/linear/commands.ts` +- Modify: `src/control-plane/extensions/linear/auth.ts` +- Test: `tests/linear-commands.test.ts` + +**Step 1: Write the failing test** + +Add a test that `hack x linear oauth-connect --json` includes or forwards the desktop redirect context expected by the new broker contract. + +**Step 2: Run test to verify it fails** + +Run: +```bash +bun test tests/linear-commands.test.ts +``` + +Expected: FAIL + +**Step 3: Write minimal implementation** + +Update the CLI Linear auth path to pass the desktop redirect URL consistently and preserve current poll-and-claim semantics. + +**Step 4: Run test to verify it passes** + +Run: +```bash +bun test tests/linear-commands.test.ts +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/control-plane/extensions/linear/commands.ts src/control-plane/extensions/linear/auth.ts tests/linear-commands.test.ts +git commit -m "feat: align linear cli flow with browser handoff contract" +``` + +### Task 6: Make macOS Linear settings stop hanging on stale browser state + +**Files:** +- Modify: `apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` +- Test: `apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift` + +**Step 1: Write the failing test** + +Add or extend a test for Linear auth status mapping so `error`, `expired`, and `complete` broker statuses stop rendering as indefinite `Waiting for browser auth`. + +**Step 2: Run test to verify it fails** + +Run: +```bash +swift test --package-path apps/macos --filter LinearSyncUXModelTests +``` + +Expected: FAIL + +**Step 3: Write minimal implementation** + +- Map broker flow states to compact user-facing states +- Default UI remains binary and calm +- Only repair states expose extra detail such as `Reconnect to repair local access` + +**Step 4: Run test to verify it passes** + +Run: +```bash +swift test --package-path apps/macos --filter LinearSyncUXModelTests +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift +git commit -m "feat: simplify linear desktop connection state" +``` + +### Task 7: Polish the Linear account page and callback shell to stay minimal + +**Files:** +- Modify: `services/auth-broker/src/modules/better-auth/shell-plugin.ts` +- Modify: `services/auth-broker/src/modules/linear-oauth/service.ts` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` + +**Step 1: Write the failing test** + +Add or update HTML snapshot assertions for the Linear callback page so it uses the same dark minimal shell style as Hack/GitHub auth pages. + +**Step 2: Run test to verify it fails** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts tests/index.test.ts +``` + +Expected: FAIL + +**Step 3: Write minimal implementation** + +- Remove leftover Linear-specific bubbly/legacy completion UI +- Keep one compact action and one terse status line +- Remove passive explanatory clutter from desktop Linear settings where the new flow state makes it unnecessary + +**Step 4: Run test to verify it passes** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts tests/index.test.ts +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add services/auth-broker/src/modules/better-auth/shell-plugin.ts services/auth-broker/src/modules/linear-oauth/service.ts apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift services/auth-broker/tests/linear-callback-routes.test.ts services/auth-broker/tests/index.test.ts +git commit -m "style: unify linear auth and connection ui" +``` + +### Task 8: Run full verification and deploy broker + +**Files:** +- Modify: `services/auth-broker/README.md` (if callback behavior or repair guidance changed) +- Modify: `docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-design.md` (only if implementation changed design assumptions) + +**Step 1: Run focused broker tests** + +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts tests/index.test.ts tests/linear-store-schema.test.ts tests/session-auth.test.ts +``` + +Expected: PASS + +**Step 2: Run desktop and CLI focused tests** + +Run: +```bash +bun test tests/linear-commands.test.ts +swift test --package-path apps/macos --filter LinearSyncUXModelTests +``` + +Expected: PASS + +**Step 3: Run repo verification gates** + +Run: +```bash +bun run build +bun run typecheck +bun run test +bun run check +hack ps +``` + +Expected: PASS, with only pre-existing legacy complexity warnings if any + +**Step 4: Deploy auth-broker and run live smoke checks** + +Run: +```bash +railway up --service auth-broker +curl -sS https://auth.hack.broker/health | jq . +curl -i -sS https://auth.hack.broker/auth | sed -n '1,40p' +``` + +Expected: +- Railway deploy succeeds +- `/health` returns `ok` +- Linear callback and auth shell return Hack-branded pages, not host 502s + +**Step 5: Commit** + +```bash +git add services/auth-broker/README.md docs/plans/2026-03-07-linear-browser-handoff-and-connection-repair-design.md +git commit -m "docs: document unified linear browser handoff" +``` From 5d70dfe81c1edf1ef5721153ea543791654ba144 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 19:07:50 -0500 Subject: [PATCH 25/54] Enable hack linear extension --- .hack/hack.config.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.hack/hack.config.json b/.hack/hack.config.json index 60ba46716..43867b9e4 100644 --- a/.hack/hack.config.json +++ b/.hack/hack.config.json @@ -6,6 +6,9 @@ "extensions": { "dance.hack.tickets": { "enabled": true + }, + "dance.hack.linear": { + "enabled": true } }, "gateway": { From 29e7758b6db50eeb77bc55e64be64a2eb8dd850a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 16:14:04 -0500 Subject: [PATCH 26/54] Unify Linear browser handoff and repair callback flow --- apps/macos/App/HackDesktopApp.swift | 5 +- apps/macos/App/Info.plist | 2 + .../DashboardFeature/DashboardModel.swift | 73 +++ .../SettingsOverlayView.swift | 482 ++++++++++------- .../HackCLIService/HackCLIClient.swift | 7 + .../src/modules/linear-connections/service.ts | 2 + .../src/modules/linear-oauth/model.ts | 1 + .../src/modules/linear-oauth/service.ts | 494 ++++++++++++------ services/auth-broker/tests/index.test.ts | 355 +++++++++++++ 9 files changed, 1073 insertions(+), 348 deletions(-) diff --git a/apps/macos/App/HackDesktopApp.swift b/apps/macos/App/HackDesktopApp.swift index 2e7a264b4..724d2d858 100644 --- a/apps/macos/App/HackDesktopApp.swift +++ b/apps/macos/App/HackDesktopApp.swift @@ -110,7 +110,8 @@ struct HackDesktopApp: App { private func handleIncomingDeepLink(url: URL) { let handledHackAuth = model.ingestHackAuthDeepLink(url: url) let handledGitHub = model.ingestGitHubOAuthDeepLink(url: url) - guard handledHackAuth || handledGitHub else { return } + let handledLinear = model.ingestLinearOAuthDeepLink(url: url) + guard handledHackAuth || handledGitHub || handledLinear else { return } #if os(macOS) NSApp.activate(ignoringOtherApps: true) @@ -119,7 +120,7 @@ struct HackDesktopApp: App { name: .hackSettingsRequested, object: nil, userInfo: [ - "pane": handledHackAuth ? "account" : "github", + "pane": handledHackAuth ? "account" : (handledLinear ? "linear" : "github"), ] ) } diff --git a/apps/macos/App/Info.plist b/apps/macos/App/Info.plist index f69bd13e3..224217e8e 100644 --- a/apps/macos/App/Info.plist +++ b/apps/macos/App/Info.plist @@ -29,6 +29,8 @@ $(HACK_URL_SCHEME)://auth/complete HackGitHubOAuthCallbackURL $(HACK_URL_SCHEME)://auth/github/callback + HackLinearOAuthCallbackURL + $(HACK_URL_SCHEME)://auth/linear/callback CFBundlePackageType APPL CFBundleShortVersionString diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 2f05df1b3..b65e95925 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -69,6 +69,18 @@ public struct GitHubOAuthDeepLinkContext: Hashable { } } +public struct LinearOAuthDeepLinkContext: Hashable { + public let flowId: String + public let profileId: String? + public let status: String? + + public init(flowId: String, profileId: String?, status: String?) { + self.flowId = flowId + self.profileId = profileId + self.status = status + } +} + @Observable @MainActor public final class DashboardModel { @@ -93,6 +105,7 @@ public final class DashboardModel { public var errorMessage: String? = nil public var statusMessage: String? = nil public private(set) var githubOAuthDeepLinkContext: GitHubOAuthDeepLinkContext? = nil + public private(set) var linearOAuthDeepLinkContext: LinearOAuthDeepLinkContext? = nil public var isRefreshing = false public private(set) var projectLifecycleActions: [String: ProjectLifecycleAction] = [:] public private(set) var globalLifecycleAction: GlobalLifecycleAction? = nil @@ -1233,6 +1246,30 @@ public final class DashboardModel { githubOAuthDeepLinkContext = nil } + @discardableResult + public func ingestLinearOAuthDeepLink(url: URL) -> Bool { + guard let context = parseLinearOAuthDeepLink(url: url) else { + return false + } + linearOAuthDeepLinkContext = context + if let profileId = context.profileId { + statusMessage = "Linear callback received for profile \(profileId). Finalizing…" + } else { + statusMessage = "Linear callback received. Finalizing…" + } + return true + } + + public func clearLinearOAuthDeepLink(flowId: String? = nil) { + guard let current = linearOAuthDeepLinkContext else { + return + } + if let flowId, current.flowId != flowId { + return + } + linearOAuthDeepLinkContext = nil + } + private func isHackAuthCompletionDeepLink(url: URL) -> Bool { guard isRegisteredHackDeepLinkScheme(url.scheme) else { return false @@ -1284,6 +1321,42 @@ public final class DashboardModel { ) } + private func parseLinearOAuthDeepLink(url: URL) -> LinearOAuthDeepLinkContext? { + guard isRegisteredHackDeepLinkScheme(url.scheme) else { + return nil + } + + let host = url.host?.lowercased() ?? "" + let path = normalizedPath(url.path) + let isLinearCallbackRoute = + (host == "auth" && path == "/linear/callback") + || (host == "linear" && path == "/callback") + guard isLinearCallbackRoute else { + return nil + } + + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let items = components.queryItems + else { + return nil + } + + let flowId = + normalizedQueryValue(named: "flowId", items: items) + ?? normalizedQueryValue(named: "flow_id", items: items) + guard let flowId else { + return nil + } + + return LinearOAuthDeepLinkContext( + flowId: flowId, + profileId: normalizedQueryValue(named: "profileId", items: items) + ?? normalizedQueryValue(named: "profile", items: items), + status: normalizedQueryValue(named: "status", items: items) + ) + } + private func isRegisteredHackDeepLinkScheme(_ scheme: String?) -> Bool { guard let normalizedScheme = scheme? diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index 21890aebd..6356066da 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -2849,6 +2849,8 @@ private struct LinearExtensionSettingsView: View { @State private var isAuthenticating = false @State private var authPollingTask: Task? = nil @State private var authFlowStatus: LinearOAuthFlowStatusResponse? = nil + @State private var activeAuthFlowId: String? = nil + @State private var activeAuthStatusURL: String? = nil @State private var disconnectingLinearProfiles: Set = [] @State private var assigneeMappings: [LinearAssigneeMapping] = [] @State private var assigneeMappingProfile = "" @@ -2872,11 +2874,16 @@ private struct LinearExtensionSettingsView: View { ) GlassCard(title: "Extension status", systemImage: "line.3.horizontal.decrease.circle") { HStack(alignment: .center, spacing: 8) { - StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) StatusPill( - text: "\(remoteLinearConnections.count) remote account\(remoteLinearConnections.count == 1 ? "" : "s")", - tone: remoteLinearConnections.isEmpty ? .neutral : .good + text: linearConnectionStateLabel, + tone: linearConnectionStateTone ) + if !remoteLinearConnections.isEmpty { + StatusPill( + text: "\(remoteLinearConnections.count) account\(remoteLinearConnections.count == 1 ? "" : "s")", + tone: .neutral + ) + } if !resolvedDefaultProfile.isEmpty { StatusPill( text: "Default profile: \(displayNameForRemoteProfileId(resolvedDefaultProfile))", @@ -2936,7 +2943,7 @@ private struct LinearExtensionSettingsView: View { if model.hackAccountState?.authenticated == true { if remoteLinearConnections.isEmpty && localOnlyLinearProfiles.isEmpty { - Text("No Hack-account Linear connections yet.") + Text("No connected accounts yet.") .font(.mono(.caption)) .foregroundStyle(.secondary) } @@ -2962,7 +2969,7 @@ private struct LinearExtensionSettingsView: View { StatusPill(text: "Default profile", tone: .good) } StatusPill( - text: localProfile == nil ? "Needs local token on this Mac" : "Saved on this Mac", + text: localProfile == nil ? "Needs attention" : "Connected", tone: localProfile == nil ? .warn : .good ) Spacer() @@ -2983,7 +2990,7 @@ private struct LinearExtensionSettingsView: View { } } label: { Label( - localProfile == nil ? "Link this Mac" : "Reconnect", + localProfile == nil ? "Finish setup" : "Reconnect", systemImage: "arrow.clockwise.circle" ) } @@ -2996,7 +3003,7 @@ private struct LinearExtensionSettingsView: View { Button(role: .destructive) { Task { await disconnectLinearProfile(localProfile.id) } } label: { - Label("Remove local token", systemImage: "trash") + Label("Remove access", systemImage: "trash") } .adaptiveToolbarButton() .disabled( @@ -3038,7 +3045,7 @@ private struct LinearExtensionSettingsView: View { if !localOnlyLinearProfiles.isEmpty { VStack(alignment: .leading, spacing: 8) { - Text("Local-only on this Mac") + Text("Needs attention") .font(.mono(.caption)) .foregroundStyle(.secondary) ForEach(localOnlyLinearProfiles, id: \.id) { profile in @@ -3048,7 +3055,7 @@ private struct LinearExtensionSettingsView: View { Text(linearAccountLabel(profile: profile, status: profileStatus) ?? profile.id) .font(.mono(.subheadline, weight: .semibold)) StatusPill(text: "Profile \(profile.id)", tone: .neutral) - StatusPill(text: "Not linked to Hack account yet", tone: .warn) + StatusPill(text: "Needs attention", tone: .warn) Spacer() Button { Task { @@ -3058,7 +3065,7 @@ private struct LinearExtensionSettingsView: View { ) } } label: { - Label("Link account", systemImage: "plus.circle") + Label("Repair connection", systemImage: "arrow.clockwise.circle") } .adaptiveToolbarButtonProminent() .disabled( @@ -3079,227 +3086,235 @@ private struct LinearExtensionSettingsView: View { } } } else if linearProfiles.isEmpty { - Text("Sign in to Hack to inspect remote Linear connections.") + Text("Sign in to Hack to manage connected accounts.") .font(.mono(.caption)) .foregroundStyle(.secondary) } else { - Text("This Mac still has \(linearProfiles.count) local Linear profile\(linearProfiles.count == 1 ? "" : "s"), but remote account-owned connections require Hack sign-in.") + Text("Sign in to Hack to finish managing Linear connections.") .font(.mono(.caption)) .foregroundStyle(.secondary) } + + if !message.isEmpty { + Text(message) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } - GlassCard(title: "Sync fields", systemImage: "arrow.triangle.branch") { - VStack(alignment: .leading, spacing: 12) { - syncToggleRow( - title: "Sync labels", - help: "Translate Linear labels and Hack categories during manual sync.", - isOn: $syncLabels - ) { newValue in - await saveLinearSyncToggle( - key: "labels", - value: newValue, - successMessage: newValue ? "Linear label sync enabled." : "Linear label sync disabled." - ) - } - syncToggleRow( - title: "Sync statuses", - help: "Translate workflow state when both sides expose a matching status.", - isOn: $syncStatuses - ) { newValue in - await saveLinearSyncToggle( - key: "statuses", - value: newValue, - successMessage: newValue ? "Linear status sync enabled." : "Linear status sync disabled." - ) - } - syncToggleRow( - title: "Sync dependencies", - help: "Translate dependency and sub-issue style relationships when the counterpart ticket exists.", - isOn: $syncDependencies - ) { newValue in - await saveLinearSyncToggle( - key: "dependencies", - value: newValue, - successMessage: newValue ? "Linear dependency sync enabled." : "Linear dependency sync disabled." - ) - } - syncToggleRow( - title: "Sync project mapping", - help: "Allow Hack projects to read and write the paired Linear project binding.", - isOn: $syncProjects - ) { newValue in - await saveLinearSyncToggle( - key: "projects", - value: newValue, - successMessage: newValue ? "Linear project sync enabled." : "Linear project sync disabled." - ) - } - HStack(alignment: .center, spacing: 6) { - Text("Review still stays manual") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - Image(systemName: "info.circle") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.tertiary) - .help(linearSyncReviewSummary) - Spacer() + if !remoteLinearConnections.isEmpty || !localOnlyLinearProfiles.isEmpty { + GlassCard(title: "Sync fields", systemImage: "arrow.triangle.branch") { + VStack(alignment: .leading, spacing: 12) { + syncToggleRow( + title: "Sync labels", + help: "Translate Linear labels and Hack categories during manual sync.", + isOn: $syncLabels + ) { newValue in + await saveLinearSyncToggle( + key: "labels", + value: newValue, + successMessage: newValue ? "Linear label sync enabled." : "Linear label sync disabled." + ) + } + syncToggleRow( + title: "Sync statuses", + help: "Translate workflow state when both sides expose a matching status.", + isOn: $syncStatuses + ) { newValue in + await saveLinearSyncToggle( + key: "statuses", + value: newValue, + successMessage: newValue ? "Linear status sync enabled." : "Linear status sync disabled." + ) + } + syncToggleRow( + title: "Sync dependencies", + help: "Translate dependency and sub-issue style relationships when the counterpart ticket exists.", + isOn: $syncDependencies + ) { newValue in + await saveLinearSyncToggle( + key: "dependencies", + value: newValue, + successMessage: newValue ? "Linear dependency sync enabled." : "Linear dependency sync disabled." + ) + } + syncToggleRow( + title: "Sync project mapping", + help: "Allow Hack projects to read and write the paired Linear project binding.", + isOn: $syncProjects + ) { newValue in + await saveLinearSyncToggle( + key: "projects", + value: newValue, + successMessage: newValue ? "Linear project sync enabled." : "Linear project sync disabled." + ) + } + HStack(alignment: .center, spacing: 6) { + Text("Review still stays manual") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + Image(systemName: "info.circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + .help(linearSyncReviewSummary) + Spacer() + } } } } - GlassCard(title: "Assignee mapping", systemImage: "person.crop.rectangle.stack") { - VStack(alignment: .leading, spacing: 12) { - if linearProfiles.isEmpty { - Text("Connect an account to add assignee mappings.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 10) { - VStack(alignment: .leading, spacing: 6) { - Text("Profile") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Picker("Profile", selection: $assigneeMappingProfile) { - ForEach(linearProfiles, id: \.id) { profile in - Text(displayNameForRemoteProfileId(profile.id)) - .tag(profile.id) + if !remoteLinearConnections.isEmpty || !localOnlyLinearProfiles.isEmpty { + GlassCard(title: "Assignee mapping", systemImage: "person.crop.rectangle.stack") { + VStack(alignment: .leading, spacing: 12) { + if linearProfiles.isEmpty { + Text("Connect an account to add assignee mappings.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 6) { + Text("Profile") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Picker("Profile", selection: $assigneeMappingProfile) { + ForEach(linearProfiles, id: \.id) { profile in + Text(displayNameForRemoteProfileId(profile.id)) + .tag(profile.id) + } } + .pickerStyle(.menu) } - .pickerStyle(.menu) - } - VStack(alignment: .leading, spacing: 6) { - Text("Team scope") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - TextField("Optional team id", text: $assigneeMappingTeamId) - .textFieldStyle(.roundedBorder) - } + VStack(alignment: .leading, spacing: 6) { + Text("Team scope") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + TextField("Optional team id", text: $assigneeMappingTeamId) + .textFieldStyle(.roundedBorder) + } - Spacer() + Spacer() - if isLoadingAssigneeMappings || isSavingAssigneeMapping { - ProgressView() - .controlSize(.small) + if isLoadingAssigneeMappings || isSavingAssigneeMapping { + ProgressView() + .controlSize(.small) + } } - } - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text("Saved mappings") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - StatusPill( - text: "\(assigneeMappings.count) mapping\(assigneeMappings.count == 1 ? "" : "s")", - tone: assigneeMappings.isEmpty ? .neutral : .good - ) - } + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Saved mappings") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + StatusPill( + text: "\(assigneeMappings.count) mapping\(assigneeMappings.count == 1 ? "" : "s")", + tone: assigneeMappings.isEmpty ? .neutral : .good + ) + } - if assigneeMappings.isEmpty { - Text("No explicit mappings for this profile/team scope yet.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(assigneeMappings) { mapping in - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(mapping.localAssignee) - .font(.mono(.subheadline, weight: .semibold)) - if let teamId = mapping.teamId, !teamId.isEmpty { - StatusPill(text: "Team \(teamId)", tone: .neutral) - } else { - StatusPill(text: "All teams", tone: .neutral) - } - Spacer() - Button { - populateAssigneeMappingDraft(from: mapping) - } label: { - Label("Use", systemImage: "arrow.down.left.circle") - } - .adaptiveToolbarButton() - Button(role: .destructive) { - Task { await removeLinearAssigneeMapping(mapping) } - } label: { - Label("Remove", systemImage: "trash") + if assigneeMappings.isEmpty { + Text("No explicit mappings for this profile/team scope yet.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(assigneeMappings) { mapping in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(mapping.localAssignee) + .font(.mono(.subheadline, weight: .semibold)) + if let teamId = mapping.teamId, !teamId.isEmpty { + StatusPill(text: "Team \(teamId)", tone: .neutral) + } else { + StatusPill(text: "All teams", tone: .neutral) + } + Spacer() + Button { + populateAssigneeMappingDraft(from: mapping) + } label: { + Label("Use", systemImage: "arrow.down.left.circle") + } + .adaptiveToolbarButton() + Button(role: .destructive) { + Task { await removeLinearAssigneeMapping(mapping) } + } label: { + Label("Remove", systemImage: "trash") + } + .adaptiveToolbarButton() + .disabled(isSavingAssigneeMapping) } - .adaptiveToolbarButton() - .disabled(isSavingAssigneeMapping) + Text(mappingDestinationLabel(mapping)) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) } - Text(mappingDestinationLabel(mapping)) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) } } } - } - Divider() - .opacity(0.2) + Divider() + .opacity(0.2) - VStack(alignment: .leading, spacing: 10) { - Text("Edit mapping") - .font(.mono(.caption)) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 10) { + Text("Edit mapping") + .font(.mono(.caption)) + .foregroundStyle(.secondary) - TextField("Local assignee (required)", text: $assigneeMappingLocalAssignee) - .textFieldStyle(.roundedBorder) - TextField("Linear user id", text: $assigneeMappingLinearUserId) - .textFieldStyle(.roundedBorder) - TextField("Linear user name", text: $assigneeMappingLinearUserName) - .textFieldStyle(.roundedBorder) - TextField("Linear user email", text: $assigneeMappingLinearUserEmail) - .textFieldStyle(.roundedBorder) + TextField("Local assignee (required)", text: $assigneeMappingLocalAssignee) + .textFieldStyle(.roundedBorder) + TextField("Linear user id", text: $assigneeMappingLinearUserId) + .textFieldStyle(.roundedBorder) + TextField("Linear user name", text: $assigneeMappingLinearUserName) + .textFieldStyle(.roundedBorder) + TextField("Linear user email", text: $assigneeMappingLinearUserEmail) + .textFieldStyle(.roundedBorder) - HStack(spacing: 8) { - Button { - Task { await saveLinearAssigneeMapping() } - } label: { - Label("Save mapping", systemImage: "tray.and.arrow.down") - } - .adaptiveToolbarButtonProminent() - .disabled(!canSaveAssigneeMapping || isSavingAssigneeMapping) + HStack(spacing: 8) { + Button { + Task { await saveLinearAssigneeMapping() } + } label: { + Label("Save mapping", systemImage: "tray.and.arrow.down") + } + .adaptiveToolbarButtonProminent() + .disabled(!canSaveAssigneeMapping || isSavingAssigneeMapping) - Button("Clear form") { - clearAssigneeMappingDraft() + Button("Clear form") { + clearAssigneeMappingDraft() + } + .adaptiveToolbarButton() + .disabled(isSavingAssigneeMapping) } - .adaptiveToolbarButton() - .disabled(isSavingAssigneeMapping) } } } } } } - - if !message.isEmpty { - InlineCallout( - tone: .neutral, - title: "Linear settings", - message: message, - actions: [] - ) - } } .padding(16) } .task { await loadConfigFromDisk() } + .onChange(of: model.linearOAuthDeepLinkContext) { _, deepLink in + guard let deepLink else { return } + Task { + await handleLinearOAuthDeepLink(deepLink) + } + } .onChange(of: assigneeMappingProfile) { _, _ in Task { await refreshLinearAssigneeMappings() } } @@ -3361,6 +3376,46 @@ private struct LinearExtensionSettingsView: View { } } + private var linearConnectionNeedsAttention: Bool { + !localOnlyLinearProfiles.isEmpty || + remoteLinearConnections.contains { connection in + let profileId = connection.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !profileId.isEmpty else { + return true + } + return !linearProfiles.contains(where: { + $0.id.caseInsensitiveCompare(profileId) == .orderedSame + }) + } + } + + private var linearConnectionStateLabel: String { + if !enabled { + return "Disabled" + } + if isAuthenticating { + return "Connecting" + } + if !remoteLinearConnections.isEmpty { + return linearConnectionNeedsAttention ? "Needs attention" : "Connected" + } + if !localOnlyLinearProfiles.isEmpty { + return "Needs attention" + } + return "Not connected" + } + + private var linearConnectionStateTone: StatusTone { + switch linearConnectionStateLabel { + case "Connected": + return .good + case "Needs attention": + return .warn + default: + return .neutral + } + } + private func nextLinearProfileId() -> String { let existing = Set( linearProfiles.map { $0.id.lowercased() } + @@ -3709,7 +3764,7 @@ private struct LinearExtensionSettingsView: View { } message = trimmedDefault.isEmpty - ? "Linear default profile cleared. Projects now need an explicit Linear routing override before sync can fall back to a remote account." + ? "Linear default profile cleared." : "Linear default profile saved. Projects without their own override will now route through \(displayNameForRemoteProfileId(trimmedDefault))." defaultProfile = trimmedDefault await model.refresh() @@ -3741,9 +3796,9 @@ private struct LinearExtensionSettingsView: View { } if resolvedDefaultProfile == trimmed { - message = "Disconnected token for \(displayNameForRemoteProfileId(trimmed)). The profile stays available for reconnect, but projects inheriting this default profile need a reconnect or a different default before the next sync." + message = "Removed access for \(displayNameForRemoteProfileId(trimmed)). Reconnect it before using this default again." } else { - message = "Disconnected token for \(displayNameForRemoteProfileId(trimmed)). The profile stays available for reconnect, and any project routed to it needs a reconnect or a different override before the next sync." + message = "Removed access for \(displayNameForRemoteProfileId(trimmed)). Reconnect it before using it again." } await model.refresh() await refreshLinearDiagnostics() @@ -3773,6 +3828,8 @@ private struct LinearExtensionSettingsView: View { authPollingTask?.cancel() authPollingTask = nil isAuthenticating = false + activeAuthFlowId = nil + activeAuthStatusURL = nil if userInitiated { message = "Linear authentication canceled." } @@ -3791,7 +3848,7 @@ private struct LinearExtensionSettingsView: View { let profile = trimmed.isEmpty ? "default" : trimmed isAuthenticating = true authFlowStatus = nil - message = "Starting Linear browser auth for profile \(profile). When it completes, the profile can be used for default routing or project-specific sync." + message = "Starting Linear connection for \(profile)…" guard let started = await model.startLinearOAuthFlow( @@ -3814,6 +3871,8 @@ private struct LinearExtensionSettingsView: View { if setDefault { defaultProfile = profile } + activeAuthFlowId = started.flowId + activeAuthStatusURL = started.statusUrl guard let authorizeURL = URL(string: started.authorizeUrl) else { message = "Auth start returned an invalid authorize URL." @@ -3821,7 +3880,7 @@ private struct LinearExtensionSettingsView: View { } NSWorkspace.shared.open(authorizeURL) - message = "Browser opened. Approve access to finish connecting \(profile), then return here to use it for default or per-project routing." + message = "Approve access in the browser to finish connecting \(profile)." let formatter = ISO8601DateFormatter() let expiresAtDate = formatter.date(from: started.expiresAt) @@ -3865,10 +3924,10 @@ private struct LinearExtensionSettingsView: View { $0.profileId?.caseInsensitiveCompare(profile) == .orderedSame }) { message = - "Linear already appears connected on this Hack account for \(profile). If the browser opened a manage/install page instead of returning here, finish there or revoke the existing Linear app token, then retry linking this Mac." + "Hack already sees this account for \(profile). Finish the browser step or retry Connect account to complete setup." } else { message = - "Still waiting for Linear to return to Hack. If the browser opened a manage/install page, finish there and then return here." + "Still waiting for Linear to finish connecting." } } if pendingPollCount >= 16 { @@ -3876,10 +3935,10 @@ private struct LinearExtensionSettingsView: View { $0.profileId?.caseInsensitiveCompare(profile) == .orderedSame }) { message = - "Linear is already linked on this Hack account for \(profile), but this Mac did not receive a fresh callback token. Close the browser and use Link this Mac if you need a local token cached here." + "Hack sees the account for \(profile), but setup still needs attention. Try Connect account again." } else { message = - "Linear did not return a callback for \(profile). If Linear shows Hack already installed, revoke the existing Hack app authorization in Linear and reconnect once so Hack can persist it on this account." + "Connection did not finish. If Linear says Hack is already installed, remove that authorization and reconnect once." } await refreshLinearDiagnostics() authFlowStatus = nil @@ -3901,7 +3960,7 @@ private struct LinearExtensionSettingsView: View { ?? flowStatus.accountEmail ?? flowStatus.accountHandle ?? "Linear provider account" - message = "Connected \(account) to profile \(flowStatus.profileId). You can now use it for default routing or bind projects to it directly." + message = "Connected \(account)." await model.refresh() await refreshLinearDiagnostics() return true @@ -3915,6 +3974,35 @@ private struct LinearExtensionSettingsView: View { return false } } + + private func handleLinearOAuthDeepLink(_ deepLink: LinearOAuthDeepLinkContext) async { + defer { + model.clearLinearOAuthDeepLink(flowId: deepLink.flowId) + } + + guard + let activeAuthFlowId, + let activeAuthStatusURL + else { + message = "Linear callback received. Return to Linear settings to finish setup." + return + } + + guard activeAuthFlowId == deepLink.flowId else { + return + } + + message = "Browser callback received. Finalizing Linear connection…" + guard let flowStatus = await model.fetchLinearOAuthFlowStatus(statusURL: activeAuthStatusURL) else { + let detail = model.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) + if let detail, !detail.isEmpty { + message = detail + } + return + } + authFlowStatus = flowStatus + _ = await handleLinearOAuthFlowStatus(flowStatus) + } } private extension String { diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index dc4d4f447..1df24374c 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -1067,6 +1067,7 @@ public actor HackCLIClient { profileId: String, setDefault: Bool ) async throws -> LinearOAuthFlowStartResponse { + let desktopRedirectURL = desktopLinearOAuthCallbackURL() var lastError: String? = nil for candidate in resolveAuthServerCandidates() { guard @@ -1077,6 +1078,7 @@ public actor HackCLIClient { URLQueryItem(name: "profile", value: profileId), URLQueryItem(name: "setDefault", value: setDefault ? "1" : "0"), URLQueryItem(name: "set_default", value: setDefault ? "1" : "0"), + URLQueryItem(name: "desktopRedirectUrl", value: desktopRedirectURL), ] ) else { @@ -1716,6 +1718,11 @@ public actor HackCLIClient { ?? "\(desktopURLScheme())://auth/github/callback" } + private func desktopLinearOAuthCallbackURL() -> String { + bundleString(forInfoDictionaryKey: "HackLinearOAuthCallbackURL") + ?? "\(desktopURLScheme())://auth/linear/callback" + } + private func desktopURLScheme() -> String { if let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") diff --git a/services/auth-broker/src/modules/linear-connections/service.ts b/services/auth-broker/src/modules/linear-connections/service.ts index 87bfedb9f..2630ea91c 100644 --- a/services/auth-broker/src/modules/linear-connections/service.ts +++ b/services/auth-broker/src/modules/linear-connections/service.ts @@ -166,6 +166,7 @@ export function createLinearConnectionStoreFromDb(input: { await ensureTable(); const connectionKey = buildConnectionKey(connection); const now = new Date(); + const id = randomUUID(); const metadataJson = JSON.stringify( composeConnectionMetadata({ metadata: connection.metadata, @@ -176,6 +177,7 @@ export function createLinearConnectionStoreFromDb(input: { const inserted = await db .insert(linearConnections) .values({ + id, connectionKey, profileId: normalizeText(connection.profileId), accountId: normalizeText(connection.accountId), diff --git a/services/auth-broker/src/modules/linear-oauth/model.ts b/services/auth-broker/src/modules/linear-oauth/model.ts index ed806fcd7..a0e958a51 100644 --- a/services/auth-broker/src/modules/linear-oauth/model.ts +++ b/services/auth-broker/src/modules/linear-oauth/model.ts @@ -9,6 +9,7 @@ export const LinearOAuthModel = { profile: t.Optional(t.String()), setDefault: t.Optional(t.String()), redirect: t.Optional(t.String()), + desktopRedirectUrl: t.Optional(t.String()), }), callbackQuery: t.Object({ code: t.Optional(t.String()), diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts index 2a6c099bd..1efecb05c 100644 --- a/services/auth-broker/src/modules/linear-oauth/service.ts +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -13,6 +13,7 @@ import { import type { OAuthFlow } from "../../types.ts"; import { issueBrokerManagementToken } from "../better-auth/management-token.ts"; import { resolveBetterAuthSession } from "../better-auth/session.ts"; +import { buildHackDesktopDeepLink } from "../github-oauth/service.ts"; import type { LinearConnectionStore } from "../linear-connections/service.ts"; import type { LinearOAuthModel } from "./model.ts"; @@ -27,12 +28,17 @@ export type StartFlowPayload = { readonly expiresAt: string; }; +type CallbackPageAction = { + readonly label: string; + readonly url: string; +}; + export function createFlow(input: { readonly config: BrokerConfig; readonly flowStore: FlowStore; readonly query: Pick< LinearOAuthModel["startQuery"], - "profile" | "setDefault" + "profile" | "setDefault" | "desktopRedirectUrl" >; }): StartFlowPayload { if (!(input.config.linearClientId && input.config.linearRedirectUri)) { @@ -44,6 +50,9 @@ export function createFlow(input: { const deviceCode = makeToken(); const profileId = normalizeText(input.query.profile) ?? "default"; const setDefault = isTruthy(input.query.setDefault); + const desktopRedirectUrl = normalizeDesktopRedirectUrl( + input.query.desktopRedirectUrl + ); const expiresAtMs = nowMs + input.config.flowTtlMs; const codeVerifier = randomBytes(64).toString("base64url"); const codeChallenge = createHash("sha256") @@ -71,6 +80,7 @@ export function createFlow(input: { createdAtMs: nowMs, expiresAtMs, redirectUri: input.config.linearRedirectUri, + ...(desktopRedirectUrl ? { desktopRedirectUrl } : {}), status: "pending", }); @@ -102,125 +112,164 @@ export async function handleLinearCallback(input: { return flowResult.response; } const flow = flowResult.flow; + try { + const oauthError = normalizeText(input.query.error); + if (oauthError) { + const oauthDescription = normalizeText(input.query.error_description); + return markLinearFlowError({ + flowStore: input.flowStore, + flowId: flow.id, + error: oauthDescription + ? `${oauthError}: ${oauthDescription}` + : oauthError, + status: "error", + title: "Linear authorization failed", + body: + oauthDescription ?? + "Linear denied access. Return to Hack and try again.", + statusCode: 400, + ...(flow.desktopRedirectUrl + ? { + actions: [ + buildOpenHackAction({ + flow, + status: "error", + }), + ], + } + : {}), + }); + } - const oauthError = normalizeText(input.query.error); - if (oauthError) { - const oauthDescription = normalizeText(input.query.error_description); - return markLinearFlowError({ - flowStore: input.flowStore, - flowId: flow.id, - error: oauthDescription - ? `${oauthError}: ${oauthDescription}` - : oauthError, - status: "error", - title: "Linear authorization failed", - body: - oauthDescription ?? - "Linear denied access. Return to Hack and try again.", - statusCode: 400, - }); - } + const code = normalizeText(input.query.code); + if (!code) { + return markLinearFlowError({ + flowStore: input.flowStore, + flowId: flow.id, + error: "missing_authorization_code", + status: "error", + title: "Missing authorization code", + body: "Linear did not return a code for this sign-in request.", + statusCode: 400, + ...(flow.desktopRedirectUrl + ? { + actions: [ + buildOpenHackAction({ + flow, + status: "error", + }), + ], + } + : {}), + }); + } - const code = normalizeText(input.query.code); - if (!code) { - return markLinearFlowError({ - flowStore: input.flowStore, - flowId: flow.id, - error: "missing_authorization_code", - status: "error", - title: "Missing authorization code", - body: "Linear did not return a code for this sign-in request.", - statusCode: 400, - }); - } + const clientId = input.config.linearClientId; + if (!(clientId && flow.codeVerifier)) { + return markLinearFlowError({ + flowStore: input.flowStore, + flowId: flow.id, + error: "linear_oauth_not_configured", + status: "error", + title: "Linear OAuth not configured", + body: "The auth broker is missing Linear OAuth configuration.", + statusCode: 412, + ...(flow.desktopRedirectUrl + ? { + actions: [ + buildOpenHackAction({ + flow, + status: "error", + }), + ], + } + : {}), + }); + } - const clientId = input.config.linearClientId; - if (!(clientId && flow.codeVerifier)) { - return markLinearFlowError({ - flowStore: input.flowStore, - flowId: flow.id, - error: "linear_oauth_not_configured", - status: "error", - title: "Linear OAuth not configured", - body: "The auth broker is missing Linear OAuth configuration.", - statusCode: 412, + const exchange = await exchangeCodeForToken({ + tokenUrl: input.config.linearTokenUrl, + clientId, + ...(input.config.linearClientSecret + ? { clientSecret: input.config.linearClientSecret } + : {}), + code, + redirectUri: flow.redirectUri, + codeVerifier: flow.codeVerifier, }); - } + if (!exchange.ok) { + return markLinearFlowError({ + flowStore: input.flowStore, + flowId: flow.id, + error: exchange.error, + status: "error", + title: "Token exchange failed", + body: exchange.error, + statusCode: 502, + ...(flow.desktopRedirectUrl + ? { + actions: [ + buildOpenHackAction({ + flow, + status: "error", + }), + ], + } + : {}), + }); + } - const exchange = await exchangeCodeForToken({ - tokenUrl: input.config.linearTokenUrl, - clientId, - ...(input.config.linearClientSecret - ? { clientSecret: input.config.linearClientSecret } - : {}), - code, - redirectUri: flow.redirectUri, - codeVerifier: flow.codeVerifier, - }); - if (!exchange.ok) { - return markLinearFlowError({ - flowStore: input.flowStore, - flowId: flow.id, - error: exchange.error, - status: "error", - title: "Token exchange failed", - body: exchange.error, - statusCode: 502, + const identity = await fetchIdentity({ + apiBaseUrl: input.config.linearApiBaseUrl, + token: exchange.token, }); - } + if (!identity.ok) { + return markLinearFlowError({ + flowStore: input.flowStore, + flowId: flow.id, + error: identity.error, + status: "error", + title: "Linear account lookup failed", + body: identity.error, + statusCode: 502, + ...(flow.desktopRedirectUrl + ? { + actions: [ + buildOpenHackAction({ + flow, + status: "error", + }), + ], + } + : {}), + }); + } - const identity = await fetchIdentity({ - apiBaseUrl: input.config.linearApiBaseUrl, - token: exchange.token, - }); - if (!identity.ok) { - return markLinearFlowError({ - flowStore: input.flowStore, - flowId: flow.id, - error: identity.error, - status: "error", - title: "Linear account lookup failed", - body: identity.error, - statusCode: 502, + const betterAuthLink = await resolveBetterAuthUserFromLinearAccount({ + runtime: input.betterAuthRuntime, + account: identity.account, + autoProvision: input.config.betterAuthLinearAutoProvisionUsers, + }); + const betterAuthSession = await resolveBetterAuthSession({ + runtime: input.betterAuthRuntime, + request: input.request, + }); + const linkedAccount = { + ...identity.account, + ...(betterAuthLink.userId + ? { betterAuthUserId: betterAuthLink.userId } + : {}), + ...(betterAuthLink.state + ? { betterAuthLinkState: betterAuthLink.state } + : {}), + } as const; + const managementToken = issueBrokerManagementToken({ + userId: betterAuthSession.session?.userId ?? betterAuthLink.userId ?? "", + profileId: flow.profileId, + organizationId: betterAuthSession.session?.organizationId ?? null, + teamId: betterAuthSession.session?.teamId ?? null, }); - } - - const betterAuthLink = await resolveBetterAuthUserFromLinearAccount({ - runtime: input.betterAuthRuntime, - account: identity.account, - autoProvision: input.config.betterAuthLinearAutoProvisionUsers, - }); - const betterAuthSession = await resolveBetterAuthSession({ - runtime: input.betterAuthRuntime, - request: input.request, - }); - const linkedAccount = { - ...identity.account, - ...(betterAuthLink.userId - ? { betterAuthUserId: betterAuthLink.userId } - : {}), - ...(betterAuthLink.state - ? { betterAuthLinkState: betterAuthLink.state } - : {}), - } as const; - const managementToken = issueBrokerManagementToken({ - userId: betterAuthSession.session?.userId ?? betterAuthLink.userId ?? "", - profileId: flow.profileId, - organizationId: betterAuthSession.session?.organizationId ?? null, - teamId: betterAuthSession.session?.teamId ?? null, - }); - input.flowStore.markComplete({ - flowId: flow.id, - account: linkedAccount, - token: exchange.token, - tokenExpiresAt: exchange.tokenExpiresAt, - refreshToken: exchange.refreshToken, - refreshTokenExpiresAt: exchange.refreshTokenExpiresAt, - managementToken: managementToken?.token, - managementTokenExpiresAt: managementToken?.expiresAt, - }); - try { await input.connectionStore.upsertConnection({ profileId: flow.profileId, accountId: identity.account.accountId, @@ -244,16 +293,62 @@ export async function handleLinearCallback(input: { : {}), }, }); - } catch { - // Connection persistence is diagnostic state and must not fail OAuth completion. - } - return renderCallbackPage({ - title: "Linear connected", - body: "Authorization is complete. Return to Hack to finish account setup.", - statusCode: 200, - success: true, - }); + input.flowStore.markComplete({ + flowId: flow.id, + account: linkedAccount, + token: exchange.token, + tokenExpiresAt: exchange.tokenExpiresAt, + refreshToken: exchange.refreshToken, + refreshTokenExpiresAt: exchange.refreshTokenExpiresAt, + managementToken: managementToken?.token, + managementTokenExpiresAt: managementToken?.expiresAt, + }); + + return renderCallbackPage({ + title: "Linear connected", + body: "Authorization is complete. Return to Hack to finish account setup.", + statusCode: 200, + success: true, + actions: flow.desktopRedirectUrl + ? [ + buildOpenHackAction({ + flow, + status: "complete", + }), + ] + : [], + }); + } catch (error) { + const actions = flow.desktopRedirectUrl + ? [ + buildOpenHackAction({ + flow, + status: "error", + }), + ] + : []; + try { + return markLinearFlowError({ + flowStore: input.flowStore, + flowId: flow.id, + error: + error instanceof Error ? error.message : "linear_callback_failed", + status: "error", + title: "Connection failed", + body: "Linear authorization could not be completed. Return to Hack and try again.", + statusCode: 502, + actions, + }); + } catch { + return renderCallbackPage({ + title: "Connection failed", + body: "Linear authorization could not be completed. Return to Hack and try again.", + statusCode: 502, + actions, + }); + } + } } function resolveLinearCallbackFlow(input: { @@ -312,16 +407,23 @@ function markLinearFlowError(input: { readonly title: string; readonly body: string; readonly statusCode: number; + readonly actions?: readonly CallbackPageAction[]; }): Response { - input.flowStore.markError({ - flowId: input.flowId, - error: input.error, - status: input.status, - }); + try { + input.flowStore.markError({ + flowId: input.flowId, + error: input.error, + status: input.status, + }); + } catch { + // Rendering the browser recovery page matters more than recording flow + // state. Callback failures must never surface as raw host errors. + } return renderCallbackPage({ title: input.title, body: input.body, statusCode: input.statusCode, + actions: input.actions, }); } @@ -404,31 +506,92 @@ function renderCallbackPage(input: { readonly body: string; readonly statusCode: number; readonly success?: boolean; + readonly actions?: readonly CallbackPageAction[]; }): Response { - const html = [ - "", - "", - "", - ' ', - ' ', - ` ${escapeHtml(input.title)}`, - " ", - "", - "", - '
', - `

${escapeHtml(input.title)}

`, - `

${escapeHtml(input.body)}

`, - "
", - "", - "", - ].join("\n"); + const actions = input.actions ?? []; + const actionsHtml = + actions.length > 0 + ? `
${actions + .map( + (action) => + `${escapeHtml(action.label)}` + ) + .join("")}
` + : ""; + const html = ` + + + + + ${escapeHtml(input.title)} + + + +
+
HACK
+

${escapeHtml(input.title)}

+

${escapeHtml(input.body)}

+ ${actionsHtml} +
+ +`; return new Response(html, { status: input.statusCode, headers: { @@ -438,6 +601,39 @@ function renderCallbackPage(input: { }); } +function buildOpenHackAction(input: { + readonly flow: OAuthFlow; + readonly status: string; +}): CallbackPageAction { + return { + label: "Open Hack", + url: buildHackDesktopDeepLink({ + flowId: input.flow.id, + profileId: input.flow.profileId, + status: input.status, + ...(input.flow.desktopRedirectUrl + ? { baseUrl: input.flow.desktopRedirectUrl } + : {}), + }), + }; +} + +function normalizeDesktopRedirectUrl(value: string | undefined): string | null { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + try { + const url = new URL(normalized); + if (url.protocol !== "hack:" && url.protocol !== "hack-dev:") { + return null; + } + return url.toString(); + } catch { + return null; + } +} + function normalizeText(value: string | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 628dad8a9..35e7b8526 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -2391,6 +2391,25 @@ describe("auth broker github flow routes", () => { expect(pollPayload.status.setDefault).toBe(true); }); + test("linear start route persists desktop redirect URL for browser handoff", async () => { + const flowStore = new FlowStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + }); + + const startResponse = await app.handle( + new Request( + "http://localhost/v1/auth/linear/start?profile=default&desktopRedirectUrl=hack-dev%3A%2F%2Fauth%2Flinear%2Fcallback" + ) + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + expect(flow?.desktopRedirectUrl).toBe("hack-dev://auth/linear/callback"); + }); + test("linear callback completes flow and claim returns token", async () => { await withManagementTokenSecret("broker-management-secret", async () => { const flowStore = new FlowStore(); @@ -2590,6 +2609,342 @@ describe("auth broker github flow routes", () => { }); }); + test("linear callback renders Open Hack action when desktop redirect is present", async () => { + await withManagementTokenSecret("broker-management-secret", async () => { + const flowStore = new FlowStore(); + const connectionStore = new InMemoryLinearConnectionStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + linearConnectionStore: connectionStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-linear-open-hack", + userId: "better-auth-linear-user", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-linear", + }, + user: { + id: "better-auth-linear-user", + email: "linear@example.com", + emailVerified: true, + name: "Linear User", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "better-auth-org", + teamId: "better-auth-team", + } + ), + createBetterAuthDb([{ id: "better-auth-linear-user" }]) + ), + }); + + const startResponse = await app.handle( + new Request( + "http://localhost/v1/auth/linear/start?profile=work&desktopRedirectUrl=hack-dev%3A%2F%2Fauth%2Flinear%2Fcallback" + ) + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + if (!flow) { + return; + } + + const originalFetch = globalThis.fetch; + globalThis.fetch = (( + input: string | URL | Request, + init?: RequestInit + ) => { + const url = resolveFetchUrl(input); + if (url === createTestConfig().linearTokenUrl) { + return new Response( + JSON.stringify({ + access_token: "lin_oauth_token", + expires_in: 3600, + refresh_token: "lin_refresh_token", + refresh_token_expires_in: 86_400, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + if (url === createTestConfig().linearApiBaseUrl) { + const bodyText = + typeof init?.body === "string" + ? init.body + : String(init?.body ?? ""); + if (bodyText.includes("viewer")) { + return new Response( + JSON.stringify({ + data: { + viewer: { + id: "usr_linear", + name: "Linear User", + email: "linear@example.com", + displayName: "Linear User", + organization: { id: "org-linear", name: "Hack Dance" }, + teams: { nodes: [{ id: "team-linear", name: "Platform" }] }, + }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + try { + const callbackResponse = await app.handle( + new Request( + `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` + ) + ); + expect(callbackResponse.status).toBe(200); + const callbackHtml = await callbackResponse.text(); + expect(callbackHtml).toContain("Open Hack"); + expect(callbackHtml).toContain("hack-dev://auth/linear/callback"); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + test("linear callback returns Hack-branded error page when token exchange throws", async () => { + const flowStore = new FlowStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + }); + + const startResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/start?profile=default") + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + if (!flow) { + return; + } + + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("network down"); + }) as unknown as typeof fetch; + + try { + const callbackResponse = await app.handle( + new Request( + `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` + ) + ); + expect(callbackResponse.status).toBe(502); + const callbackHtml = await callbackResponse.text(); + expect(callbackHtml).toContain("Connection failed"); + expect(callbackHtml).toContain("Return to Hack"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("linear callback fails when connection persistence cannot be saved", async () => { + await withManagementTokenSecret("broker-management-secret", async () => { + const flowStore = new FlowStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + linearConnectionStore: { + upsertConnection() { + return Promise.reject(new Error("linear connection write failed")); + }, + listConnections() { + return Promise.resolve([]); + }, + resolveWebhookOwnership() { + return Promise.resolve({ + status: "unmatched", + profileId: null, + betterAuthUserId: null, + betterAuthOrganizationId: null, + betterAuthTeamId: null, + organizationId: null, + teamId: null, + } as const); + }, + }, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam( + { + session: { + id: "sess-linear-persist-fail", + userId: "better-auth-linear-user", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-linear", + }, + user: { + id: "better-auth-linear-user", + email: "linear@example.com", + emailVerified: true, + name: "Linear User", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + organizationId: "better-auth-org", + teamId: "better-auth-team", + } + ), + createBetterAuthDb([{ id: "better-auth-linear-user" }]) + ), + }); + + const startResponse = await app.handle( + new Request( + "http://localhost/v1/auth/linear/start?profile=work&desktopRedirectUrl=hack-dev%3A%2F%2Fauth%2Flinear%2Fcallback" + ) + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + if (!flow) { + return; + } + + const originalFetch = globalThis.fetch; + globalThis.fetch = (( + input: string | URL | Request, + init?: RequestInit + ) => { + const url = resolveFetchUrl(input); + if (url === createTestConfig().linearTokenUrl) { + return new Response( + JSON.stringify({ + access_token: "lin_oauth_token", + expires_in: 3600, + refresh_token: "lin_refresh_token", + refresh_token_expires_in: 86_400, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + if (url === createTestConfig().linearApiBaseUrl) { + const bodyText = + typeof init?.body === "string" + ? init.body + : String(init?.body ?? ""); + if (bodyText.includes("viewer")) { + return new Response( + JSON.stringify({ + data: { + viewer: { + id: "usr_linear", + name: "Linear User", + email: "linear@example.com", + displayName: "Linear User", + organization: { id: "org-linear", name: "Hack Dance" }, + teams: { nodes: [{ id: "team-linear", name: "Platform" }] }, + }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + try { + const callbackResponse = await app.handle( + new Request( + `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` + ) + ); + expect(callbackResponse.status).toBe(502); + const callbackHtml = await callbackResponse.text(); + expect(callbackHtml).toContain("Connection failed"); + expect(callbackHtml).toContain("Open Hack"); + expect(callbackHtml).toContain("hack-dev://auth/linear/callback"); + + const completedFlow = flowStore.getById(startPayload.flow.flowId); + expect(completedFlow?.status).toBe("error"); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + test("linear callback still renders a Hack page when flow error recording fails", async () => { + const flowStore = new FlowStore(); + const originalMarkError = flowStore.markError.bind(flowStore); + flowStore.markError = (() => { + throw new Error("flow store unavailable"); + }) as typeof flowStore.markError; + + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + }); + + const startResponse = await app.handle( + new Request( + "http://localhost/v1/auth/linear/start?profile=default&desktopRedirectUrl=hack-dev%3A%2F%2Fauth%2Flinear%2Fcallback" + ) + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow).not.toBeNull(); + if (!flow) { + return; + } + + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("network down"); + }) as unknown as typeof fetch; + + try { + const callbackResponse = await app.handle( + new Request( + `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` + ) + ); + expect(callbackResponse.status).toBe(502); + const callbackHtml = await callbackResponse.text(); + expect(callbackHtml).toContain("Connection failed"); + expect(callbackHtml).toContain("Open Hack"); + expect(callbackHtml).toContain("hack-dev://auth/linear/callback"); + } finally { + globalThis.fetch = originalFetch; + flowStore.markError = originalMarkError; + } + }); + test("claimed management token authorizes protected Linear routes without a browser session", async () => { await withManagementTokenSecret("broker-management-secret", async () => { const flowStore = new FlowStore(); From 4f574f4bd45794005b0d4142c371626396ce4e12 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 16:51:28 -0500 Subject: [PATCH 27/54] Bind Linear OAuth flows to Hack auth ownership --- services/auth-broker/src/flow-store.ts | 14 ++ .../src/modules/linear-oauth/plugin.ts | 1 + .../src/modules/linear-oauth/service.ts | 81 +++++++++- .../linear-oauth/start-routes-plugin.ts | 46 +++++- services/auth-broker/src/types.ts | 3 + services/auth-broker/tests/index.test.ts | 153 ++++++++++++------ .../extensions/linear/commands.ts | 23 ++- 7 files changed, 256 insertions(+), 65 deletions(-) diff --git a/services/auth-broker/src/flow-store.ts b/services/auth-broker/src/flow-store.ts index 6660e8074..f5990b176 100644 --- a/services/auth-broker/src/flow-store.ts +++ b/services/auth-broker/src/flow-store.ts @@ -385,6 +385,15 @@ function parsePersistedFlow(input: { const authorizeUrl = asNonEmptyString(input.value.authorizeUrl); const redirectUri = asNonEmptyString(input.value.redirectUri); const desktopRedirectUrl = asOptionalString(input.value.desktopRedirectUrl); + const requestedByBetterAuthUserId = asOptionalString( + input.value.requestedByBetterAuthUserId + ); + const requestedByBetterAuthOrganizationId = asOptionalString( + input.value.requestedByBetterAuthOrganizationId + ); + const requestedByBetterAuthTeamId = asOptionalString( + input.value.requestedByBetterAuthTeamId + ); const codeVerifier = asOptionalString(input.value.codeVerifier); const status = asFlowStatus(input.value.status); const setDefault = @@ -441,6 +450,11 @@ function parsePersistedFlow(input: { expiresAtMs, redirectUri, ...(desktopRedirectUrl ? { desktopRedirectUrl } : {}), + ...(requestedByBetterAuthUserId ? { requestedByBetterAuthUserId } : {}), + ...(requestedByBetterAuthOrganizationId + ? { requestedByBetterAuthOrganizationId } + : {}), + ...(requestedByBetterAuthTeamId ? { requestedByBetterAuthTeamId } : {}), status, ...(account ? { account } : {}), ...(installationId ? { installationId } : {}), diff --git a/services/auth-broker/src/modules/linear-oauth/plugin.ts b/services/auth-broker/src/modules/linear-oauth/plugin.ts index 91c7c7c37..324b0e4b2 100644 --- a/services/auth-broker/src/modules/linear-oauth/plugin.ts +++ b/services/auth-broker/src/modules/linear-oauth/plugin.ts @@ -29,6 +29,7 @@ export function createLinearOAuthPlugin({ createLinearOAuthStartRoutesPlugin({ config, flowStore, + betterAuthRuntime, }) ) .use( diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts index 1efecb05c..855320315 100644 --- a/services/auth-broker/src/modules/linear-oauth/service.ts +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -40,6 +40,11 @@ export function createFlow(input: { LinearOAuthModel["startQuery"], "profile" | "setDefault" | "desktopRedirectUrl" >; + readonly requestedBy?: { + readonly betterAuthUserId: string; + readonly betterAuthOrganizationId?: string | null; + readonly betterAuthTeamId?: string | null; + } | null; }): StartFlowPayload { if (!(input.config.linearClientId && input.config.linearRedirectUri)) { throw new Error("linear_oauth_not_configured"); @@ -81,6 +86,22 @@ export function createFlow(input: { expiresAtMs, redirectUri: input.config.linearRedirectUri, ...(desktopRedirectUrl ? { desktopRedirectUrl } : {}), + ...(input.requestedBy?.betterAuthUserId + ? { + requestedByBetterAuthUserId: input.requestedBy.betterAuthUserId, + ...(input.requestedBy.betterAuthOrganizationId + ? { + requestedByBetterAuthOrganizationId: + input.requestedBy.betterAuthOrganizationId, + } + : {}), + ...(input.requestedBy.betterAuthTeamId + ? { + requestedByBetterAuthTeamId: input.requestedBy.betterAuthTeamId, + } + : {}), + } + : {}), status: "pending", }); @@ -250,14 +271,19 @@ export async function handleLinearCallback(input: { account: identity.account, autoProvision: input.config.betterAuthLinearAutoProvisionUsers, }); - const betterAuthSession = await resolveBetterAuthSession({ - runtime: input.betterAuthRuntime, - request: input.request, - }); + const betterAuthSession = flow.requestedByBetterAuthUserId + ? createRequestedBetterAuthSession({ flow }) + : await resolveBetterAuthSession({ + runtime: input.betterAuthRuntime, + request: input.request, + }); const linkedAccount = { ...identity.account, - ...(betterAuthLink.userId - ? { betterAuthUserId: betterAuthLink.userId } + ...((betterAuthSession.session?.userId ?? betterAuthLink.userId) + ? { + betterAuthUserId: + betterAuthSession.session?.userId ?? betterAuthLink.userId, + } : {}), ...(betterAuthLink.state ? { betterAuthLinkState: betterAuthLink.state } @@ -275,7 +301,8 @@ export async function handleLinearCallback(input: { accountId: identity.account.accountId, accountName: identity.account.accountName, accountEmail: identity.account.accountEmail, - betterAuthUserId: betterAuthLink.userId ?? null, + betterAuthUserId: + betterAuthSession.session?.userId ?? betterAuthLink.userId ?? null, betterAuthOrganizationId: betterAuthSession.session?.organizationId ?? null, betterAuthTeamId: betterAuthSession.session?.teamId ?? null, @@ -351,6 +378,46 @@ export async function handleLinearCallback(input: { } } +function createRequestedBetterAuthSession(input: { + readonly flow: OAuthFlow; +}): { + readonly enabled: true; + readonly accessControlMode: + | "better_auth_team_owned" + | "better_auth_session_owned" + | "better_auth_organization_owned"; + readonly session: { + readonly userId: string; + readonly email: null; + readonly name: null; + readonly organizationId: string | null; + readonly teamId: string | null; + readonly managementTokenProfileId: null; + }; +} { + let accessControlMode: + | "better_auth_team_owned" + | "better_auth_session_owned" + | "better_auth_organization_owned" = "better_auth_session_owned"; + if (input.flow.requestedByBetterAuthTeamId) { + accessControlMode = "better_auth_team_owned"; + } else if (input.flow.requestedByBetterAuthOrganizationId) { + accessControlMode = "better_auth_organization_owned"; + } + return { + enabled: true, + accessControlMode, + session: { + userId: input.flow.requestedByBetterAuthUserId ?? "", + email: null, + name: null, + organizationId: input.flow.requestedByBetterAuthOrganizationId ?? null, + teamId: input.flow.requestedByBetterAuthTeamId ?? null, + managementTokenProfileId: null, + }, + }; +} + function resolveLinearCallbackFlow(input: { readonly flowStore: FlowStore; readonly query: LinearOAuthModel["callbackQuery"]; diff --git a/services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts b/services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts index 182501486..8fc552601 100644 --- a/services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts +++ b/services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts @@ -1,25 +1,29 @@ import { Elysia } from "elysia"; +import type { BetterAuthRuntime } from "../../better-auth.ts"; import type { BrokerConfig } from "../../config.ts"; import type { FlowStore } from "../../flow-store.ts"; +import { resolveBetterAuthSession } from "../better-auth/session.ts"; import { LinearOAuthModel } from "./model.ts"; import { createFlow, isTruthy, redirect } from "./service.ts"; type CreateLinearOAuthStartRoutesPluginOptions = { readonly config: BrokerConfig; readonly flowStore: FlowStore; + readonly betterAuthRuntime: BetterAuthRuntime; }; export function createLinearOAuthStartRoutesPlugin({ config, flowStore, + betterAuthRuntime, }: CreateLinearOAuthStartRoutesPluginOptions) { return new Elysia({ name: "hack-auth-broker.linear-oauth.start-routes", }) .all( "/v1/auth/linear/start", - ({ query, set }) => { + async ({ query, request, set }) => { if (!config.linearClientId) { set.status = 412; return { @@ -27,10 +31,29 @@ export function createLinearOAuthStartRoutesPlugin({ error: "linear_oauth_not_configured", } as const; } + const sessionResolution = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (sessionResolution.enabled && !sessionResolution.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } const flow = createFlow({ config, flowStore, query, + requestedBy: sessionResolution.session + ? { + betterAuthUserId: sessionResolution.session.userId, + betterAuthOrganizationId: + sessionResolution.session.organizationId, + betterAuthTeamId: sessionResolution.session.teamId, + } + : null, }); if (isTruthy(query.redirect)) { return redirect(flow.authorizeUrl); @@ -44,7 +67,7 @@ export function createLinearOAuthStartRoutesPlugin({ ) .all( "/linear/start", - ({ query, set }) => { + async ({ query, request, set }) => { if (!config.linearClientId) { set.status = 412; return { @@ -52,10 +75,29 @@ export function createLinearOAuthStartRoutesPlugin({ error: "linear_oauth_not_configured", } as const; } + const sessionResolution = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (sessionResolution.enabled && !sessionResolution.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } const flow = createFlow({ config, flowStore, query, + requestedBy: sessionResolution.session + ? { + betterAuthUserId: sessionResolution.session.userId, + betterAuthOrganizationId: + sessionResolution.session.organizationId, + betterAuthTeamId: sessionResolution.session.teamId, + } + : null, }); return redirect(flow.authorizeUrl); }, diff --git a/services/auth-broker/src/types.ts b/services/auth-broker/src/types.ts index 225952acf..1bc4741fe 100644 --- a/services/auth-broker/src/types.ts +++ b/services/auth-broker/src/types.ts @@ -45,6 +45,9 @@ export type OAuthFlow = { readonly expiresAtMs: number; readonly redirectUri: string; readonly desktopRedirectUrl?: string; + readonly requestedByBetterAuthUserId?: string; + readonly requestedByBetterAuthOrganizationId?: string; + readonly requestedByBetterAuthTeamId?: string; installationId?: string; status: FlowStatus; account?: OAuthFlowAccount; diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 35e7b8526..31e5b08d3 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -2391,6 +2391,58 @@ describe("auth broker github flow routes", () => { expect(pollPayload.status.setDefault).toBe(true); }); + test("linear start route requires Hack auth when Better Auth is enabled", async () => { + const flowStore = new FlowStore(); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + + const startResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/start?profile=default") + ); + expect(startResponse.status).toBe(401); + const payload = (await startResponse.json()) as { + readonly ok: false; + readonly error: string; + }; + expect(payload.ok).toBe(false); + expect(payload.error).toBe("better_auth_session_required"); + }); + + test("linear start route persists Hack account ownership from management token", async () => { + await withManagementTokenSecret("broker-management-secret", async () => { + const flowStore = new FlowStore(); + const managementToken = issueBrokerManagementToken({ + userId: "better-auth-linear-user", + organizationId: "better-auth-org", + teamId: "better-auth-team", + }); + const app = createAuthBrokerApp({ + config: createTestConfig(), + flowStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession(null), + }); + + const startResponse = await app.handle( + new Request("http://localhost/v1/auth/linear/start?profile=default", { + headers: managementToken + ? { + authorization: `Bearer ${managementToken.token}`, + } + : {}, + }) + ); + expect(startResponse.status).toBe(200); + const startPayload = (await startResponse.json()) as StartFlowResponse; + const flow = flowStore.getById(startPayload.flow.flowId); + expect(flow?.requestedByBetterAuthUserId).toBe("better-auth-linear-user"); + expect(flow?.requestedByBetterAuthOrganizationId).toBe("better-auth-org"); + expect(flow?.requestedByBetterAuthTeamId).toBe("better-auth-team"); + }); + }); + test("linear start route persists desktop redirect URL for browser handoff", async () => { const flowStore = new FlowStore(); const app = createAuthBrokerApp({ @@ -2414,41 +2466,29 @@ describe("auth broker github flow routes", () => { await withManagementTokenSecret("broker-management-secret", async () => { const flowStore = new FlowStore(); const connectionStore = new InMemoryLinearConnectionStore(); + const managementToken = issueBrokerManagementToken({ + userId: "better-auth-linear-user", + organizationId: "better-auth-org", + teamId: "better-auth-team", + }); const app = createAuthBrokerApp({ config: createTestConfig(), flowStore, linearConnectionStore: connectionStore, betterAuthRuntime: createBetterAuthRuntimeWithSession( - withActiveTeam( - { - session: { - id: "sess-linear", - userId: "better-auth-linear-user", - expiresAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - token: "token-linear", - }, - user: { - id: "better-auth-linear-user", - email: "linear@example.com", - emailVerified: true, - name: "Linear User", - createdAt: new Date(), - updatedAt: new Date(), - }, - }, - { - organizationId: "better-auth-org", - teamId: "better-auth-team", - } - ), + null, createBetterAuthDb([{ id: "better-auth-linear-user" }]) ), }); const startResponse = await app.handle( - new Request("http://localhost/v1/auth/linear/start?profile=work") + new Request("http://localhost/v1/auth/linear/start?profile=work", { + headers: managementToken + ? { + authorization: `Bearer ${managementToken.token}`, + } + : {}, + }) ); expect(startResponse.status).toBe(200); const startPayload = (await startResponse.json()) as StartFlowResponse; @@ -2561,7 +2601,14 @@ describe("auth broker github flow routes", () => { const connectionsResponse = await app.handle( new Request( - "http://localhost/v1/auth/linear/connections?profileId=work" + "http://localhost/v1/auth/linear/connections?profileId=work", + { + headers: claimPayload.status.managementToken + ? { + authorization: `Bearer ${claimPayload.status.managementToken}`, + } + : {}, + } ) ); expect(connectionsResponse.status).toBe(200); @@ -2613,42 +2660,31 @@ describe("auth broker github flow routes", () => { await withManagementTokenSecret("broker-management-secret", async () => { const flowStore = new FlowStore(); const connectionStore = new InMemoryLinearConnectionStore(); + const managementToken = issueBrokerManagementToken({ + userId: "better-auth-linear-user", + organizationId: "better-auth-org", + teamId: "better-auth-team", + }); const app = createAuthBrokerApp({ config: createTestConfig(), flowStore, linearConnectionStore: connectionStore, betterAuthRuntime: createBetterAuthRuntimeWithSession( - withActiveTeam( - { - session: { - id: "sess-linear-open-hack", - userId: "better-auth-linear-user", - expiresAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - token: "token-linear", - }, - user: { - id: "better-auth-linear-user", - email: "linear@example.com", - emailVerified: true, - name: "Linear User", - createdAt: new Date(), - updatedAt: new Date(), - }, - }, - { - organizationId: "better-auth-org", - teamId: "better-auth-team", - } - ), + null, createBetterAuthDb([{ id: "better-auth-linear-user" }]) ), }); const startResponse = await app.handle( new Request( - "http://localhost/v1/auth/linear/start?profile=work&desktopRedirectUrl=hack-dev%3A%2F%2Fauth%2Flinear%2Fcallback" + "http://localhost/v1/auth/linear/start?profile=work&desktopRedirectUrl=hack-dev%3A%2F%2Fauth%2Flinear%2Fcallback", + { + headers: managementToken + ? { + authorization: `Bearer ${managementToken.token}`, + } + : {}, + } ) ); expect(startResponse.status).toBe(200); @@ -2949,6 +2985,11 @@ describe("auth broker github flow routes", () => { await withManagementTokenSecret("broker-management-secret", async () => { const flowStore = new FlowStore(); const connectionStore = new InMemoryLinearConnectionStore(); + const startManagementToken = issueBrokerManagementToken({ + userId: "better-auth-linear-user", + organizationId: "better-auth-org", + teamId: "better-auth-team", + }); const app = createAuthBrokerApp({ config: createTestConfig(), flowStore, @@ -2960,7 +3001,13 @@ describe("auth broker github flow routes", () => { }); const startResponse = await app.handle( - new Request("http://localhost/v1/auth/linear/start?profile=work") + new Request("http://localhost/v1/auth/linear/start?profile=work", { + headers: startManagementToken + ? { + authorization: `Bearer ${startManagementToken.token}`, + } + : {}, + }) ); expect(startResponse.status).toBe(200); const startPayload = (await startResponse.json()) as StartFlowResponse; @@ -3077,7 +3124,7 @@ describe("auth broker github flow routes", () => { }; expect(connectionsPayload.ok).toBe(true); expect(connectionsPayload.accessControlMode).toBe( - "better_auth_session_owned" + "better_auth_team_owned" ); expect(connectionsPayload.connections).toHaveLength(1); expect(connectionsPayload.connections[0]?.profileId).toBe("work"); diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index 1be1948c6..fc15b75e5 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -364,7 +364,8 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ }, { name: "connections", - summary: "List broker-owned Linear connections for the current Hack account", + summary: + "List broker-owned Linear connections for the current Hack account", scope: "global", allowWhenDisabled: true, handler: async ({ ctx, args }) => { @@ -2121,6 +2122,16 @@ async function runLinearBrokerOAuthFlow( } | { readonly ok: false; readonly error: string } > { + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + if (!brokerAuth.ok) { + return { + ok: false, + error: brokerAuth.error, + }; + } const startUrl = new URL( "/v1/auth/linear/start", `${input.brokerConfig.baseUrl}/` @@ -2130,11 +2141,17 @@ async function runLinearBrokerOAuthFlow( const start = await fetchJson({ url: startUrl.toString(), + init: { + headers: brokerAuth.headers, + }, }); if (!start.ok) { return { ok: false, - error: `Auth broker Linear start failed: ${start.error}`, + error: normalizeBrokerProtectedLinearError({ + error: start.error, + profileId: brokerAuth.profileId, + }), }; } @@ -6291,7 +6308,7 @@ function parseLinearConnectionSummary(input: { const id = readOptionalString(input.value.id); const createdAt = readOptionalString(input.value.createdAt); const updatedAt = readOptionalString(input.value.updatedAt); - if (!id || !createdAt || !updatedAt) { + if (!(id && createdAt && updatedAt)) { return null; } return { From 7fcec0f0b173b133a8bd18455d5ad6f8051737f1 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 16:57:37 -0500 Subject: [PATCH 28/54] Serve provider callback errors without edge 502s --- services/auth-broker/src/modules/github-oauth/service.ts | 4 ++-- services/auth-broker/src/modules/linear-oauth/service.ts | 8 ++++---- services/auth-broker/tests/index.test.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/services/auth-broker/src/modules/github-oauth/service.ts b/services/auth-broker/src/modules/github-oauth/service.ts index 826ec14ab..6a709d5a3 100644 --- a/services/auth-broker/src/modules/github-oauth/service.ts +++ b/services/auth-broker/src/modules/github-oauth/service.ts @@ -233,7 +233,7 @@ export async function handleGitHubCallback(input: { return renderCallbackPage({ title: "Token exchange failed", body: exchange.error, - statusCode: 502, + statusCode: 200, }); } @@ -250,7 +250,7 @@ export async function handleGitHubCallback(input: { return renderCallbackPage({ title: "GitHub account lookup failed", body: identity.error, - statusCode: 502, + statusCode: 200, }); } diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts index 855320315..a0a55ff1a 100644 --- a/services/auth-broker/src/modules/linear-oauth/service.ts +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -226,7 +226,7 @@ export async function handleLinearCallback(input: { status: "error", title: "Token exchange failed", body: exchange.error, - statusCode: 502, + statusCode: 200, ...(flow.desktopRedirectUrl ? { actions: [ @@ -252,7 +252,7 @@ export async function handleLinearCallback(input: { status: "error", title: "Linear account lookup failed", body: identity.error, - statusCode: 502, + statusCode: 200, ...(flow.desktopRedirectUrl ? { actions: [ @@ -364,14 +364,14 @@ export async function handleLinearCallback(input: { status: "error", title: "Connection failed", body: "Linear authorization could not be completed. Return to Hack and try again.", - statusCode: 502, + statusCode: 200, actions, }); } catch { return renderCallbackPage({ title: "Connection failed", body: "Linear authorization could not be completed. Return to Hack and try again.", - statusCode: 502, + statusCode: 200, actions, }); } diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 31e5b08d3..dab000478 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -2789,7 +2789,7 @@ describe("auth broker github flow routes", () => { `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` ) ); - expect(callbackResponse.status).toBe(502); + expect(callbackResponse.status).toBe(200); const callbackHtml = await callbackResponse.text(); expect(callbackHtml).toContain("Connection failed"); expect(callbackHtml).toContain("Return to Hack"); @@ -2920,7 +2920,7 @@ describe("auth broker github flow routes", () => { `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` ) ); - expect(callbackResponse.status).toBe(502); + expect(callbackResponse.status).toBe(200); const callbackHtml = await callbackResponse.text(); expect(callbackHtml).toContain("Connection failed"); expect(callbackHtml).toContain("Open Hack"); @@ -2970,7 +2970,7 @@ describe("auth broker github flow routes", () => { `http://localhost/linear/callback?state=${encodeURIComponent(flow.state)}&code=test-code` ) ); - expect(callbackResponse.status).toBe(502); + expect(callbackResponse.status).toBe(200); const callbackHtml = await callbackResponse.text(); expect(callbackHtml).toContain("Connection failed"); expect(callbackHtml).toContain("Open Hack"); From f9b2b6e244fd7bd1722b8e454aef4d4eecf9c46f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 17:27:17 -0500 Subject: [PATCH 29/54] Fix desktop Linear OAuth handoff start flow --- .../HackCLIService/HackCLIClient.swift | 68 ++------ .../extensions/linear/commands.ts | 155 +++++++++++++++--- tests/linear-commands.test.ts | 39 ++++- 3 files changed, 176 insertions(+), 86 deletions(-) diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 1df24374c..9df54c016 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -1067,60 +1067,22 @@ public actor HackCLIClient { profileId: String, setDefault: Bool ) async throws -> LinearOAuthFlowStartResponse { - let desktopRedirectURL = desktopLinearOAuthCallbackURL() - var lastError: String? = nil - for candidate in resolveAuthServerCandidates() { - guard - let startURL = buildAuthURL( - base: candidate, - path: "/v1/auth/linear/start", - queryItems: [ - URLQueryItem(name: "profile", value: profileId), - URLQueryItem(name: "setDefault", value: setDefault ? "1" : "0"), - URLQueryItem(name: "set_default", value: setDefault ? "1" : "0"), - URLQueryItem(name: "desktopRedirectUrl", value: desktopRedirectURL), - ] - ) - else { - continue - } - do { - let body = try await fetchAuthBody(url: startURL) - if let direct = tryDecodeLenient(LinearOAuthFlowStartResponse.self, from: body) { - return direct - } - if let wrapped = tryDecodeLenient(LinearOAuthStartEnvelope.self, from: body), wrapped.ok { - guard - let statusURL = buildAuthURLWithQuery( - urlString: wrapped.flow.pollUrl, - queryItems: [ - URLQueryItem(name: "deviceCode", value: wrapped.flow.deviceCode), - URLQueryItem(name: "claim", value: "1"), - ] - )?.absoluteString - else { - throw HackCLIError.network("Auth flow returned an invalid status URL.") - } - return LinearOAuthFlowStartResponse( - ok: true, - flowId: wrapped.flow.flowId, - profileId: wrapped.flow.profileId, - setDefault: wrapped.flow.setDefault, - authorizeUrl: wrapped.flow.authorizeUrl, - statusUrl: statusURL, - expiresAt: wrapped.flow.expiresAt - ) - } - throw HackCLIError.network("Auth server returned invalid JSON.") - } catch { - lastError = error.localizedDescription - } + var args = [ + "x", + "linear", + "oauth-connect", + "--json", + "--start-only", + "--profile", + profileId, + "--desktop-redirect-url", + desktopLinearOAuthCallbackURL(), + ] + if setDefault { + args.append("--set-default") } - - throw HackCLIError.network( - lastError - ?? "Unable to reach any configured auth broker endpoint. Check network/broker status and retry." - ) + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearOAuthFlowStartResponse.self, result: result) } /// Polls Linear broker OAuth state and imports claimed tokens into local keychain-backed profiles. diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index fc15b75e5..c08c7d8e2 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -1781,7 +1781,40 @@ async function handleLinearOAuthConnectCommand(input: { | { readonly ok: false; readonly error: string }; if (shouldUseBrokerOAuthFlow({ parsed: parsed.value })) { + const brokerFlow = await startLinearBrokerOAuthFlow({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + parsed: parsed.value, + brokerConfig: resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + }), + profileId: resolved.profileId, + }); + if (parsed.value.startOnly) { + if (!brokerFlow.ok) { + input.ctx.logger.error({ message: brokerFlow.error }); + return 1; + } + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify(buildLinearOAuthStartPayload({ flow: brokerFlow.flow }), null, 2)}\n` + ); + } else { + await display.panel({ + title: "Linear OAuth", + tone: "info", + lines: [ + "Open this URL in your browser to continue:", + brokerFlow.flow.authorizeUrl, + "", + `Status URL: ${buildLinearOAuthStatusUrl({ pollUrl: brokerFlow.flow.pollUrl, deviceCode: brokerFlow.flow.deviceCode })}`, + ], + }); + } + return 0; + } + oauthFlow = await runLinearBrokerOAuthFlow({ + controlPlaneConfig: input.ctx.controlPlaneConfig, parsed: parsed.value, brokerConfig: resolveOAuthBrokerRuntimeConfig({ controlPlaneConfig: input.ctx.controlPlaneConfig, @@ -2033,6 +2066,7 @@ type OAuthConnectRuntime = { }; type OAuthBrokerConnectRuntime = { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; readonly parsed: OAuthConnectArgs; readonly brokerConfig: OAuthBrokerRuntimeConfig; readonly profileId: string; @@ -2106,19 +2140,12 @@ async function runLinearOAuthFlow(input: OAuthConnectRuntime): Promise< }; } -async function runLinearBrokerOAuthFlow( +async function startLinearBrokerOAuthFlow( input: OAuthBrokerConnectRuntime ): Promise< | { readonly ok: true; - readonly tokenExchange: { - readonly token: string; - readonly expiresAt?: string; - readonly refreshToken?: string; - readonly refreshTokenExpiresAt?: string; - readonly managementToken?: string; - readonly managementTokenExpiresAt?: string; - }; + readonly flow: BrokerStartFlowPayload; } | { readonly ok: false; readonly error: string } > { @@ -2138,6 +2165,12 @@ async function runLinearBrokerOAuthFlow( ); startUrl.searchParams.set("profile", input.profileId); startUrl.searchParams.set("setDefault", input.parsed.setDefault ? "1" : "0"); + if (input.parsed.desktopRedirectUrl) { + startUrl.searchParams.set( + "desktopRedirectUrl", + input.parsed.desktopRedirectUrl + ); + } const start = await fetchJson({ url: startUrl.toString(), @@ -2155,18 +2188,80 @@ async function runLinearBrokerOAuthFlow( }; } + return { + ok: true, + flow: start.value.flow, + }; +} + +function buildLinearOAuthStatusUrl(input: { + readonly pollUrl: string; + readonly deviceCode: string; +}): string { + const statusUrl = new URL(input.pollUrl); + statusUrl.searchParams.set("deviceCode", input.deviceCode); + statusUrl.searchParams.set("claim", "1"); + return statusUrl.toString(); +} + +function buildLinearOAuthStartPayload(input: { + readonly flow: BrokerStartFlowPayload; +}): { + readonly ok: true; + readonly flowId: string; + readonly profileId: string; + readonly setDefault: boolean; + readonly authorizeUrl: string; + readonly statusUrl: string; + readonly expiresAt: string; +} { + return { + ok: true, + flowId: input.flow.flowId, + profileId: input.flow.profileId, + setDefault: input.flow.setDefault, + authorizeUrl: input.flow.authorizeUrl, + statusUrl: buildLinearOAuthStatusUrl({ + pollUrl: input.flow.pollUrl, + deviceCode: input.flow.deviceCode, + }), + expiresAt: input.flow.expiresAt, + }; +} + +async function runLinearBrokerOAuthFlow( + input: OAuthBrokerConnectRuntime +): Promise< + | { + readonly ok: true; + readonly tokenExchange: { + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; + }; + } + | { readonly ok: false; readonly error: string } +> { + const start = await startLinearBrokerOAuthFlow(input); + if (!start.ok) { + return start; + } + const launch = await launchLinearOAuthBrowser({ parsed: input.parsed, - authorizeUrl: start.value.flow.authorizeUrl, - pollUrl: start.value.flow.pollUrl, + authorizeUrl: start.flow.authorizeUrl, + pollUrl: start.flow.pollUrl, }); if (!launch.ok) { return launch; } - const expiresAtMs = Date.parse(start.value.flow.expiresAt); - const statusUrl = new URL(start.value.flow.pollUrl); - statusUrl.searchParams.set("deviceCode", start.value.flow.deviceCode); + const expiresAtMs = Date.parse(start.flow.expiresAt); + const statusUrl = new URL(start.flow.pollUrl); + statusUrl.searchParams.set("deviceCode", start.flow.deviceCode); statusUrl.searchParams.set("claim", "1"); while (true) { @@ -6313,18 +6408,17 @@ function parseLinearConnectionSummary(input: { } return { id, - profileId: readOptionalString(input.value.profileId), - accountId: readOptionalString(input.value.accountId), - accountName: readOptionalString(input.value.accountName), - accountEmail: readOptionalString(input.value.accountEmail), - authRef: readOptionalString(input.value.authRef), - betterAuthUserId: readOptionalString(input.value.betterAuthUserId), - betterAuthOrganizationId: readOptionalString( - input.value.betterAuthOrganizationId - ), - betterAuthTeamId: readOptionalString(input.value.betterAuthTeamId), - organizationId: readOptionalString(input.value.organizationId), - teamId: readOptionalString(input.value.teamId), + profileId: readOptionalString(input.value.profileId) ?? null, + accountId: readOptionalString(input.value.accountId) ?? null, + accountName: readOptionalString(input.value.accountName) ?? null, + accountEmail: readOptionalString(input.value.accountEmail) ?? null, + authRef: readOptionalString(input.value.authRef) ?? null, + betterAuthUserId: readOptionalString(input.value.betterAuthUserId) ?? null, + betterAuthOrganizationId: + readOptionalString(input.value.betterAuthOrganizationId) ?? null, + betterAuthTeamId: readOptionalString(input.value.betterAuthTeamId) ?? null, + organizationId: readOptionalString(input.value.organizationId) ?? null, + teamId: readOptionalString(input.value.teamId) ?? null, metadata: isRecord(input.value.metadata) ? input.value.metadata : ({} as Record), @@ -6832,6 +6926,7 @@ type ConnectArgs = { type OAuthConnectArgs = { profileId?: string; setDefault: boolean; + startOnly: boolean; clientId?: string; clientSecret?: string; clientSecretStdin: boolean; @@ -6844,6 +6939,7 @@ type OAuthConnectArgs = { tokenEnv?: string; authRef?: string; service?: string; + desktopRedirectUrl?: string; noOpen: boolean; json: boolean; }; @@ -7348,6 +7444,7 @@ function parseOAuthConnectArgs(input: { | { readonly ok: false; readonly error: string } { const value: OAuthConnectArgs = { setDefault: false, + startOnly: false, clientSecretStdin: false, noOpen: false, json: false, @@ -7363,6 +7460,10 @@ function parseOAuthConnectArgs(input: { value.clientSecretStdin = true; continue; } + if (token === "--start-only") { + value.startOnly = true; + continue; + } if (token === "--no-open") { value.noOpen = true; continue; @@ -7390,6 +7491,7 @@ function parseOAuthConnectArgs(input: { "token-env": "tokenEnv", "auth-ref": "authRef", service: "service", + "desktop-redirect-url": "desktopRedirectUrl", }, }); if (handled.ok) { @@ -7881,6 +7983,7 @@ export const __testOnly = { parseConnectArgs, parseConnectionsArgs, parseDeliveriesArgs, + parseOAuthConnectArgs, parseRunAutosyncArgs, parseProjectBindArgs, parseProjectLinkArgs, diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index 5c1d3a7ba..1456c5eb2 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -114,6 +114,35 @@ test("parseConnectArgs parses token connection flags", () => { }); }); +test("parseOAuthConnectArgs parses desktop handoff flags", () => { + const parsed = __testOnly.parseOAuthConnectArgs({ + args: [ + "--profile", + "work", + "--set-default", + "--start-only", + "--desktop-redirect-url", + "hack-dev://auth/linear/callback", + "--json", + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + setDefault: true, + startOnly: true, + clientSecretStdin: false, + desktopRedirectUrl: "hack-dev://auth/linear/callback", + noOpen: false, + json: true, + }); +}); + test("parseSyncIssueArgs rejects invalid direction", () => { const parsed = __testOnly.parseSyncIssueArgs({ args: ["--from", "other"], @@ -162,13 +191,7 @@ test("parseSyncProjectArgs parses owner filter and limits", () => { test("parseConnectionsArgs parses broker connection filters", () => { const parsed = __testOnly.parseConnectionsArgs({ - args: [ - "--profile", - "work", - "--organization-id", - "shared-org", - "--json", - ], + args: ["--profile", "work", "--organization-id", "shared-org", "--json"], }); expect(parsed.ok).toBe(true); @@ -403,6 +426,7 @@ test("oauth connect prefers broker flow when no local oauth overrides are provid const useBroker = __testOnly.shouldUseBrokerOAuthFlow({ parsed: { setDefault: false, + startOnly: false, clientSecretStdin: false, noOpen: false, json: false, @@ -416,6 +440,7 @@ test("oauth connect disables broker flow when local oauth overrides are provided const useBroker = __testOnly.shouldUseBrokerOAuthFlow({ parsed: { setDefault: false, + startOnly: false, clientId: "client-id", clientSecretStdin: false, noOpen: false, From e955826d6ed6653d3703ba50fa7b3765f6586f98 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 17:44:47 -0500 Subject: [PATCH 30/54] docs: plan linear local access seeding --- ...3-07-linear-local-access-seeding-design.md | 153 ++++++++++++++ ...-03-07-linear-local-access-seeding-plan.md | 196 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 docs/plans/2026-03-07-linear-local-access-seeding-design.md create mode 100644 docs/plans/2026-03-07-linear-local-access-seeding-plan.md diff --git a/docs/plans/2026-03-07-linear-local-access-seeding-design.md b/docs/plans/2026-03-07-linear-local-access-seeding-design.md new file mode 100644 index 000000000..e6debe9ec --- /dev/null +++ b/docs/plans/2026-03-07-linear-local-access-seeding-design.md @@ -0,0 +1,153 @@ +# Linear Local Access Seeding Design + +## Context + +Hack now owns Linear connection state remotely in the auth broker, but the current sync engine still requires a locally stored Linear token envelope on each Mac. After the browser callback flow completes once, the provider token is only available through the short-lived OAuth flow claim. If a second Mac signs in later, or if the original Mac loses keychain state, Hack can see the remote Linear connection but cannot reseed local access without sending the user back through Linear OAuth again. + +That creates a broken product boundary: +- Hack account state says the Linear account exists. +- The app and CLI still fail local sync work because local access is missing. +- The default UX collapses into confusing states such as `0 provider accounts`, `Waiting for browser auth`, or generic reconnect instructions. + +## Goals + +- Let a signed-in Hack client reseed local Linear access from an existing Hack-owned remote connection. +- Keep the default UX flattened to `Connected`, `Needs attention`, `Connecting`, and `Not connected`. +- Avoid leaking raw provider tokens into normal UI or unprotected broker routes. +- Keep remote custody encrypted at rest inside the broker. +- Keep the existing local keychain-backed profile model for the current sync engine. + +## Non-Goals + +- Replacing the current local Linear token/profile model with broker-proxied execution. +- Generalizing remote encrypted custody to all providers in this pass. +- Exposing remote/local implementation detail in standard desktop UX. + +## Approaches Considered + +### 1. Repair-only OAuth replay + +Keep remote connection state as metadata only. When local access is missing, force the user back through Linear OAuth again. + +Pros: +- Small change. + +Cons: +- Does not solve cross-Mac portability. +- Keeps provider reconnect as the only recovery mechanism. +- Conflicts with the desired Hack-account-owned connection model. + +### 2. Broker-held encrypted token custody with protected local seeding (recommended) + +Persist the Linear token envelope encrypted against the Hack-owned remote connection record. Expose a protected broker endpoint that decrypts, refreshes if needed, and returns a claimable local envelope to authenticated clients. CLI and desktop import that envelope into local keychain-backed profiles. + +Pros: +- Solves the real portability gap. +- Preserves the current local sync engine. +- Keeps the normal UI simple. +- Sets up the same pattern for future remote env/secret portability work. + +Cons: +- Requires broker schema/config changes and secure encryption handling. +- Requires keeping the remote stored envelope fresh when local refresh rotates tokens. + +### 3. Broker-proxied Linear API execution + +Never seed local access. Route Linear sync work through the broker. + +Pros: +- Hides provider tokens from Macs. + +Cons: +- Major architecture change. +- Pulls the sync engine into the broker. +- Too much scope for this ticket. + +## Recommended Design + +Adopt approach 2. + +The broker should store an encrypted Linear token envelope alongside the Hack-owned connection record. That encrypted envelope becomes the source for reseeding local access on any signed-in Mac. The desktop and CLI still store local keychain-backed profiles because the existing sync engine expects them, but the user-facing contract becomes straightforward: +- `Connected`: Hack has the account and this Mac has local access. +- `Needs attention`: Hack has the account, but this Mac needs local access repaired. +- `Connecting`: OAuth flow is currently in progress. +- `Not connected`: no Hack-owned Linear account exists. + +## Architecture + +### Broker storage + +Extend `linear_connections` with encrypted provider token custody fields. The stored payload should contain: +- access token +- access token expiry +- refresh token +- refresh token expiry +- last updated timestamp + +The payload should be encrypted using a dedicated broker encryption key from environment configuration. The broker should never expose the encrypted payload directly. + +### Broker endpoints + +Add a protected broker endpoint for reseeding local access, keyed by `profileId` and constrained by Better Auth ownership checks. The endpoint should: +- look up the Hack-owned Linear connection +- decrypt the stored envelope +- refresh it first if the access token is missing or close to expiry and a refresh token exists +- persist the refreshed encrypted envelope back to the connection row if rotation happened +- return a plain claim payload only to the authorized caller + +Add a protected broker endpoint to update the stored envelope from a local client after local refresh rotates credentials. + +### Callback flow + +During successful Linear callback completion: +- persist/update the Hack-owned connection row +- persist/update the encrypted token envelope on that row +- mark the flow complete for immediate local claim + +This keeps the browser flow fast for the Mac that initiated it while also establishing durable remote custody. + +### CLI/local profile behavior + +Add a CLI command that imports local Linear access from the broker-held connection state into the existing keychain-backed profile. It should reuse the current `saveLinearToken()` path so the local sync engine remains unchanged. + +Local token refresh should also push the refreshed envelope back to the broker when the profile still has broker-owned access. That keeps the remote stored envelope current and avoids reseeding stale refresh tokens later. + +### Desktop UX + +The Linear settings page should derive one flattened state per profile row: +- `Connected` +- `Connecting` +- `Needs attention` +- `Not connected` + +If a Hack-owned connection exists but the local profile lacks a resolved token, show a single repair action such as `Repair access`. Do not expose `remote` vs `local` wording in the default row chrome. + +## Failure Handling + +If remote token custody is unavailable or corrupted: +- the broker seed endpoint should return a controlled error +- the desktop should map it to `Needs attention` +- the repair action should fall back to full reconnect only if reseeding cannot work + +If refresh fails because the stored refresh token is revoked: +- mark the seed response as a repair failure +- keep the connection row, but show reconnect-required state + +## Testing + +### Broker +- connection callback persists encrypted token custody +- seed endpoint returns decrypted envelope only for authorized owners +- seed endpoint refreshes stale tokens and updates remote custody +- update endpoint replaces stored custody after local refresh +- missing encryption key fails safely + +### CLI +- import-from-broker stores local token envelope in the selected profile +- refreshed local tokens sync custody back to the broker when broker-owned access exists +- normal local-only profiles keep working unchanged + +### Desktop +- remote connection plus missing local token maps to `Needs attention` +- repair action imports local access and flips the row to `Connected` +- no more `0 provider accounts` when Hack already owns the account diff --git a/docs/plans/2026-03-07-linear-local-access-seeding-plan.md b/docs/plans/2026-03-07-linear-local-access-seeding-plan.md new file mode 100644 index 000000000..13e8f0fb7 --- /dev/null +++ b/docs/plans/2026-03-07-linear-local-access-seeding-plan.md @@ -0,0 +1,196 @@ +# Linear Local Access Seeding Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Let signed-in Hack clients reseed local Linear access from an existing Hack-owned remote connection without forcing users back through Linear OAuth. + +**Architecture:** Persist an encrypted Linear token envelope on the broker-owned connection row, expose a protected local-seeding endpoint, and keep that remote custody fresh whenever local token refresh rotates credentials. Desktop and CLI continue using local keychain-backed profiles, while the default UX stays flattened to connected-or-not-connected with repair only when needed. + +**Tech Stack:** Bun, Elysia, Drizzle, Better Auth, macOS SwiftUI, Hack CLI, Railway + +--- + +### Task 1: Add failing broker tests for encrypted Linear token custody + +**Files:** +- Modify: `services/auth-broker/tests/linear-callback-routes.test.ts` +- Create: `services/auth-broker/tests/linear-token-custody.test.ts` +- Modify: `services/auth-broker/tests/index.test.ts` + +**Step 1: Write the failing test** +Add tests for: +- successful Linear callback persists encrypted token custody on the connection +- protected seed endpoint returns a decrypted token envelope for the owning Hack account +- refresh/update endpoint rejects unauthorized callers + +**Step 2: Run test to verify it fails** +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts tests/linear-token-custody.test.ts +``` +Expected: FAIL because the broker does not yet store or reseed durable Linear token custody. + +**Step 3: Write minimal implementation** +Touch only broker schema/store/service code until the custody contract exists. + +**Step 4: Run test to verify it passes** +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts tests/linear-token-custody.test.ts +``` +Expected: PASS + +**Step 5: Commit** +```bash +git add services/auth-broker/tests/linear-callback-routes.test.ts services/auth-broker/tests/linear-token-custody.test.ts services/auth-broker/tests/index.test.ts services/auth-broker/src/db/schema.ts services/auth-broker/src/config.ts services/auth-broker/src/modules/linear-connections/service.ts services/auth-broker/src/modules/linear-connections/plugin.ts services/auth-broker/src/modules/linear-oauth/service.ts + git commit -m "feat: persist encrypted linear token custody" +``` + +### Task 2: Add broker seed/update endpoints for local Linear access + +**Files:** +- Modify: `services/auth-broker/src/modules/linear-connections/plugin.ts` +- Modify: `services/auth-broker/src/modules/linear-connections/service.ts` +- Modify: `services/auth-broker/tests/linear-token-custody.test.ts` + +**Step 1: Write the failing test** +Add tests for: +- `POST /v1/auth/linear/connections/seed` returns seedable local-access payload +- `POST /v1/auth/linear/connections/update-local-access` updates encrypted custody +- seed refreshes stale access tokens before returning payload when refresh token exists + +**Step 2: Run test to verify it fails** +Run: +```bash +bun test --cwd services/auth-broker tests/linear-token-custody.test.ts +``` +Expected: FAIL + +**Step 3: Write minimal implementation** +Implement protected broker routes and reuse Better Auth ownership checks already used by connection listing. + +**Step 4: Run test to verify it passes** +Run: +```bash +bun test --cwd services/auth-broker tests/linear-token-custody.test.ts +``` +Expected: PASS + +**Step 5: Commit** +```bash +git add services/auth-broker/src/modules/linear-connections/plugin.ts services/auth-broker/src/modules/linear-connections/service.ts services/auth-broker/tests/linear-token-custody.test.ts + git commit -m "feat: add broker linear local-access seeding endpoints" +``` + +### Task 3: Add CLI import/update plumbing for broker-held Linear access + +**Files:** +- Modify: `src/control-plane/extensions/linear/auth.ts` +- Modify: `src/control-plane/extensions/linear/commands.ts` +- Modify: `tests/linear-commands.test.ts` + +**Step 1: Write the failing test** +Add tests for: +- `hack x linear seed-local-access --profile default --json` +- successful seed stores the local profile token envelope +- local refresh pushes updated custody back to the broker when broker-owned access exists + +**Step 2: Run test to verify it fails** +Run: +```bash +bun test tests/linear-commands.test.ts +``` +Expected: FAIL + +**Step 3: Write minimal implementation** +Add a CLI repair/import path that consumes the protected broker seed endpoint and writes through `saveLinearToken()`. + +**Step 4: Run test to verify it passes** +Run: +```bash +bun test tests/linear-commands.test.ts +``` +Expected: PASS + +**Step 5: Commit** +```bash +git add src/control-plane/extensions/linear/auth.ts src/control-plane/extensions/linear/commands.ts tests/linear-commands.test.ts + git commit -m "feat: seed local linear access from broker connection" +``` + +### Task 4: Flatten desktop Linear state around repairable local access + +**Files:** +- Modify: `apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift` +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` +- Modify: `apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift` + +**Step 1: Write the failing test** +Add tests for: +- remote connection plus missing local token -> `Needs attention` +- repair action imports local access and flips to `Connected` +- no `Waiting for browser auth` loop once remote connection exists but local access is missing + +**Step 2: Run test to verify it fails** +Run: +```bash +swift test --package-path apps/macos --filter LinearSyncUXModelTests +``` +Expected: FAIL + +**Step 3: Write minimal implementation** +Teach the app to use the new CLI repair/import path and flatten the visible state to connected/repair-only copy. + +**Step 4: Run test to verify it passes** +Run: +```bash +swift test --package-path apps/macos --filter LinearSyncUXModelTests +``` +Expected: PASS + +**Step 5: Commit** +```bash +git add apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift + git commit -m "feat: repair local linear access from hack account state" +``` + +### Task 5: Deploy and verify live Linear reseeding + +**Files:** +- Modify if needed: `services/auth-broker/README.md` +- Modify if needed: `services/auth-broker/.env.example` + +**Step 1: Run focused verification** +Run: +```bash +bun test --cwd services/auth-broker tests/linear-callback-routes.test.ts tests/linear-token-custody.test.ts +bun test tests/linear-commands.test.ts +swift test --package-path apps/macos --filter LinearSyncUXModelTests +``` +Expected: PASS + +**Step 2: Run full verification gates** +Run: +```bash +bun run build +bun run typecheck +bun run test +bun run check +hack ps +swift build --package-path apps/macos +``` +Expected: PASS + +**Step 3: Deploy broker** +Deploy auth-broker to Railway and verify: +- callback still succeeds +- seed endpoint returns protected data only to signed-in owners +- a fresh Mac/local profile can import Linear access from an existing Hack-owned connection without reauthorizing Linear + +**Step 4: Commit docs/config updates** +```bash +git add services/auth-broker/README.md services/auth-broker/.env.example docs/plans/2026-03-07-linear-local-access-seeding-design.md docs/plans/2026-03-07-linear-local-access-seeding-plan.md + git commit -m "docs: capture linear local access seeding rollout" +``` From 06cc4a9628d6ecbf346fbdb48e5478aec537da04 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 18:50:14 -0500 Subject: [PATCH 31/54] Seed local Linear access from Hack-owned connections --- .../DashboardFeature/DashboardModel.swift | 9 + .../SettingsOverlayView.swift | 258 +++++++++---- .../HackCLIService/HackCLIClient.swift | 20 +- .../Sources/HackDesktopModels/Models.swift | 83 +++++ packages/db/src/schema/core.ts | 4 + services/auth-broker/.env.example | 3 + services/auth-broker/README.md | 25 ++ services/auth-broker/src/app.ts | 7 + services/auth-broker/src/config.ts | 8 + services/auth-broker/src/db/schema.ts | 4 + .../linear-connections/local-access.ts | 208 +++++++++++ .../src/modules/linear-connections/plugin.ts | 238 +++++++++--- .../src/modules/linear-connections/service.ts | 305 +++++++++++++++- .../src/modules/linear-oauth/service.ts | 35 ++ services/auth-broker/tests/index.test.ts | 11 + .../tests/linear-ownership-compat.test.ts | 4 + .../tests/linear-store-schema.test.ts | 2 + .../tests/linear-token-custody.test.ts | 331 +++++++++++++++++ src/control-plane/extensions/linear/auth.ts | 38 ++ .../extensions/linear/commands.ts | 341 +++++++++++++++++- tests/linear-commands.test.ts | 17 + 21 files changed, 1839 insertions(+), 112 deletions(-) create mode 100644 services/auth-broker/src/modules/linear-connections/local-access.ts create mode 100644 services/auth-broker/tests/linear-token-custody.test.ts diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index b65e95925..72ab1aa29 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -805,6 +805,15 @@ public final class DashboardModel { } } + public func seedLinearLocalAccess(profileId: String) async -> LinearLocalAccessSeedResponse? { + do { + return try await client.seedLinearLocalAccess(profileId: profileId) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + public func inspectLinearStatus(profileId: String? = nil) async -> LinearStatusResponse? { do { return try await client.inspectLinearStatus(profileId: profileId) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index 6356066da..0442295f1 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -2852,6 +2852,7 @@ private struct LinearExtensionSettingsView: View { @State private var activeAuthFlowId: String? = nil @State private var activeAuthStatusURL: String? = nil @State private var disconnectingLinearProfiles: Set = [] + @State private var repairingLinearProfiles: Set = [] @State private var assigneeMappings: [LinearAssigneeMapping] = [] @State private var assigneeMappingProfile = "" @State private var assigneeMappingTeamId = "" @@ -2900,7 +2901,9 @@ private struct LinearExtensionSettingsView: View { await applyLinearEnabledToggle(newValue) } } - if isLoadingConfig || isSavingConfig || isLoadingDiagnostics || isAuthenticating { + if isLoadingConfig || isSavingConfig || isLoadingDiagnostics || isAuthenticating + || !repairingLinearProfiles.isEmpty + { ProgressView() .controlSize(.small) } @@ -2920,6 +2923,7 @@ private struct LinearExtensionSettingsView: View { ) } .adaptiveToolbarButtonProminent() + .disabled(!isAuthenticating && !repairingLinearProfiles.isEmpty) } else { Button("Sign in to Hack") { Task { _ = await model.loginHackAccount() } @@ -2929,15 +2933,25 @@ private struct LinearExtensionSettingsView: View { } if isAuthenticating { - StatusPill(text: "Waiting for browser auth", tone: .good) + StatusPill( + text: LinearConnectionPresentationState.connecting.label, + tone: .neutral + ) } else if let authFlowStatus { switch authFlowStatus.status { case "complete": - StatusPill(text: "Connected", tone: .good) + let state = linearConnectionNeedsAttention + ? LinearConnectionPresentationState.needsAttention + : LinearConnectionPresentationState.connected + StatusPill(text: state.label, tone: linearTone(for: state)) case "error", "expired": - StatusPill(text: "Connect failed", tone: .warn) + let state = LinearConnectionPresentationState.needsAttention + StatusPill(text: state.label, tone: linearTone(for: state)) default: - StatusPill(text: "Connect pending", tone: .neutral) + StatusPill( + text: LinearConnectionPresentationState.connecting.label, + tone: .neutral + ) } } @@ -2952,7 +2966,10 @@ private struct LinearExtensionSettingsView: View { VStack(alignment: .leading, spacing: 10) { ForEach(remoteLinearConnections) { connection in let profileId = connection.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let localProfile = linearProfiles.first(where: { $0.id == profileId }) + let localProfile = localLinearProfile(profileId: profileId) + let rowState = linearRemoteConnectionState(connection) + let needsRepair = rowState == .needsAttention + let rowActionProfileId = profileId.isEmpty ? nextLinearProfileId() : profileId let accountLabel = connection.accountName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? connection.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty @@ -2969,8 +2986,8 @@ private struct LinearExtensionSettingsView: View { StatusPill(text: "Default profile", tone: .good) } StatusPill( - text: localProfile == nil ? "Needs attention" : "Connected", - tone: localProfile == nil ? .warn : .good + text: rowState.label, + tone: linearTone(for: rowState) ) Spacer() if !profileId.isEmpty && resolvedDefaultProfile != profileId { @@ -2981,24 +2998,41 @@ private struct LinearExtensionSettingsView: View { } .adaptiveToolbarButton() } - Button { - Task { - await reconnectLinearProfile( - profileId.isEmpty ? nextLinearProfileId() : profileId, - setDefault: resolvedDefaultProfile == profileId - ) + if needsRepair { + Button { + Task { + await repairLinearLocalAccess( + profileId: rowActionProfileId, + setDefault: resolvedDefaultProfile == profileId + ) + } + } label: { + Label("Repair access", systemImage: "arrow.clockwise.circle") } - } label: { - Label( - localProfile == nil ? "Finish setup" : "Reconnect", - systemImage: "arrow.clockwise.circle" + .adaptiveToolbarButtonProminent() + .disabled( + isAuthenticating || + repairingLinearProfiles.contains(rowActionProfileId) || + (!profileId.isEmpty && disconnectingLinearProfiles.contains(profileId)) + ) + } else { + Button { + Task { + await reconnectLinearProfile( + rowActionProfileId, + setDefault: resolvedDefaultProfile == profileId + ) + } + } label: { + Label("Reconnect", systemImage: "arrow.clockwise.circle") + } + .adaptiveToolbarButton() + .disabled( + isAuthenticating || + repairingLinearProfiles.contains(rowActionProfileId) || + (!profileId.isEmpty && disconnectingLinearProfiles.contains(profileId)) ) } - .adaptiveToolbarButtonProminent() - .disabled( - isAuthenticating || - (!profileId.isEmpty && disconnectingLinearProfiles.contains(profileId)) - ) if let localProfile { Button(role: .destructive) { Task { await disconnectLinearProfile(localProfile.id) } @@ -3055,7 +3089,8 @@ private struct LinearExtensionSettingsView: View { Text(linearAccountLabel(profile: profile, status: profileStatus) ?? profile.id) .font(.mono(.subheadline, weight: .semibold)) StatusPill(text: "Profile \(profile.id)", tone: .neutral) - StatusPill(text: "Needs attention", tone: .warn) + let rowState = LinearConnectionPresentationState.needsAttention + StatusPill(text: rowState.label, tone: linearTone(for: rowState)) Spacer() Button { Task { @@ -3065,7 +3100,7 @@ private struct LinearExtensionSettingsView: View { ) } } label: { - Label("Repair connection", systemImage: "arrow.clockwise.circle") + Label("Connect account", systemImage: "plus.circle") } .adaptiveToolbarButtonProminent() .disabled( @@ -3376,43 +3411,81 @@ private struct LinearExtensionSettingsView: View { } } + private func localLinearProfile(profileId: String) -> LinearProfileSummary? { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return linearProfiles.first(where: { profile in + profile.id.caseInsensitiveCompare(trimmed) == .orderedSame + }) + } + + private func hasLocalLinearAccess(profileId: String) -> Bool { + guard localLinearProfile(profileId: profileId) != nil else { + return false + } + guard let status = profileStatusById[profileId] else { + return true + } + return status.tokenResolved + } + + private func linearRemoteConnectionState( + _ connection: LinearRemoteConnection + ) -> LinearConnectionPresentationState { + let profileId = connection.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !profileId.isEmpty else { + return .needsAttention + } + return hasLocalLinearAccess(profileId: profileId) ? .connected : .needsAttention + } + private var linearConnectionNeedsAttention: Bool { !localOnlyLinearProfiles.isEmpty || remoteLinearConnections.contains { connection in - let profileId = connection.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !profileId.isEmpty else { - return true - } - return !linearProfiles.contains(where: { - $0.id.caseInsensitiveCompare(profileId) == .orderedSame - }) + linearRemoteConnectionState(connection) == .needsAttention } } - private var linearConnectionStateLabel: String { + private var linearConnectionState: LinearConnectionPresentationState { if !enabled { - return "Disabled" + return .notConnected } if isAuthenticating { - return "Connecting" + return .connecting + } + if let authFlowStatus, authFlowStatus.status == "error" || authFlowStatus.status == "expired" { + return .needsAttention } if !remoteLinearConnections.isEmpty { - return linearConnectionNeedsAttention ? "Needs attention" : "Connected" + return linearConnectionNeedsAttention ? .needsAttention : .connected } if !localOnlyLinearProfiles.isEmpty { - return "Needs attention" + return .needsAttention } - return "Not connected" + return .notConnected + } + + private var linearConnectionStateLabel: String { + if !enabled { + return "Disabled" + } + return linearConnectionState.label } private var linearConnectionStateTone: StatusTone { - switch linearConnectionStateLabel { - case "Connected": - return .good - case "Needs attention": - return .warn - default: - return .neutral + linearTone(for: linearConnectionState) + } + + private func linearTone(for state: LinearConnectionPresentationState) -> StatusTone { + switch state { + case .connected: + .good + case .needsAttention: + .warn + case .connecting, .notConnected: + .neutral } } @@ -3436,7 +3509,14 @@ private struct LinearExtensionSettingsView: View { guard !defaultProfileId.isEmpty else { return false } - guard linearProfiles.contains(where: { $0.id == defaultProfileId }) else { + if remoteLinearConnections.contains(where: { connection in + connection.profileId?.caseInsensitiveCompare(defaultProfileId) == .orderedSame + }) { + return !hasLocalLinearAccess(profileId: defaultProfileId) + } + guard linearProfiles.contains(where: { + $0.id.caseInsensitiveCompare(defaultProfileId) == .orderedSame + }) else { return false } guard let status = profileStatusById[defaultProfileId] else { @@ -3771,6 +3851,52 @@ private struct LinearExtensionSettingsView: View { await refreshLinearDiagnostics() } + private func shouldFallbackLinearRepairToReconnect(_ error: String) -> Bool { + let normalized = error.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { + return false + } + return normalized.contains("seed-local-access") + || normalized.contains("linear_local_access_") + || normalized.contains("provider_token_custody_not_configured") + || normalized.contains("local access needs repair") + } + + private func repairLinearLocalAccess( + profileId: String, + setDefault: Bool + ) async { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + repairingLinearProfiles.insert(trimmed) + defer { repairingLinearProfiles.remove(trimmed) } + + if let repaired = await model.seedLinearLocalAccess(profileId: trimmed) { + let account = repaired.accountName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? repaired.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? displayNameForRemoteProfileId(trimmed) + message = repaired.refreshed + ? "Repaired local access for \(account)." + : "Local access restored for \(account)." + await model.refresh() + await refreshLinearDiagnostics() + return + } + + let detail = model.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if shouldFallbackLinearRepairToReconnect(detail) { + await reconnectLinearProfile(trimmed, setDefault: setDefault) + return + } + if !detail.isEmpty { + message = detail + } else { + message = "Unable to repair local access for \(displayNameForRemoteProfileId(trimmed)). Reconnect the account if the problem persists." + } + } + private func reconnectLinearProfile( _ profileId: String, setDefault: Bool @@ -3919,27 +4045,23 @@ private struct LinearExtensionSettingsView: View { { remoteConnections = refreshedRemoteConnections } + let remoteConnectionVisible = remoteLinearConnections.contains(where: { + $0.profileId?.caseInsensitiveCompare(profile) == .orderedSame + }) + if remoteConnectionVisible && pendingPollCount >= 8 { + message = + "Hack already sees this account for \(profile). If this Mac still needs access, use Repair access." + await refreshLinearDiagnostics() + authFlowStatus = nil + return + } if pendingPollCount == 8 { - if remoteLinearConnections.contains(where: { - $0.profileId?.caseInsensitiveCompare(profile) == .orderedSame - }) { - message = - "Hack already sees this account for \(profile). Finish the browser step or retry Connect account to complete setup." - } else { - message = - "Still waiting for Linear to finish connecting." - } + message = + "Still waiting for Linear to finish connecting." } if pendingPollCount >= 16 { - if remoteLinearConnections.contains(where: { - $0.profileId?.caseInsensitiveCompare(profile) == .orderedSame - }) { - message = - "Hack sees the account for \(profile), but setup still needs attention. Try Connect account again." - } else { - message = - "Connection did not finish. If Linear says Hack is already installed, remove that authorization and reconnect once." - } + message = + "Connection did not finish. If Linear says Hack is already installed, remove that authorization and reconnect once." await refreshLinearDiagnostics() authFlowStatus = nil return @@ -3960,9 +4082,15 @@ private struct LinearExtensionSettingsView: View { ?? flowStatus.accountEmail ?? flowStatus.accountHandle ?? "Linear provider account" - message = "Connected \(account)." await model.refresh() await refreshLinearDiagnostics() + if remoteLinearConnections.contains(where: { + $0.profileId?.caseInsensitiveCompare(flowStatus.profileId) == .orderedSame + }) && !hasLocalLinearAccess(profileId: flowStatus.profileId) { + message = "\(account) is connected on Hack. Use Repair access to finish setup on this Mac." + } else { + message = "Connected \(account)." + } return true case "error": message = flowStatus.error ?? "Linear authentication failed." diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 9df54c016..6ac79cc19 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -516,6 +516,16 @@ public actor HackCLIClient { return try decodeJsonOrThrow(LinearConnectionsResponse.self, result: result) } + public func seedLinearLocalAccess(profileId: String) async throws -> LinearLocalAccessSeedResponse { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + var args = ["x", "linear", "seed-local-access", "--json"] + if !trimmed.isEmpty { + args.append(contentsOf: ["--profile", trimmed]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(LinearLocalAccessSeedResponse.self, result: result) + } + public func inspectLinearStatus(profileId: String? = nil) async throws -> LinearStatusResponse { var args = ["x", "linear", "status", "--json"] if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1114,8 +1124,12 @@ public actor HackCLIClient { } } else if wrapped.status.status == "claimed" { let profileId = wrapped.status.profileId - let localStatus = try await inspectLinearStatus(profileId: profileId) - if !localStatus.tokenResolved { + let localStatus = try? await inspectLinearStatus(profileId: profileId) + if localStatus?.tokenResolved != true { + _ = try? await seedLinearLocalAccess(profileId: profileId) + } + let repairedStatus = try? await inspectLinearStatus(profileId: profileId) + if repairedStatus?.tokenResolved != true { return brokerClaimedWithoutLocalLinearTokenStatus(wrapped.status) } } @@ -1827,7 +1841,7 @@ public actor HackCLIClient { accountEmail: wrapped.accountEmail, tokenExpiresAt: wrapped.tokenExpiresAt, error: - "OAuth token was claimed remotely but is not available in local profile \(wrapped.profileId). Re-run Add account and allow keychain access." + "Hack already connected \(wrapped.profileId) remotely, but this Mac still needs local access. Use Repair access or reconnect the account." ) } diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 14f763aa2..04bb70d80 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -1306,6 +1306,7 @@ public struct LinearRemoteConnection: Decodable, Hashable, Identifiable { public let betterAuthTeamId: String? public let organizationId: String? public let teamId: String? + public let localAccessAvailable: Bool public let createdAt: String public let updatedAt: String @@ -1321,6 +1322,7 @@ public struct LinearRemoteConnection: Decodable, Hashable, Identifiable { betterAuthTeamId: String?, organizationId: String?, teamId: String?, + localAccessAvailable: Bool, createdAt: String, updatedAt: String ) { @@ -1335,9 +1337,48 @@ public struct LinearRemoteConnection: Decodable, Hashable, Identifiable { self.betterAuthTeamId = betterAuthTeamId self.organizationId = organizationId self.teamId = teamId + self.localAccessAvailable = localAccessAvailable self.createdAt = createdAt self.updatedAt = updatedAt } + + enum CodingKeys: String, CodingKey { + case id + case profileId + case accountId + case accountName + case accountEmail + case authRef + case betterAuthUserId + case betterAuthOrganizationId + case betterAuthTeamId + case organizationId + case teamId + case localAccessAvailable + case createdAt + case updatedAt + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + profileId = try container.decodeIfPresent(String.self, forKey: .profileId) + accountId = try container.decodeIfPresent(String.self, forKey: .accountId) + accountName = try container.decodeIfPresent(String.self, forKey: .accountName) + accountEmail = try container.decodeIfPresent(String.self, forKey: .accountEmail) + authRef = try container.decodeIfPresent(String.self, forKey: .authRef) + betterAuthUserId = try container.decodeIfPresent(String.self, forKey: .betterAuthUserId) + betterAuthOrganizationId = try container.decodeIfPresent( + String.self, + forKey: .betterAuthOrganizationId + ) + betterAuthTeamId = try container.decodeIfPresent(String.self, forKey: .betterAuthTeamId) + organizationId = try container.decodeIfPresent(String.self, forKey: .organizationId) + teamId = try container.decodeIfPresent(String.self, forKey: .teamId) + localAccessAvailable = try container.decodeIfPresent(Bool.self, forKey: .localAccessAvailable) ?? false + createdAt = try container.decode(String.self, forKey: .createdAt) + updatedAt = try container.decode(String.self, forKey: .updatedAt) + } } public struct LinearConnectionsResponse: Decodable, Hashable { @@ -1353,6 +1394,48 @@ public struct LinearConnectionsResponse: Decodable, Hashable { } } +public struct LinearLocalAccessSeedResponse: Decodable, Hashable { + public let profileId: String + public let accountName: String? + public let accountEmail: String? + public let refreshed: Bool + public let setDefault: Bool + + public init( + profileId: String, + accountName: String?, + accountEmail: String?, + refreshed: Bool, + setDefault: Bool + ) { + self.profileId = profileId + self.accountName = accountName + self.accountEmail = accountEmail + self.refreshed = refreshed + self.setDefault = setDefault + } +} + +public enum LinearConnectionPresentationState: String, Hashable { + case connected + case connecting + case needsAttention + case notConnected + + public var label: String { + switch self { + case .connected: + "Connected" + case .connecting: + "Connecting" + case .needsAttention: + "Needs attention" + case .notConnected: + "Not connected" + } + } +} + public struct LinearStatusResponse: Decodable, Hashable { public let extensionId: String public let selectedProfile: String diff --git a/packages/db/src/schema/core.ts b/packages/db/src/schema/core.ts index bcd6d32af..3d3759aad 100644 --- a/packages/db/src/schema/core.ts +++ b/packages/db/src/schema/core.ts @@ -70,6 +70,10 @@ export const linearConnections = pgTable("linear_connections", { betterAuthTeamId: text("better_auth_team_id"), organizationId: text("organization_id"), teamId: text("team_id"), + localAccessSealed: text("local_access_sealed"), + localAccessUpdatedAt: timestamp("local_access_updated_at", { + withTimezone: true, + }), metadataJson: text("metadata_json").notNull().default("{}"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() diff --git a/services/auth-broker/.env.example b/services/auth-broker/.env.example index 9122b4b21..5325de094 100644 --- a/services/auth-broker/.env.example +++ b/services/auth-broker/.env.example @@ -29,6 +29,9 @@ HACK_LINEAR_TOKEN_URL=https://api.linear.app/oauth/token HACK_LINEAR_API_BASE_URL=https://api.linear.app HACK_LINEAR_REDIRECT_URI=https://auth.hack.broker/linear/callback HACK_LINEAR_WEBHOOK_PATH=/linear/webhooks +# Required to store encrypted provider-token custody for Linear local-access seeding. +# Generate a 32-byte secret (for example: `openssl rand -base64 32`) and keep it stable. +HACK_PROVIDER_TOKEN_ENCRYPTION_KEY= # Optional broker network settings AUTH_BROKER_PUBLIC_BASE_URL=http://127.0.0.1:8080 diff --git a/services/auth-broker/README.md b/services/auth-broker/README.md index 5efbfc441..048e9cfc5 100644 --- a/services/auth-broker/README.md +++ b/services/auth-broker/README.md @@ -136,6 +136,9 @@ return `405` for non-`GET` requests. 14. `GET /auth` 15. `GET /auth/account` 16. `ALL /api/auth/*` (proxied to Better Auth handler) +17. `GET /v1/auth/linear/connections` +18. `POST /v1/auth/linear/connections/seed` +19. `POST /v1/auth/linear/connections/update-local-access` When `requireInstallation=1` is used, flow polling can defer token claim until an installation is visible for the flow, enabling one-pass authorize+install UX. @@ -168,6 +171,28 @@ This token is not: - a replacement for provider OAuth/app tokens - a gateway or daemon transport bearer token + +## Linear local-access seeding + +Linear connections are owned remotely by the active Hack account, but the sync +engine still needs a local provider token envelope on each Mac. The broker now +stores that Linear token envelope in encrypted custody and exposes protected +repair routes so a signed-in client can reseed local access without sending the +user back through Linear OAuth. + +Required env: + +- `HACK_PROVIDER_TOKEN_ENCRYPTION_KEY` + +Generate a stable 32-byte secret, for example: + +```bash +openssl rand -base64 32 +``` + +This key is used only on the broker to encrypt/decrypt provider-token custody. +Losing or rotating it without migrating stored data will invalidate previously +stored Linear local-access envelopes. - a general-purpose local secret export mechanism `GET /v1/auth/me` resolves either: diff --git a/services/auth-broker/src/app.ts b/services/auth-broker/src/app.ts index e729072f2..4a24fc68b 100644 --- a/services/auth-broker/src/app.ts +++ b/services/auth-broker/src/app.ts @@ -128,6 +128,7 @@ export function createAuthBrokerApp({ ) .use( createLinearConnectionsPlugin({ + config, connectionStore: linearConnectionStore, betterAuthRuntime, }) @@ -236,6 +237,12 @@ function isReadOnlyRoutePath(input: { readonly path: string }): boolean { if (input.path === "/v1/auth/linear/refresh") { return false; } + if (input.path === "/v1/auth/linear/connections/seed") { + return false; + } + if (input.path === "/v1/auth/linear/connections/update-local-access") { + return false; + } if (input.path === "/v1/auth/linear/subscriptions") { return false; } diff --git a/services/auth-broker/src/config.ts b/services/auth-broker/src/config.ts index 56b075cab..590c98cea 100644 --- a/services/auth-broker/src/config.ts +++ b/services/auth-broker/src/config.ts @@ -6,6 +6,7 @@ export type BrokerConfig = { readonly host: string; readonly publicBaseUrl: string; readonly flowStorePath: string; + readonly providerTokenEncryptionKey?: string; readonly githubClientId: string; readonly githubClientSecret: string; readonly githubAppId?: string; @@ -78,6 +79,13 @@ export function resolveConfig(): BrokerConfig { publicBaseUrl, flowStorePath: normalizeString(process.env.FLOW_STORE_PATH) ?? DEFAULT_FLOW_STORE_PATH, + providerTokenEncryptionKey: + normalizeString( + readFirstEnv([ + "HACK_PROVIDER_TOKEN_ENCRYPTION_KEY", + "PROVIDER_TOKEN_ENCRYPTION_KEY", + ]) + ) ?? undefined, ...github, betterAuthGitHubAutoProvisionUsers: parseBoolean(process.env.BETTER_AUTH_GITHUB_AUTO_PROVISION_USERS) ?? diff --git a/services/auth-broker/src/db/schema.ts b/services/auth-broker/src/db/schema.ts index 654699982..c58250ca3 100644 --- a/services/auth-broker/src/db/schema.ts +++ b/services/auth-broker/src/db/schema.ts @@ -229,6 +229,10 @@ export const linearConnections = pgTable("linear_connections", { betterAuthTeamId: text("better_auth_team_id"), organizationId: text("organization_id"), teamId: text("team_id"), + localAccessSealed: text("local_access_sealed"), + localAccessUpdatedAt: timestamp("local_access_updated_at", { + withTimezone: true, + }), metadataJson: text("metadata_json").notNull().default("{}"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() diff --git a/services/auth-broker/src/modules/linear-connections/local-access.ts b/services/auth-broker/src/modules/linear-connections/local-access.ts new file mode 100644 index 000000000..290238f8d --- /dev/null +++ b/services/auth-broker/src/modules/linear-connections/local-access.ts @@ -0,0 +1,208 @@ +import { refreshAccessToken } from "../../linear.ts"; +import type { + LinearConnectionStore, + LinearStoredLocalAccess, +} from "./service.ts"; + +const TOKEN_REFRESH_WINDOW_MS = 60_000; + +export type SeedLinearLocalAccessResult = + | { + readonly ok: true; + readonly profileId: string; + readonly accountName: string | null; + readonly accountEmail: string | null; + readonly token: string; + readonly tokenExpiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly refreshed: boolean; + } + | { + readonly ok: false; + readonly error: string; + readonly statusCode: number; + }; + +export async function persistLinearLocalAccessCustody(input: { + readonly config: { + readonly providerTokenEncryptionKey?: string; + }; + readonly connectionStore: LinearConnectionStore; + readonly profileId: string; + readonly token?: string | null; + readonly tokenExpiresAt?: string | null; + readonly refreshToken?: string | null; + readonly refreshTokenExpiresAt?: string | null; +}) { + const encryptionKey = input.config.providerTokenEncryptionKey?.trim() ?? ""; + if (!encryptionKey) { + return { + ok: false as const, + error: "provider_token_custody_not_configured", + statusCode: 503, + }; + } + try { + const connection = await input.connectionStore.saveLocalAccess({ + profileId: input.profileId, + token: input.token, + tokenExpiresAt: input.tokenExpiresAt, + refreshToken: input.refreshToken, + refreshTokenExpiresAt: input.refreshTokenExpiresAt, + encryptionKey, + }); + return { + ok: true as const, + connection, + }; + } catch (error) { + return { + ok: false as const, + error: error instanceof Error ? error.message : String(error), + statusCode: 500, + }; + } +} + +export async function seedLinearLocalAccess(input: { + readonly config: { + readonly providerTokenEncryptionKey?: string; + readonly linearTokenUrl: string; + readonly linearClientId?: string; + readonly linearClientSecret?: string; + }; + readonly connectionStore: LinearConnectionStore; + readonly profileId: string; +}): Promise { + const encryptionKey = input.config.providerTokenEncryptionKey?.trim() ?? ""; + if (!encryptionKey) { + return { + ok: false, + error: "provider_token_custody_not_configured", + statusCode: 503, + }; + } + let stored: LinearStoredLocalAccess | null = null; + try { + stored = await input.connectionStore.readLocalAccess({ + profileId: input.profileId, + encryptionKey, + }); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + statusCode: 500, + }; + } + if (!stored) { + return { + ok: false, + error: "linear_local_access_unavailable", + statusCode: 404, + }; + } + + let envelope = stored.envelope; + let refreshed = false; + if (shouldRefreshEnvelope({ envelope })) { + const refreshToken = envelope.refreshToken?.trim() ?? ""; + if (!refreshToken) { + return { + ok: false, + error: "linear_local_access_refresh_required", + statusCode: 409, + }; + } + const refreshedToken = await refreshAccessToken({ + tokenUrl: input.config.linearTokenUrl, + clientId: input.config.linearClientId ?? "", + ...(input.config.linearClientSecret + ? { clientSecret: input.config.linearClientSecret } + : {}), + refreshToken, + }); + if (!refreshedToken.ok) { + return { + ok: false, + error: refreshedToken.error, + statusCode: refreshedToken.statusCode, + }; + } + const persisted = await persistLinearLocalAccessCustody({ + config: input.config, + connectionStore: input.connectionStore, + profileId: input.profileId, + token: refreshedToken.token, + tokenExpiresAt: refreshedToken.tokenExpiresAt, + refreshToken: refreshedToken.refreshToken ?? envelope.refreshToken, + refreshTokenExpiresAt: + refreshedToken.refreshTokenExpiresAt ?? envelope.refreshTokenExpiresAt, + }); + if (!persisted.ok) { + return persisted; + } + envelope = + ( + await input.connectionStore.readLocalAccess({ + profileId: input.profileId, + encryptionKey, + }) + )?.envelope ?? envelope; + refreshed = true; + } + + const token = envelope.token?.trim() ?? ""; + if (!token) { + return { + ok: false, + error: "linear_local_access_unavailable", + statusCode: 409, + }; + } + + return { + ok: true, + profileId: stored.connection.profileId ?? input.profileId, + accountName: stored.connection.accountName, + accountEmail: stored.connection.accountEmail, + token, + ...(envelope.tokenExpiresAt + ? { tokenExpiresAt: envelope.tokenExpiresAt } + : {}), + ...(envelope.refreshToken ? { refreshToken: envelope.refreshToken } : {}), + ...(envelope.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: envelope.refreshTokenExpiresAt } + : {}), + refreshed, + }; +} + +function shouldRefreshEnvelope(input: { + readonly envelope: { + readonly token?: string; + readonly tokenExpiresAt?: string; + readonly refreshToken?: string; + }; +}): boolean { + if (!input.envelope.refreshToken) { + return false; + } + if (!input.envelope.token) { + return true; + } + const expiresAtMs = parseTimestampMs(input.envelope.tokenExpiresAt); + if (expiresAtMs === null) { + return false; + } + return expiresAtMs <= Date.now() + TOKEN_REFRESH_WINDOW_MS; +} + +function parseTimestampMs(value?: string): number | null { + if (!(typeof value === "string" && value.trim().length > 0)) { + return null; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/services/auth-broker/src/modules/linear-connections/plugin.ts b/services/auth-broker/src/modules/linear-connections/plugin.ts index 852e89281..7e5e80751 100644 --- a/services/auth-broker/src/modules/linear-connections/plugin.ts +++ b/services/auth-broker/src/modules/linear-connections/plugin.ts @@ -6,6 +6,10 @@ import { hasBetterAuthProfileAccess, resolveBetterAuthSession, } from "../better-auth/session.ts"; +import { + persistLinearLocalAccessCustody, + seedLinearLocalAccess, +} from "./local-access.ts"; import type { LinearConnectionStore } from "./service.ts"; const listConnectionsQuerySchema = t.Object({ @@ -16,62 +20,212 @@ const listConnectionsQuerySchema = t.Object({ type CreateLinearConnectionsPluginOptions = { readonly connectionStore: LinearConnectionStore; readonly betterAuthRuntime: BetterAuthRuntime; + readonly config: { + readonly providerTokenEncryptionKey?: string; + readonly linearTokenUrl: string; + readonly linearClientId?: string; + readonly linearClientSecret?: string; + }; }; export function createLinearConnectionsPlugin({ connectionStore, betterAuthRuntime, + config, }: CreateLinearConnectionsPluginOptions) { return new Elysia({ name: "hack-auth-broker.linear-connections", - }).get( - "/v1/auth/linear/connections", - async ({ query, request, set }) => { - const session = await resolveBetterAuthSession({ - runtime: betterAuthRuntime, - request, - }); - if (session.enabled && !session.session) { - set.status = 401; + }) + .get( + "/v1/auth/linear/connections", + async ({ query, request, set }) => { + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { + ok: false, + error: "better_auth_session_required", + } as const; + } + if ( + !hasBetterAuthProfileAccess({ + session: session.session, + profileId: normalizeOptionalQueryValue(query.profileId), + }) + ) { + set.status = 403; + return { + ok: false, + error: "better_auth_profile_forbidden", + } as const; + } + const activeSession = session.session; + const allConnections = await connectionStore.listConnections({ + profileId: normalizeOptionalQueryValue(query.profileId), + organizationId: normalizeOptionalQueryValue(query.organizationId), + }); + const connections = + session.enabled && activeSession + ? allConnections.filter((connection) => + hasBetterAuthAccess({ + session: activeSession, + record: connection, + }) + ) + : allConnections; return { - ok: false, - error: "better_auth_session_required", + ok: true, + accessControlMode: session.accessControlMode, + connections, } as const; + }, + { query: listConnectionsQuerySchema } + ) + .post( + "/v1/auth/linear/connections/seed", + async ({ body, request, set }) => { + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { ok: false, error: "better_auth_session_required" } as const; + } + const profileId = normalizeOptionalQueryValue(body.profileId); + if ( + !hasBetterAuthProfileAccess({ + session: session.session, + profileId, + }) + ) { + set.status = 403; + return { + ok: false, + error: "better_auth_profile_forbidden", + } as const; + } + const allConnections = await connectionStore.listConnections({ + ...(profileId ? { profileId } : {}), + }); + const activeSession = session.session; + const connection = + session.enabled && activeSession + ? (allConnections.find((record) => + hasBetterAuthAccess({ + session: activeSession, + record, + }) + ) ?? null) + : (allConnections[0] ?? null); + if (!connection) { + set.status = 404; + return { + ok: false, + error: "linear_connection_not_found", + } as const; + } + const seeded = await seedLinearLocalAccess({ + config, + connectionStore, + profileId: connection.profileId ?? profileId ?? "", + }); + if (!seeded.ok) { + set.status = seeded.statusCode; + return { + ok: false, + error: seeded.error, + } as const; + } + return { + ok: true, + seed: seeded, + } as const; + }, + { + body: t.Object({ + profileId: t.Optional(t.String()), + }), } - if ( - !hasBetterAuthProfileAccess({ - session: session.session, - profileId: normalizeOptionalQueryValue(query.profileId), - }) - ) { - set.status = 403; + ) + .post( + "/v1/auth/linear/connections/update-local-access", + async ({ body, request, set }) => { + const session = await resolveBetterAuthSession({ + runtime: betterAuthRuntime, + request, + }); + if (session.enabled && !session.session) { + set.status = 401; + return { ok: false, error: "better_auth_session_required" } as const; + } + const profileId = normalizeOptionalQueryValue(body.profileId); + if ( + !hasBetterAuthProfileAccess({ + session: session.session, + profileId, + }) + ) { + set.status = 403; + return { + ok: false, + error: "better_auth_profile_forbidden", + } as const; + } + const allConnections = await connectionStore.listConnections({ + ...(profileId ? { profileId } : {}), + }); + const activeSession = session.session; + const connection = + session.enabled && activeSession + ? (allConnections.find((record) => + hasBetterAuthAccess({ + session: activeSession, + record, + }) + ) ?? null) + : (allConnections[0] ?? null); + if (!connection) { + set.status = 404; + return { + ok: false, + error: "linear_connection_not_found", + } as const; + } + const persisted = await persistLinearLocalAccessCustody({ + config, + connectionStore, + profileId: connection.profileId ?? profileId ?? "", + token: body.token, + tokenExpiresAt: body.tokenExpiresAt, + refreshToken: body.refreshToken, + refreshTokenExpiresAt: body.refreshTokenExpiresAt, + }); + if (!persisted.ok) { + set.status = persisted.statusCode; + return { + ok: false, + error: persisted.error, + } as const; + } return { - ok: false, - error: "better_auth_profile_forbidden", + ok: true, + connection: persisted.connection, } as const; + }, + { + body: t.Object({ + profileId: t.Optional(t.String()), + token: t.Optional(t.String()), + tokenExpiresAt: t.Optional(t.String()), + refreshToken: t.Optional(t.String()), + refreshTokenExpiresAt: t.Optional(t.String()), + }), } - const activeSession = session.session; - const allConnections = await connectionStore.listConnections({ - profileId: normalizeOptionalQueryValue(query.profileId), - organizationId: normalizeOptionalQueryValue(query.organizationId), - }); - const connections = - session.enabled && activeSession - ? allConnections.filter((connection) => - hasBetterAuthAccess({ - session: activeSession, - record: connection, - }) - ) - : allConnections; - return { - ok: true, - accessControlMode: session.accessControlMode, - connections, - } as const; - }, - { query: listConnectionsQuerySchema } - ); + ); } function normalizeOptionalQueryValue(value?: string): string | null { diff --git a/services/auth-broker/src/modules/linear-connections/service.ts b/services/auth-broker/src/modules/linear-connections/service.ts index 2630ea91c..16448f306 100644 --- a/services/auth-broker/src/modules/linear-connections/service.ts +++ b/services/auth-broker/src/modules/linear-connections/service.ts @@ -1,4 +1,10 @@ -import { randomUUID } from "node:crypto"; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + randomUUID, +} from "node:crypto"; import { and, desc, eq, type SQL, sql } from "drizzle-orm"; @@ -21,11 +27,34 @@ export type LinearConnectionRecord = { readonly betterAuthTeamId: string | null; readonly organizationId: string | null; readonly teamId: string | null; + readonly localAccessAvailable: boolean; readonly metadata: LinearConnectionMetadata; readonly createdAt: string; readonly updatedAt: string; }; +export type LinearLocalAccessEnvelope = { + readonly token?: string; + readonly tokenExpiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly updatedAt: string; +}; + +export type SaveLinearLocalAccessInput = { + readonly profileId: string; + readonly token?: string | null; + readonly tokenExpiresAt?: string | null; + readonly refreshToken?: string | null; + readonly refreshTokenExpiresAt?: string | null; + readonly encryptionKey: string; +}; + +export type LinearStoredLocalAccess = { + readonly connection: LinearConnectionRecord; + readonly envelope: LinearLocalAccessEnvelope; +}; + export type UpsertLinearConnectionInput = { readonly profileId?: string | null; readonly accountId?: string | null; @@ -70,10 +99,21 @@ export type LinearConnectionStore = { readonly profileId?: string | null; readonly organizationId?: string | null; }) => Promise; + readonly saveLocalAccess: ( + input: SaveLinearLocalAccessInput + ) => Promise; + readonly readLocalAccess: (input: { + readonly profileId: string; + readonly encryptionKey: string; + }) => Promise; }; export class InMemoryLinearConnectionStore implements LinearConnectionStore { private readonly recordsByKey = new Map(); + private readonly localAccessByKey = new Map< + string, + LinearLocalAccessEnvelope + >(); upsertConnection( input: UpsertLinearConnectionInput @@ -99,6 +139,7 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { betterAuthTeamId: normalizeText(input.betterAuthTeamId), organizationId: normalizeText(input.organizationId), teamId: normalizeText(input.teamId), + localAccessAvailable: this.localAccessByKey.has(connectionKey), metadata, createdAt: existing?.createdAt ?? now, updatedAt: now, @@ -152,6 +193,72 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { fallbackOrganizationId: organizationId, }); } + + saveLocalAccess( + input: SaveLinearLocalAccessInput + ): Promise { + const profileId = normalizeText(input.profileId); + if (!profileId) { + throw new Error("Missing Linear profile id for local access."); + } + const connection = [...this.recordsByKey.values()] + .filter((record) => record.profileId === profileId) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]; + if (!connection) { + throw new Error( + `Linear connection not found for profile "${profileId}".` + ); + } + const envelope = normalizeLocalAccessEnvelope({ + token: input.token, + tokenExpiresAt: input.tokenExpiresAt, + refreshToken: input.refreshToken, + refreshTokenExpiresAt: input.refreshTokenExpiresAt, + }); + if (!envelope) { + throw new Error("Linear local access requires a token or refresh token."); + } + const updatedAt = new Date().toISOString(); + const storedEnvelope: LinearLocalAccessEnvelope = { + ...envelope, + updatedAt, + }; + this.localAccessByKey.set(connection.connectionKey, storedEnvelope); + const nextRecord: LinearConnectionRecord = { + ...connection, + localAccessAvailable: true, + updatedAt, + }; + this.recordsByKey.set(connection.connectionKey, nextRecord); + return nextRecord; + } + + readLocalAccess(input: { + readonly profileId: string; + readonly encryptionKey: string; + }): Promise { + const profileId = normalizeText(input.profileId); + if (!profileId) { + return null; + } + const connection = [...this.recordsByKey.values()] + .filter((record) => record.profileId === profileId) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]; + if (!connection) { + return null; + } + const envelope = this.localAccessByKey.get(connection.connectionKey); + if (!envelope) { + return null; + } + return { + connection: { + ...connection, + localAccessAvailable: true, + }, + envelope, + }; + } } export function createLinearConnectionStoreFromDb(input: { @@ -282,6 +389,84 @@ export function createLinearConnectionStoreFromDb(input: { fallbackOrganizationId: normalizedOrganizationId, }); }, + saveLocalAccess: async (input) => { + await ensureTable(); + const profileId = normalizeText(input.profileId); + if (!profileId) { + throw new Error("Missing Linear profile id for local access."); + } + const row = ( + await db + .select() + .from(linearConnections) + .where(eq(linearConnections.profileId, profileId)) + .orderBy(desc(linearConnections.updatedAt)) + .limit(1) + )[0]; + if (!row) { + throw new Error( + `Linear connection not found for profile "${profileId}".` + ); + } + const envelope = normalizeLocalAccessEnvelope({ + token: input.token, + tokenExpiresAt: input.tokenExpiresAt, + refreshToken: input.refreshToken, + refreshTokenExpiresAt: input.refreshTokenExpiresAt, + }); + if (!envelope) { + throw new Error( + "Linear local access requires a token or refresh token." + ); + } + const updatedAt = new Date(); + const storedEnvelope: LinearLocalAccessEnvelope = { + ...envelope, + updatedAt: updatedAt.toISOString(), + }; + const updated = await db + .update(linearConnections) + .set({ + localAccessSealed: sealLocalAccessEnvelope({ + encryptionKey: input.encryptionKey, + envelope: storedEnvelope, + }), + localAccessUpdatedAt: updatedAt, + updatedAt, + }) + .where(eq(linearConnections.id, row.id)) + .returning(); + const saved = updated[0]; + if (!saved) { + throw new Error("Failed to persist Linear local access custody."); + } + return toConnectionRecord({ row: saved }); + }, + readLocalAccess: async (input) => { + await ensureTable(); + const profileId = normalizeText(input.profileId); + if (!profileId) { + return null; + } + const row = ( + await db + .select() + .from(linearConnections) + .where(eq(linearConnections.profileId, profileId)) + .orderBy(desc(linearConnections.updatedAt)) + .limit(1) + )[0]; + if (!(row && normalizeText(row.localAccessSealed))) { + return null; + } + return { + connection: toConnectionRecord({ row }), + envelope: unsealLocalAccessEnvelope({ + encryptionKey: input.encryptionKey, + sealed: row.localAccessSealed ?? "", + }), + }; + }, }; } @@ -300,6 +485,14 @@ function createLinearConnectionsTableEnsurer(input: { name: "better_auth_team_id", definition: "text", }, + { + name: "local_access_sealed", + definition: "text", + }, + { + name: "local_access_updated_at", + definition: "timestamptz", + }, ], }); let promise: Promise | null = null; @@ -333,6 +526,8 @@ export async function ensureLinearConnectionsTable(input: { better_auth_team_id text, organization_id text, team_id text, + local_access_sealed text, + local_access_updated_at timestamptz, metadata_json text NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() @@ -445,6 +640,9 @@ export function toConnectionRecord(input: { input.row.betterAuthTeamId ?? storedOwnership.betterAuthTeamId, organizationId: input.row.organizationId ?? null, teamId: input.row.teamId ?? null, + localAccessAvailable: Boolean( + normalizeText(input.row.localAccessSealed ?? null) + ), metadata: parseMetadata({ raw: input.row.metadataJson }), createdAt: input.row.createdAt.toISOString(), updatedAt: input.row.updatedAt.toISOString(), @@ -514,6 +712,9 @@ function toWebhookOwnership(input: { const BETTER_AUTH_ORGANIZATION_METADATA_KEY = "_betterAuthOrganizationId"; const BETTER_AUTH_TEAM_METADATA_KEY = "_betterAuthTeamId"; +const LOCAL_ACCESS_CIPHERTEXT_VERSION = 1; +const LOCAL_ACCESS_IV_BYTES = 12; +const LOCAL_ACCESS_ALGORITHM = "aes-256-gcm"; function composeConnectionMetadata(input: { readonly metadata?: LinearConnectionMetadata; @@ -574,3 +775,105 @@ function normalizeText(value: unknown): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } + +function normalizeLocalAccessEnvelope(input: { + readonly token?: unknown; + readonly tokenExpiresAt?: unknown; + readonly refreshToken?: unknown; + readonly refreshTokenExpiresAt?: unknown; +}): Omit | null { + const token = normalizeText(input.token); + const refreshToken = normalizeText(input.refreshToken); + if (!(token || refreshToken)) { + return null; + } + return { + ...(token ? { token } : {}), + ...(normalizeText(input.tokenExpiresAt) + ? { tokenExpiresAt: normalizeText(input.tokenExpiresAt) ?? undefined } + : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(normalizeText(input.refreshTokenExpiresAt) + ? { + refreshTokenExpiresAt: + normalizeText(input.refreshTokenExpiresAt) ?? undefined, + } + : {}), + }; +} + +function sealLocalAccessEnvelope(input: { + readonly encryptionKey: string; + readonly envelope: LinearLocalAccessEnvelope; +}): string { + const key = deriveEncryptionKey({ source: input.encryptionKey }); + const iv = randomBytes(LOCAL_ACCESS_IV_BYTES); + const cipher = createCipheriv(LOCAL_ACCESS_ALGORITHM, key, iv); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(input.envelope), "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return JSON.stringify({ + v: LOCAL_ACCESS_CIPHERTEXT_VERSION, + iv: iv.toString("base64"), + tag: tag.toString("base64"), + ciphertext: ciphertext.toString("base64"), + }); +} + +function unsealLocalAccessEnvelope(input: { + readonly encryptionKey: string; + readonly sealed: string; +}): LinearLocalAccessEnvelope { + let parsed: Record; + try { + parsed = JSON.parse(input.sealed) as Record; + } catch { + throw new Error("Invalid stored Linear local access envelope."); + } + const ciphertext = normalizeText(parsed.ciphertext); + const iv = normalizeText(parsed.iv); + const tag = normalizeText(parsed.tag); + if (!(ciphertext && iv && tag)) { + throw new Error("Stored Linear local access envelope is incomplete."); + } + const decipher = createDecipheriv( + LOCAL_ACCESS_ALGORITHM, + deriveEncryptionKey({ source: input.encryptionKey }), + Buffer.from(iv, "base64") + ); + decipher.setAuthTag(Buffer.from(tag, "base64")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertext, "base64")), + decipher.final(), + ]).toString("utf8"); + let payload: Record; + try { + payload = JSON.parse(plaintext) as Record; + } catch { + throw new Error("Stored Linear local access payload is invalid."); + } + const updatedAt = normalizeText(payload.updatedAt); + const normalized = normalizeLocalAccessEnvelope({ + token: payload.token, + tokenExpiresAt: payload.tokenExpiresAt, + refreshToken: payload.refreshToken, + refreshTokenExpiresAt: payload.refreshTokenExpiresAt, + }); + if (!(updatedAt && normalized)) { + throw new Error("Stored Linear local access payload is incomplete."); + } + return { + ...normalized, + updatedAt, + }; +} + +function deriveEncryptionKey(input: { readonly source: string }): Buffer { + const source = input.source.trim(); + if (!source) { + throw new Error("Missing provider token encryption key."); + } + return createHash("sha256").update(source).digest(); +} diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts index a0a55ff1a..1d870cfc0 100644 --- a/services/auth-broker/src/modules/linear-oauth/service.ts +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -14,6 +14,7 @@ import type { OAuthFlow } from "../../types.ts"; import { issueBrokerManagementToken } from "../better-auth/management-token.ts"; import { resolveBetterAuthSession } from "../better-auth/session.ts"; import { buildHackDesktopDeepLink } from "../github-oauth/service.ts"; +import { persistLinearLocalAccessCustody } from "../linear-connections/local-access.ts"; import type { LinearConnectionStore } from "../linear-connections/service.ts"; import type { LinearOAuthModel } from "./model.ts"; @@ -321,6 +322,40 @@ export async function handleLinearCallback(input: { }, }); + const persistedLocalAccess = await persistLinearLocalAccessCustody({ + config: input.config, + connectionStore: input.connectionStore, + profileId: flow.profileId, + token: exchange.token, + tokenExpiresAt: exchange.tokenExpiresAt, + refreshToken: exchange.refreshToken, + refreshTokenExpiresAt: exchange.refreshTokenExpiresAt, + }); + if (!persistedLocalAccess.ok) { + return markLinearFlowError({ + flowStore: input.flowStore, + flowId: flow.id, + error: persistedLocalAccess.error, + status: "error", + title: "Connection failed", + body: + persistedLocalAccess.error === "provider_token_custody_not_configured" + ? "Hack could not secure this Linear account for reuse yet. Return to Hack and try again later." + : "Hack could not finish securing this Linear account. Return to Hack and try again.", + statusCode: 200, + ...(flow.desktopRedirectUrl + ? { + actions: [ + buildOpenHackAction({ + flow, + status: "error", + }), + ], + } + : {}), + }); + } + input.flowStore.markComplete({ flowId: flow.id, account: linkedAccount, diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index dab000478..3584d1890 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -127,6 +127,7 @@ function createTestConfig() { host: "127.0.0.1", publicBaseUrl: "http://127.0.0.1:8080", flowStorePath: ".data/test-oauth-flows.json", + providerTokenEncryptionKey: "linear-token-custody-test-key", githubClientId: "test-client-id", githubClientSecret: "test-client-secret", githubScopes: "repo,read:org", @@ -200,6 +201,8 @@ describe("auth broker github flow routes", () => { betterAuthTeamId: "column-team", organizationId: "linear-org", teamId: "linear-team", + localAccessSealed: null, + localAccessUpdatedAt: null, metadataJson: JSON.stringify({ organizationName: "Hack", _betterAuthOrganizationId: "legacy-org", @@ -229,6 +232,8 @@ describe("auth broker github flow routes", () => { betterAuthTeamId: null, organizationId: "legacy-linear-org", teamId: "legacy-linear-team", + localAccessSealed: null, + localAccessUpdatedAt: null, metadataJson: JSON.stringify({ _betterAuthOrganizationId: "legacy-org", _betterAuthTeamId: "legacy-team", @@ -2822,6 +2827,12 @@ describe("auth broker github flow routes", () => { teamId: null, } as const); }, + saveLocalAccess() { + return Promise.reject(new Error("local access write failed")); + }, + readLocalAccess() { + return Promise.resolve(null); + }, }, betterAuthRuntime: createBetterAuthRuntimeWithSession( withActiveTeam( diff --git a/services/auth-broker/tests/linear-ownership-compat.test.ts b/services/auth-broker/tests/linear-ownership-compat.test.ts index e24f90b1f..713651e62 100644 --- a/services/auth-broker/tests/linear-ownership-compat.test.ts +++ b/services/auth-broker/tests/linear-ownership-compat.test.ts @@ -28,6 +28,8 @@ test("toConnectionRecord prefers first-class ownership columns and strips legacy betterAuthTeamId: "team-column", organizationId: "linear-org", teamId: "linear-team", + localAccessSealed: null, + localAccessUpdatedAt: null, metadataJson: JSON.stringify({ _betterAuthOrganizationId: "org-legacy", _betterAuthTeamId: "team-legacy", @@ -57,6 +59,8 @@ test("toConnectionRecord falls back to legacy ownership metadata when structured betterAuthTeamId: null, organizationId: null, teamId: null, + localAccessSealed: null, + localAccessUpdatedAt: null, metadataJson: JSON.stringify({ _betterAuthOrganizationId: "org-legacy", _betterAuthTeamId: "team-legacy", diff --git a/services/auth-broker/tests/linear-store-schema.test.ts b/services/auth-broker/tests/linear-store-schema.test.ts index fc86ca8d0..f6982b12e 100644 --- a/services/auth-broker/tests/linear-store-schema.test.ts +++ b/services/auth-broker/tests/linear-store-schema.test.ts @@ -54,6 +54,8 @@ test("ensureLinearConnectionsTable creates the broker connection table", async ( expect(queries[0]).toContain("CREATE TABLE IF NOT EXISTS linear_connections"); expect(queries[0]).toContain("connection_key text NOT NULL UNIQUE"); expect(queries[0]).toContain("better_auth_organization_id text"); + expect(queries[0]).toContain("local_access_sealed text"); + expect(queries[0]).toContain("local_access_updated_at timestamptz"); }); test("ensureLinearWebhookEventsTable creates the broker webhook delivery table", async () => { diff --git a/services/auth-broker/tests/linear-token-custody.test.ts b/services/auth-broker/tests/linear-token-custody.test.ts new file mode 100644 index 000000000..683840eaa --- /dev/null +++ b/services/auth-broker/tests/linear-token-custody.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, test } from "bun:test"; + +import type { BetterAuthRuntime } from "../src/better-auth.ts"; +import type { BrokerConfig } from "../src/config.ts"; +import { FlowStore } from "../src/flow-store.ts"; +import { createAuthBrokerApp } from "../src/index.ts"; +import { seedLinearLocalAccess } from "../src/modules/linear-connections/local-access.ts"; +import { + InMemoryLinearConnectionStore, + type LinearConnectionStore, +} from "../src/modules/linear-connections/service.ts"; +import { + createFlow, + handleLinearCallback, +} from "../src/modules/linear-oauth/service.ts"; + +type BetterAuthAuth = NonNullable; +type BetterAuthSession = Awaited< + ReturnType +>; + +function createTestConfig(): BrokerConfig { + return { + port: 0, + host: "127.0.0.1", + publicBaseUrl: "http://127.0.0.1:8080", + flowStorePath: ".data/test-oauth-flows.json", + providerTokenEncryptionKey: "linear-token-custody-test-key", + githubClientId: "test-client-id", + githubClientSecret: "test-client-secret", + githubScopes: "repo,read:org", + githubAuthorizeUrl: "https://github.com/login/oauth/authorize", + githubTokenUrl: "https://github.com/login/oauth/access_token", + githubApiBaseUrl: "https://api.github.com", + githubRedirectUri: "http://127.0.0.1:8080/gh/callback", + betterAuthGitHubAutoProvisionUsers: false, + betterAuthLinearAutoProvisionUsers: false, + linearClientId: "linear-client-id", + linearClientSecret: "linear-client-secret", + linearDeveloperAppToken: undefined, + linearActor: "app", + linearScopes: "read,write,app:mentionable,app:assignable", + linearAuthorizeUrl: "https://linear.app/oauth/authorize", + linearTokenUrl: "https://api.linear.app/oauth/token", + linearApiBaseUrl: "https://api.linear.app", + linearRedirectUri: "http://127.0.0.1:8080/linear/callback", + linearWebhookPath: "/linear/webhooks", + linearWebhookSigningSecret: undefined, + flowTtlMs: 60_000, + flowSweepIntervalMs: 60_000, + }; +} + +function createBetterAuthRuntimeWithSession( + session: BetterAuthSession +): BetterAuthRuntime { + return { + enabled: true, + socialProviders: [{ id: "github", label: "GitHub" }], + accountLinkingPolicy: { + requireVerifiedEmail: true, + allowDifferentEmails: false, + trustedProviders: [], + }, + auth: { + api: { + getSession: async () => session, + }, + } as unknown as BetterAuthAuth["api"], + } as unknown as BetterAuthRuntime; +} + +function makeSession(): BetterAuthSession { + return { + user: { + id: "user-123", + email: "hack@example.com", + emailVerified: true, + name: "Hack User", + }, + session: { + id: "sess-123", + userId: "user-123", + token: "session-token", + activeOrganizationId: "org-123", + activeTeamId: "team-123", + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + } as unknown as BetterAuthSession; +} + +function extractFlowState(authorizeUrl: string): string { + const state = new URL(authorizeUrl).searchParams.get("state"); + if (!state) { + throw new Error("Missing flow state."); + } + return state; +} + +describe("linear token custody", () => { + test("callback persists broker-held local access that can be reseeded later", async () => { + const config = createTestConfig(); + const flowStore = new FlowStore(); + const connectionStore = new InMemoryLinearConnectionStore(); + const betterAuthRuntime = createBetterAuthRuntimeWithSession(makeSession()); + const flow = createFlow({ + config, + flowStore, + query: { + profile: "default", + setDefault: "1", + desktopRedirectUrl: "hack-dev://auth/linear/callback", + }, + requestedBy: { + betterAuthUserId: "user-123", + betterAuthOrganizationId: "org-123", + betterAuthTeamId: "team-123", + }, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === config.linearTokenUrl) { + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: "lin_access_token", + expires_in: 3600, + refresh_token: "lin_refresh_token", + refresh_token_expires_in: 7200, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + } + if (url === config.linearApiBaseUrl) { + return Promise.resolve( + new Response( + JSON.stringify({ + data: { + viewer: { + id: "lin-user-1", + name: "Hack User", + displayName: "Hack User", + email: "hack@example.com", + organization: { + id: "lin-org-1", + name: "Hack Org", + }, + teams: { + nodes: [{ id: "team-123", name: "Team" }], + }, + }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + } + return Promise.resolve(new Response("not found", { status: 404 })); + }) as typeof fetch; + + try { + const response = await handleLinearCallback({ + config, + flowStore, + connectionStore, + betterAuthRuntime, + request: new Request("http://localhost/linear/callback"), + query: { + code: "linear-code", + state: extractFlowState(flow.authorizeUrl), + }, + }); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("Open Hack"); + + const connections = await connectionStore.listConnections({ + profileId: "default", + }); + expect(connections).toHaveLength(1); + expect(connections[0]?.localAccessAvailable).toBe(true); + + const seeded = await seedLinearLocalAccess({ + config, + connectionStore, + profileId: "default", + }); + expect(seeded.ok).toBe(true); + if (!seeded.ok) { + return; + } + expect(seeded.token).toBe("lin_access_token"); + expect(seeded.refreshToken).toBe("lin_refresh_token"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("seed route returns protected local access for an authorized Hack account", async () => { + const config = createTestConfig(); + const connectionStore: LinearConnectionStore = + new InMemoryLinearConnectionStore(); + await connectionStore.upsertConnection({ + profileId: "default", + accountId: "lin-user-1", + accountName: "Hack User", + accountEmail: "hack@example.com", + betterAuthUserId: "user-123", + betterAuthOrganizationId: "org-123", + betterAuthTeamId: "team-123", + organizationId: "lin-org-1", + teamId: "team-123", + }); + await connectionStore.saveLocalAccess({ + profileId: "default", + token: "lin_access_token", + tokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), + refreshToken: "lin_refresh_token", + refreshTokenExpiresAt: new Date(Date.now() + 7_200_000).toISOString(), + encryptionKey: config.providerTokenEncryptionKey ?? "", + }); + + const app = createAuthBrokerApp({ + config, + flowStore: new FlowStore(), + betterAuthRuntime: createBetterAuthRuntimeWithSession(makeSession()), + linearConnectionStore: connectionStore, + }); + + const response = await app.handle( + new Request("http://localhost/v1/auth/linear/connections/seed", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + profileId: "default", + }), + }) + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + readonly ok: true; + readonly seed: { + readonly profileId: string; + readonly token: string; + readonly refreshToken?: string; + }; + }; + expect(payload.ok).toBe(true); + expect(payload.seed.profileId).toBe("default"); + expect(payload.seed.token).toBe("lin_access_token"); + expect(payload.seed.refreshToken).toBe("lin_refresh_token"); + }); + + test("seed refreshes expired stored Linear access before returning it", async () => { + const config = createTestConfig(); + const connectionStore: LinearConnectionStore = + new InMemoryLinearConnectionStore(); + await connectionStore.upsertConnection({ + profileId: "default", + accountId: "lin-user-1", + accountName: "Hack User", + accountEmail: "hack@example.com", + betterAuthUserId: "user-123", + betterAuthOrganizationId: "org-123", + betterAuthTeamId: "team-123", + organizationId: "lin-org-1", + teamId: "team-123", + }); + await connectionStore.saveLocalAccess({ + profileId: "default", + token: "expired-token", + tokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), + refreshToken: "lin_refresh_token", + refreshTokenExpiresAt: new Date(Date.now() + 7_200_000).toISOString(), + encryptionKey: config.providerTokenEncryptionKey ?? "", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== config.linearTokenUrl) { + return Promise.resolve(new Response("not found", { status: 404 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: "lin_refreshed_access_token", + expires_in: 1800, + refresh_token: "lin_rotated_refresh_token", + refresh_token_expires_in: 172_800, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + }) as typeof fetch; + + try { + const seeded = await seedLinearLocalAccess({ + config, + connectionStore, + profileId: "default", + }); + expect(seeded.ok).toBe(true); + if (!seeded.ok) { + return; + } + expect(seeded.refreshed).toBe(true); + expect(seeded.token).toBe("lin_refreshed_access_token"); + expect(seeded.refreshToken).toBe("lin_rotated_refresh_token"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/src/control-plane/extensions/linear/auth.ts b/src/control-plane/extensions/linear/auth.ts index 41b342b65..b3c34cc1e 100644 --- a/src/control-plane/extensions/linear/auth.ts +++ b/src/control-plane/extensions/linear/auth.ts @@ -128,6 +128,8 @@ type LinearTokenEnvelope = { readonly managementTokenExpiresAt?: string; }; +export type LinearStoredTokenEnvelope = LinearTokenEnvelope; + type LinearProfileSelection = { readonly selectedProfileId: string; readonly selectedProfileSource: LinearProfileSelectionSource; @@ -606,6 +608,42 @@ export async function resolveLinearBrokerManagementToken(input: { }; } +/** + * Read the currently stored Linear token envelope for a selected profile. + */ +export async function readStoredLinearTokenEnvelope(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; + readonly authRef?: string; + readonly service?: string; + readonly store?: SecretStore; +}): Promise<{ + readonly profileId: string; + readonly authRef: string; + readonly service: string; + readonly envelope: LinearStoredTokenEnvelope | null; +}> { + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + }); + const authRef = (input.authRef ?? settings.authRef).trim(); + const service = (input.service ?? settings.service).trim(); + const store = input.store ?? DEFAULT_SECRET_STORE; + const stored = await store.get({ + service, + name: authRef, + }); + return { + profileId: settings.profileId, + authRef, + service, + envelope: parseStoredTokenEnvelope(stored), + }; +} + /** * Delete a Linear token from keychain for the selected profile. */ diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index c08c7d8e2..56dd7be7c 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -22,6 +22,7 @@ import type { ExtensionCommand, ExtensionCommandContext } from "../types.ts"; import { deleteLinearToken, listLinearAuthProfiles, + readStoredLinearTokenEnvelope, resolveLinearAuthSettings, resolveLinearAuthSettingsResult, resolveLinearBrokerManagementToken, @@ -409,11 +410,12 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ } await display.table({ - columns: ["Profile", "Account", "Email", "Owner", "Updated"], + columns: ["Profile", "Account", "Email", "Local", "Owner", "Updated"], rows: connections.data.connections.map((connection) => [ connection.profileId ?? "", connection.accountName ?? connection.accountId ?? "", connection.accountEmail ?? "", + connection.localAccessAvailable ? "ready" : "missing", describeLinearConnectionOwner(connection), connection.updatedAt, ]), @@ -421,6 +423,49 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 0; }, }, + { + name: "seed-local-access", + summary: + "Seed this Mac with local Linear access from a Hack-owned remote connection", + scope: "global", + allowWhenDisabled: true, + handler: async ({ ctx, args }) => { + const parsed = parseSeedLocalAccessArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const seeded = await seedLinearLocalAccessFromBroker({ + controlPlaneConfig: ctx.controlPlaneConfig, + profileId: parsed.value.profileId, + setDefault: parsed.value.setDefault, + }); + if (!seeded.ok) { + ctx.logger.error({ message: seeded.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(seeded.data, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "Linear local access seeded", + entries: [ + ["profile", seeded.data.profileId], + [ + "account", + seeded.data.accountName ?? seeded.data.accountEmail ?? "", + ], + ["refreshed", seeded.data.refreshed ? "yes" : "no"], + ["set_default", seeded.data.setDefault ? "yes" : "no"], + ], + }); + return 0; + }, + }, { name: "use", summary: "Set global default Linear profile", @@ -3400,6 +3445,7 @@ type LinearConnectionSummary = { readonly betterAuthTeamId: string | null; readonly organizationId: string | null; readonly teamId: string | null; + readonly localAccessAvailable: boolean; readonly metadata: Record; readonly createdAt: string; readonly updatedAt: string; @@ -3410,6 +3456,27 @@ type BrokerListConnectionsPayload = { readonly connections: readonly LinearConnectionSummary[]; }; +type BrokerSeedLocalAccessPayload = { + readonly seed: { + readonly profileId: string; + readonly accountName: string | null; + readonly accountEmail: string | null; + readonly token: string; + readonly tokenExpiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly refreshed: boolean; + }; +}; + +type SeededLinearLocalAccessResult = { + readonly profileId: string; + readonly accountName: string | null; + readonly accountEmail: string | null; + readonly refreshed: boolean; + readonly setDefault: boolean; +}; + type BrokerApplyDeliveryPayload = { readonly profileId?: string; readonly deliveryId: string; @@ -5634,6 +5701,40 @@ async function resolveLinearTokenWithBrokerRefresh(input: { const brokerConfig = resolveOAuthBrokerRuntimeConfig({ controlPlaneConfig: input.controlPlaneConfig, }); + const resolved = await resolveLinearToken({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + refreshConfig: { + baseUrl: brokerConfig.baseUrl, + }, + }); + if (resolved.ok) { + if (resolved.source === "refreshed") { + await syncLinearLocalAccessToBroker({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: resolved.profileId, + }); + } + return resolved; + } + + if ( + !shouldAttemptLinearBrokerLocalAccessSeed({ + error: resolved.error, + }) + ) { + return resolved; + } + + const seeded = await seedLinearLocalAccessFromBroker({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + }); + if (!seeded.ok) { + return resolved; + } + return await resolveLinearToken({ controlPlaneConfig: input.controlPlaneConfig, ...(input.profileId ? { profileId: input.profileId } : {}), @@ -5644,6 +5745,17 @@ async function resolveLinearTokenWithBrokerRefresh(input: { }); } +function shouldAttemptLinearBrokerLocalAccessSeed(input: { + readonly error: string; +}): boolean { + const error = input.error.trim(); + return ( + error.startsWith("Missing Linear token") || + error.includes("linear_local_access_unavailable") || + error.includes("linear_local_access_refresh_required") + ); +} + function shouldUseBrokerOAuthFlow(input: { readonly parsed: OAuthConnectArgs; }): boolean { @@ -5959,6 +6071,150 @@ async function listLinearConnections(input: { return { ok: true, data: payload }; } +async function seedLinearLocalAccessFromBroker(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId?: string; + readonly setDefault?: boolean; +}): Promise< + | { readonly ok: true; readonly data: SeededLinearLocalAccessResult } + | { readonly ok: false; readonly error: string } +> { + const profileId = resolveSelectedLinearProfileId({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId, + }); + if (!brokerAuth.ok) { + return brokerAuth; + } + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + const response = await fetchJson({ + url: new URL( + "/v1/auth/linear/connections/seed", + `${brokerConfig.baseUrl}/` + ).toString(), + init: { + method: "POST", + headers: { + "content-type": "application/json", + ...brokerAuth.headers, + }, + body: JSON.stringify({ + profileId: brokerAuth.profileId, + }), + }, + }); + if (!response.ok) { + return { + ok: false, + error: normalizeBrokerProtectedLinearError({ + error: response.error, + profileId: brokerAuth.profileId, + }), + }; + } + const payload = parseLinearSeedLocalAccessPayload({ + payload: response.value, + }); + if (!payload) { + return { + ok: false, + error: "Linear local access seed payload was invalid.", + }; + } + + const settings = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: payload.seed.profileId, + allowProjectOverride: false, + }); + await saveLinearToken({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: payload.seed.profileId, + allowProjectOverride: false, + token: payload.seed.token, + ...(payload.seed.tokenExpiresAt + ? { expiresAt: payload.seed.tokenExpiresAt } + : {}), + ...(payload.seed.refreshToken + ? { refreshToken: payload.seed.refreshToken } + : {}), + ...(payload.seed.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: payload.seed.refreshTokenExpiresAt } + : {}), + }); + await persistLinearProfileDefaults({ + profileId: payload.seed.profileId, + tokenEnv: settings.tokenEnv, + authRef: settings.authRef, + service: settings.service, + apiUrl: settings.apiUrl, + accountName: payload.seed.accountName ?? undefined, + accountEmail: payload.seed.accountEmail ?? undefined, + setAsDefault: input.setDefault ?? false, + }); + + return { + ok: true, + data: { + profileId: payload.seed.profileId, + accountName: payload.seed.accountName, + accountEmail: payload.seed.accountEmail, + refreshed: payload.seed.refreshed, + setDefault: input.setDefault ?? false, + }, + }; +} + +async function syncLinearLocalAccessToBroker(input: { + readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; + readonly profileId: string; +}): Promise { + const stored = await readStoredLinearTokenEnvelope({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + allowProjectOverride: false, + }); + if (!stored.envelope) { + return; + } + const brokerAuth = await resolveLinearBrokerAuthorization({ + controlPlaneConfig: input.controlPlaneConfig, + profileId: input.profileId, + }); + if (!brokerAuth.ok) { + return; + } + const brokerConfig = resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }); + await fetchJson({ + url: new URL( + "/v1/auth/linear/connections/update-local-access", + `${brokerConfig.baseUrl}/` + ).toString(), + init: { + method: "POST", + headers: { + "content-type": "application/json", + ...brokerAuth.headers, + }, + body: JSON.stringify({ + profileId: brokerAuth.profileId, + token: stored.envelope.token, + tokenExpiresAt: stored.envelope.expiresAt, + refreshToken: stored.envelope.refreshToken, + refreshTokenExpiresAt: stored.envelope.refreshTokenExpiresAt, + }), + }, + }).catch(() => null); +} + async function listLinearDeliveries(input: { readonly controlPlaneConfig: ExtensionCommandContext["controlPlaneConfig"]; readonly profileId?: string; @@ -6394,6 +6650,45 @@ function parseLinearConnectionsPayload(input: { }; } +function parseLinearSeedLocalAccessPayload(input: { + readonly payload: unknown; +}): BrokerSeedLocalAccessPayload | null { + if (!(isRecord(input.payload) && isRecord(input.payload.seed))) { + return null; + } + const profileId = readOptionalString(input.payload.seed.profileId); + const token = readOptionalString(input.payload.seed.token); + if (!(profileId && token)) { + return null; + } + return { + seed: { + profileId, + accountName: readOptionalString(input.payload.seed.accountName) ?? null, + accountEmail: readOptionalString(input.payload.seed.accountEmail) ?? null, + token, + ...(readOptionalString(input.payload.seed.tokenExpiresAt) + ? { + tokenExpiresAt: readOptionalString( + input.payload.seed.tokenExpiresAt + ), + } + : {}), + ...(readOptionalString(input.payload.seed.refreshToken) + ? { refreshToken: readOptionalString(input.payload.seed.refreshToken) } + : {}), + ...(readOptionalString(input.payload.seed.refreshTokenExpiresAt) + ? { + refreshTokenExpiresAt: readOptionalString( + input.payload.seed.refreshTokenExpiresAt + ), + } + : {}), + refreshed: input.payload.seed.refreshed === true, + }, + }; +} + function parseLinearConnectionSummary(input: { readonly value: unknown; }): LinearConnectionSummary | null { @@ -6419,6 +6714,7 @@ function parseLinearConnectionSummary(input: { betterAuthTeamId: readOptionalString(input.value.betterAuthTeamId) ?? null, organizationId: readOptionalString(input.value.organizationId) ?? null, teamId: readOptionalString(input.value.teamId) ?? null, + localAccessAvailable: input.value.localAccessAvailable === true, metadata: isRecord(input.value.metadata) ? input.value.metadata : ({} as Record), @@ -6965,6 +7261,12 @@ type ConnectionsArgs = { json: boolean; }; +type SeedLocalAccessArgs = { + profileId?: string; + setDefault: boolean; + json: boolean; +}; + type UseArgs = { profileId: string; }; @@ -7606,6 +7908,42 @@ function parseConnectionsArgs(input: { return { ok: true, value }; } +function parseSeedLocalAccessArgs(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: SeedLocalAccessArgs } + | { readonly ok: false; readonly error: string } { + const value: SeedLocalAccessArgs = { + setDefault: false, + json: false, + }; + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + if (token === "--set-default") { + value.setDefault = true; + continue; + } + if (token === "--json") { + value.json = true; + continue; + } + const handled = assignKeyValueFlag({ + token, + args: input.args, + index: i, + out: value, + keys: { + profile: "profileId", + }, + }); + if (!handled.ok) { + return { ok: false, error: handled.error }; + } + i = handled.nextIndex; + } + return { ok: true, value }; +} + function parseUseArgs(input: { readonly args: readonly string[]; }): @@ -7984,6 +8322,7 @@ export const __testOnly = { parseConnectionsArgs, parseDeliveriesArgs, parseOAuthConnectArgs, + parseSeedLocalAccessArgs, parseRunAutosyncArgs, parseProjectBindArgs, parseProjectLinkArgs, diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index 1456c5eb2..75eee414b 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -206,6 +206,23 @@ test("parseConnectionsArgs parses broker connection filters", () => { }); }); +test("parseSeedLocalAccessArgs parses repair flags", () => { + const parsed = __testOnly.parseSeedLocalAccessArgs({ + args: ["--profile", "work", "--set-default", "--json"], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + expect(parsed.value).toEqual({ + profileId: "work", + setDefault: true, + json: true, + }); +}); + test("parseRunAutosyncArgs parses profile, route filters, and limit", () => { const parsed = __testOnly.parseRunAutosyncArgs({ args: [ From 4cfc3baf148b7439797dd6ac31e9a3078f695ffc Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 19:51:34 -0500 Subject: [PATCH 32/54] Fix Linear local access seeding on main branch --- .../src/modules/linear-connections/service.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/auth-broker/src/modules/linear-connections/service.ts b/services/auth-broker/src/modules/linear-connections/service.ts index 16448f306..0b4b80e6e 100644 --- a/services/auth-broker/src/modules/linear-connections/service.ts +++ b/services/auth-broker/src/modules/linear-connections/service.ts @@ -230,7 +230,7 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { updatedAt, }; this.recordsByKey.set(connection.connectionKey, nextRecord); - return nextRecord; + return Promise.resolve(nextRecord); } readLocalAccess(input: { @@ -239,25 +239,25 @@ export class InMemoryLinearConnectionStore implements LinearConnectionStore { }): Promise { const profileId = normalizeText(input.profileId); if (!profileId) { - return null; + return Promise.resolve(null); } const connection = [...this.recordsByKey.values()] .filter((record) => record.profileId === profileId) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]; if (!connection) { - return null; + return Promise.resolve(null); } const envelope = this.localAccessByKey.get(connection.connectionKey); if (!envelope) { - return null; + return Promise.resolve(null); } - return { + return Promise.resolve({ connection: { ...connection, localAccessAvailable: true, }, envelope, - }; + }); } } From cddb89942382c960ae7b914ce15eb0b0925ccb62 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 20:13:27 -0500 Subject: [PATCH 33/54] Send Bearer auth to Linear APIs --- services/auth-broker/src/linear.ts | 2 +- .../tests/linear-auth-header.test.ts | 48 +++++++++++++++++++ src/control-plane/extensions/linear/client.ts | 2 +- tests/linear-client.test.ts | 35 ++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 services/auth-broker/tests/linear-auth-header.test.ts diff --git a/services/auth-broker/src/linear.ts b/services/auth-broker/src/linear.ts index 3d16c0c35..3861a1a9c 100644 --- a/services/auth-broker/src/linear.ts +++ b/services/auth-broker/src/linear.ts @@ -189,7 +189,7 @@ export async function fetchIdentity(input: { const response = await fetch(input.apiBaseUrl, { method: "POST", headers: { - Authorization: input.token, + Authorization: `Bearer ${input.token}`, "content-type": "application/json", accept: "application/json", "user-agent": USER_AGENT, diff --git a/services/auth-broker/tests/linear-auth-header.test.ts b/services/auth-broker/tests/linear-auth-header.test.ts new file mode 100644 index 000000000..1458b9dcd --- /dev/null +++ b/services/auth-broker/tests/linear-auth-header.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test"; + +import { fetchIdentity } from "../src/linear.ts"; + +const ORIGINAL_FETCH = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; +}); + +test("fetchIdentity sends bearer authorization for oauth tokens", async () => { + let authorization: string | null = null; + globalThis.fetch = ((_input, init) => { + authorization = + init?.headers && !Array.isArray(init.headers) + ? ((init.headers as Record).Authorization ?? null) + : null; + return Promise.resolve( + new Response( + JSON.stringify({ + data: { + viewer: { + id: "usr_123", + displayName: "Alice Example", + email: "alice@example.com", + organization: { + id: "org_123", + name: "Hack", + }, + teams: { + nodes: [], + }, + }, + }, + }), + { status: 200 } + ) + ); + }) as typeof fetch; + + const result = await fetchIdentity({ + apiBaseUrl: "https://api.linear.app/graphql", + token: "linear-oauth-token", + }); + + expect(result.ok).toBe(true); + expect(String(authorization)).toBe("Bearer linear-oauth-token"); +}); diff --git a/src/control-plane/extensions/linear/client.ts b/src/control-plane/extensions/linear/client.ts index c4e41da5a..dbc28b97e 100644 --- a/src/control-plane/extensions/linear/client.ts +++ b/src/control-plane/extensions/linear/client.ts @@ -176,7 +176,7 @@ export function createLinearClient(input: { const response = await fetch(apiUrl, { method: "POST", headers: { - Authorization: token, + Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": "hack-cli", }, diff --git a/tests/linear-client.test.ts b/tests/linear-client.test.ts index 3cc606bb6..482188fd5 100644 --- a/tests/linear-client.test.ts +++ b/tests/linear-client.test.ts @@ -77,6 +77,41 @@ test("getIssueByIdentifier includes assignee data when present", async () => { expect(result.data?.assigneeActive).toBe(true); }); +test("getViewer sends bearer authorization for oauth tokens", async () => { + let authorization: string | null = null; + globalThis.fetch = (async (_input, init) => { + if ( + init?.headers && + typeof Headers !== "undefined" && + init.headers instanceof Headers + ) { + authorization = init.headers.get("Authorization"); + } else if (init?.headers && !Array.isArray(init.headers)) { + authorization = + (init.headers as Record).Authorization ?? null; + } + return new Response( + JSON.stringify({ + data: { + viewer: { + id: "usr_123", + name: "Alice Example", + email: "alice@example.com", + displayName: "Alice", + }, + }, + }), + { status: 200 } + ); + }) as typeof fetch; + + const client = createLinearClient({ token: "linear-oauth-token" }); + const result = await client.getViewer(); + + expect(result.ok).toBe(true); + expect(authorization).toBe("Bearer linear-oauth-token"); +}); + test("listTeamUsers returns membership users for assignee mapping", async () => { let requestBody: { readonly query?: unknown; From 0e9a222fe820d95e99981836da6f3fbf3ddef88e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 20:39:46 -0500 Subject: [PATCH 34/54] Fix Linear GraphQL callback lookup --- services/auth-broker/.env.example | 2 +- services/auth-broker/README.md | 2 +- services/auth-broker/src/config.ts | 16 ++++++- services/auth-broker/src/linear.ts | 14 ++++++- services/auth-broker/tests/config.test.ts | 27 ++++++++++++ services/auth-broker/tests/index.test.ts | 2 +- .../tests/linear-auth-header.test.ts | 42 +++++++++++++++++++ .../tests/linear-token-custody.test.ts | 2 +- .../auth-broker/tests/session-auth.test.ts | 2 +- src/control-plane/extensions/linear/client.ts | 6 ++- tests/linear-client.test.ts | 13 +++++- 11 files changed, 118 insertions(+), 10 deletions(-) diff --git a/services/auth-broker/.env.example b/services/auth-broker/.env.example index 5325de094..9121d5a33 100644 --- a/services/auth-broker/.env.example +++ b/services/auth-broker/.env.example @@ -26,7 +26,7 @@ LINEAR_WEBHOOK_SIGNING_SECRET= HACK_LINEAR_SCOPES=read,write,app:mentionable,app:assignable HACK_LINEAR_AUTHORIZE_URL=https://linear.app/oauth/authorize HACK_LINEAR_TOKEN_URL=https://api.linear.app/oauth/token -HACK_LINEAR_API_BASE_URL=https://api.linear.app +HACK_LINEAR_API_BASE_URL=https://api.linear.app/graphql HACK_LINEAR_REDIRECT_URI=https://auth.hack.broker/linear/callback HACK_LINEAR_WEBHOOK_PATH=/linear/webhooks # Required to store encrypted provider-token custody for Linear local-access seeding. diff --git a/services/auth-broker/README.md b/services/auth-broker/README.md index 048e9cfc5..a66f26f51 100644 --- a/services/auth-broker/README.md +++ b/services/auth-broker/README.md @@ -311,7 +311,7 @@ Hack account ownership, not on provider identity. 33. `HACK_LINEAR_WEBHOOK_PATH` (default: `/linear/webhooks`) 34. `HACK_LINEAR_AUTHORIZE_URL` (optional; default: `https://linear.app/oauth/authorize`) 35. `HACK_LINEAR_TOKEN_URL` (optional; default: `https://api.linear.app/oauth/token`) -36. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app`) +36. `HACK_LINEAR_API_BASE_URL` (optional; default: `https://api.linear.app/graphql`) 37. `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` / `LINEAR_WEBHOOK_SIGNING_SECRET` / `LINEAR_OAUTH_ACTOR` (optional compatibility aliases) `services/auth-broker/.env.example` reflects the recommended grouping: diff --git a/services/auth-broker/src/config.ts b/services/auth-broker/src/config.ts index 590c98cea..71e90cb72 100644 --- a/services/auth-broker/src/config.ts +++ b/services/auth-broker/src/config.ts @@ -46,11 +46,12 @@ const DEFAULT_LINEAR_SCOPES = "read,write,app:mentionable,app:assignable"; const DEFAULT_LINEAR_ACTOR = "app"; const DEFAULT_LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize"; const DEFAULT_LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token"; -const DEFAULT_LINEAR_API_BASE_URL = "https://api.linear.app"; +const DEFAULT_LINEAR_API_BASE_URL = "https://api.linear.app/graphql"; const DEFAULT_LINEAR_WEBHOOK_PATH = "/linear/webhooks"; const DEFAULT_FLOW_TTL_MS = 10 * 60 * 1000; const DEFAULT_FLOW_SWEEP_INTERVAL_MS = 30 * 1000; const TRAILING_SLASH_PATTERN = /\/$/; +const TRAILING_PATH_SLASHES_PATTERN = /\/+$/; const GITHUB_SCOPE_SPLIT_PATTERN = /[,\s]+/; const NEWLINE_SPLIT_PATTERN = /\r?\n/; const ROOT_ENV_FALLBACK_FILES = [ @@ -250,7 +251,7 @@ function resolveLinearConfig(input: { readFirstEnv(["HACK_LINEAR_TOKEN_URL", "LINEAR_TOKEN_URL"]) ) ?? DEFAULT_LINEAR_TOKEN_URL, linearApiBaseUrl: - normalizeUrl( + normalizeLinearApiBaseUrl( readFirstEnv(["HACK_LINEAR_API_BASE_URL", "LINEAR_API_BASE_URL"]) ) ?? DEFAULT_LINEAR_API_BASE_URL, linearRedirectUri: @@ -281,6 +282,17 @@ function normalizeLinearActor( return null; } +function normalizeLinearApiBaseUrl(value: string | undefined): string | null { + const normalized = normalizeUrl(value); + if (!normalized) { + return null; + } + const url = new URL(normalized); + const pathname = url.pathname.replace(TRAILING_PATH_SLASHES_PATTERN, ""); + url.pathname = pathname.length === 0 ? "/graphql" : pathname; + return url.toString(); +} + function normalizeGitHubScopes(value: string | undefined): string | null { return normalizeOAuthScopes({ value }); } diff --git a/services/auth-broker/src/linear.ts b/services/auth-broker/src/linear.ts index 3861a1a9c..c4e9bbb45 100644 --- a/services/auth-broker/src/linear.ts +++ b/services/auth-broker/src/linear.ts @@ -1,6 +1,7 @@ import type { OAuthFlowAccount } from "./types.ts"; const USER_AGENT = "hack-auth-broker"; +const TRAILING_PATH_SLASHES_PATTERN = /\/+$/; const LINEAR_VIEWER_QUERY = [ "query LinearViewer {", " viewer {", @@ -186,7 +187,7 @@ export async function fetchIdentity(input: { readonly apiBaseUrl: string; readonly token: string; }): Promise { - const response = await fetch(input.apiBaseUrl, { + const response = await fetch(normalizeGraphqlUrl(input.apiBaseUrl), { method: "POST", headers: { Authorization: `Bearer ${input.token}`, @@ -223,6 +224,17 @@ export async function fetchIdentity(input: { return { ok: true, account }; } +function normalizeGraphqlUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return "https://api.linear.app/graphql"; + } + const url = new URL(trimmed); + const pathname = url.pathname.replace(TRAILING_PATH_SLASHES_PATTERN, ""); + url.pathname = pathname.length === 0 ? "/graphql" : pathname; + return url.toString(); +} + function buildIdentityAccount(input: { readonly payload: Record; }): OAuthFlowAccount | null { diff --git a/services/auth-broker/tests/config.test.ts b/services/auth-broker/tests/config.test.ts index e593a7181..d07c5cbc6 100644 --- a/services/auth-broker/tests/config.test.ts +++ b/services/auth-broker/tests/config.test.ts @@ -144,6 +144,33 @@ describe("auth broker config", () => { } ); }); + + test("defaults Linear API base URL to the GraphQL endpoint", () => { + withEnv( + { + GITHUB_CLIENT_ID: "test-client-id", + GITHUB_CLIENT_SECRET: "test-client-secret", + }, + () => { + const config = resolveConfig(); + expect(config.linearApiBaseUrl).toBe("https://api.linear.app/graphql"); + } + ); + }); + + test("normalizes configured Linear API base URL to the GraphQL endpoint", () => { + withEnv( + { + GITHUB_CLIENT_ID: "test-client-id", + GITHUB_CLIENT_SECRET: "test-client-secret", + HACK_LINEAR_API_BASE_URL: "https://api.linear.app", + }, + () => { + const config = resolveConfig(); + expect(config.linearApiBaseUrl).toBe("https://api.linear.app/graphql"); + } + ); + }); }); function withEnv(overrides: EnvMap, run: () => void): void { diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 3584d1890..0d37f432f 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -143,7 +143,7 @@ function createTestConfig() { linearScopes: "read,write,app:mentionable,app:assignable", linearAuthorizeUrl: "https://linear.app/oauth/authorize", linearTokenUrl: "https://api.linear.app/oauth/token", - linearApiBaseUrl: "https://api.linear.app", + linearApiBaseUrl: "https://api.linear.app/graphql", linearRedirectUri: "http://127.0.0.1:8080/linear/callback", linearWebhookPath: "/linear/webhooks", flowTtlMs: 60_000, diff --git a/services/auth-broker/tests/linear-auth-header.test.ts b/services/auth-broker/tests/linear-auth-header.test.ts index 1458b9dcd..dbf68534c 100644 --- a/services/auth-broker/tests/linear-auth-header.test.ts +++ b/services/auth-broker/tests/linear-auth-header.test.ts @@ -46,3 +46,45 @@ test("fetchIdentity sends bearer authorization for oauth tokens", async () => { expect(result.ok).toBe(true); expect(String(authorization)).toBe("Bearer linear-oauth-token"); }); + +test("fetchIdentity normalizes the Linear GraphQL endpoint", async () => { + let requestedUrl: string | null = null; + globalThis.fetch = ((_input, _init) => { + if (typeof _input === "string") { + requestedUrl = _input; + } else if (_input instanceof URL) { + requestedUrl = _input.toString(); + } else { + requestedUrl = _input.url; + } + return Promise.resolve( + new Response( + JSON.stringify({ + data: { + viewer: { + id: "usr_123", + displayName: "Alice Example", + email: "alice@example.com", + organization: { + id: "org_123", + name: "Hack", + }, + teams: { + nodes: [], + }, + }, + }, + }), + { status: 200 } + ) + ); + }) as typeof fetch; + + const result = await fetchIdentity({ + apiBaseUrl: "https://api.linear.app", + token: "linear-oauth-token", + }); + + expect(result.ok).toBe(true); + expect(String(requestedUrl)).toBe("https://api.linear.app/graphql"); +}); diff --git a/services/auth-broker/tests/linear-token-custody.test.ts b/services/auth-broker/tests/linear-token-custody.test.ts index 683840eaa..fd2a36257 100644 --- a/services/auth-broker/tests/linear-token-custody.test.ts +++ b/services/auth-broker/tests/linear-token-custody.test.ts @@ -42,7 +42,7 @@ function createTestConfig(): BrokerConfig { linearScopes: "read,write,app:mentionable,app:assignable", linearAuthorizeUrl: "https://linear.app/oauth/authorize", linearTokenUrl: "https://api.linear.app/oauth/token", - linearApiBaseUrl: "https://api.linear.app", + linearApiBaseUrl: "https://api.linear.app/graphql", linearRedirectUri: "http://127.0.0.1:8080/linear/callback", linearWebhookPath: "/linear/webhooks", linearWebhookSigningSecret: undefined, diff --git a/services/auth-broker/tests/session-auth.test.ts b/services/auth-broker/tests/session-auth.test.ts index fd45b813e..5f76a49cf 100644 --- a/services/auth-broker/tests/session-auth.test.ts +++ b/services/auth-broker/tests/session-auth.test.ts @@ -44,7 +44,7 @@ function createTestConfig() { linearScopes: "read,write,app:mentionable,app:assignable", linearAuthorizeUrl: "https://linear.app/oauth/authorize", linearTokenUrl: "https://api.linear.app/oauth/token", - linearApiBaseUrl: "https://api.linear.app", + linearApiBaseUrl: "https://api.linear.app/graphql", linearRedirectUri: "http://127.0.0.1:8080/linear/callback", linearWebhookPath: "/linear/webhooks", flowTtlMs: 60_000, diff --git a/src/control-plane/extensions/linear/client.ts b/src/control-plane/extensions/linear/client.ts index dbc28b97e..49177db88 100644 --- a/src/control-plane/extensions/linear/client.ts +++ b/src/control-plane/extensions/linear/client.ts @@ -156,6 +156,7 @@ export type LinearClient = { const DEFAULT_LINEAR_API_URL = "https://api.linear.app/graphql"; const DEFAULT_PAGE_SIZE = 50; +const TRAILING_PATH_SLASHES_PATTERN = /\/+$/; /** * Build a Linear GraphQL client for issue/project sync operations. @@ -1264,5 +1265,8 @@ function normalizeApiUrl(input: { readonly value: string }): string { if (!trimmed) { return DEFAULT_LINEAR_API_URL; } - return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; + const url = new URL(trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed); + const pathname = url.pathname.replace(TRAILING_PATH_SLASHES_PATTERN, ""); + url.pathname = pathname.length === 0 ? "/graphql" : pathname; + return url.toString(); } diff --git a/tests/linear-client.test.ts b/tests/linear-client.test.ts index 482188fd5..e13dfde89 100644 --- a/tests/linear-client.test.ts +++ b/tests/linear-client.test.ts @@ -79,7 +79,14 @@ test("getIssueByIdentifier includes assignee data when present", async () => { test("getViewer sends bearer authorization for oauth tokens", async () => { let authorization: string | null = null; + let requestedUrl: string | null = null; globalThis.fetch = (async (_input, init) => { + requestedUrl = + typeof _input === "string" + ? _input + : _input instanceof URL + ? _input.toString() + : _input.url; if ( init?.headers && typeof Headers !== "undefined" && @@ -105,11 +112,15 @@ test("getViewer sends bearer authorization for oauth tokens", async () => { ); }) as typeof fetch; - const client = createLinearClient({ token: "linear-oauth-token" }); + const client = createLinearClient({ + token: "linear-oauth-token", + apiUrl: "https://api.linear.app", + }); const result = await client.getViewer(); expect(result.ok).toBe(true); expect(authorization).toBe("Bearer linear-oauth-token"); + expect(requestedUrl).toBe("https://api.linear.app/graphql"); }); test("listTeamUsers returns membership users for assignee mapping", async () => { From 735961fd30a98457651800445ad43ac928cb8a04 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 20:59:57 -0500 Subject: [PATCH 35/54] Fix false Linear repair state in desktop settings --- .../SettingsOverlayView.swift | 23 ++++++++++++------- .../Sources/HackDesktopModels/Models.swift | 17 ++++++++++++++ .../LinearSyncUXModelTests.swift | 22 ++++++++++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index 0442295f1..f99c08202 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -3435,10 +3435,14 @@ private struct LinearExtensionSettingsView: View { _ connection: LinearRemoteConnection ) -> LinearConnectionPresentationState { let profileId = connection.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !profileId.isEmpty else { - return .needsAttention - } - return hasLocalLinearAccess(profileId: profileId) ? .connected : .needsAttention + let localProfilePresent = localLinearProfile(profileId: profileId) != nil + let localTokenResolved = profileStatusById[profileId]?.tokenResolved + return LinearConnectionStateResolver.presentationState( + profileId: profileId, + localProfilePresent: localProfilePresent, + localTokenResolved: localTokenResolved, + remoteLocalAccessAvailable: connection.localAccessAvailable + ) } private var linearConnectionNeedsAttention: Bool { @@ -3509,10 +3513,10 @@ private struct LinearExtensionSettingsView: View { guard !defaultProfileId.isEmpty else { return false } - if remoteLinearConnections.contains(where: { connection in + if let remoteConnection = remoteLinearConnections.first(where: { connection in connection.profileId?.caseInsensitiveCompare(defaultProfileId) == .orderedSame }) { - return !hasLocalLinearAccess(profileId: defaultProfileId) + return linearRemoteConnectionState(remoteConnection) == .needsAttention } guard linearProfiles.contains(where: { $0.id.caseInsensitiveCompare(defaultProfileId) == .orderedSame @@ -4084,9 +4088,12 @@ private struct LinearExtensionSettingsView: View { ?? "Linear provider account" await model.refresh() await refreshLinearDiagnostics() - if remoteLinearConnections.contains(where: { + let resolvedConnection = remoteLinearConnections.first(where: { $0.profileId?.caseInsensitiveCompare(flowStatus.profileId) == .orderedSame - }) && !hasLocalLinearAccess(profileId: flowStatus.profileId) { + }) + if let resolvedConnection, + linearRemoteConnectionState(resolvedConnection) == .needsAttention + { message = "\(account) is connected on Hack. Use Repair access to finish setup on this Mac." } else { message = "Connected \(account)." diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 04bb70d80..eb6106e45 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -1436,6 +1436,23 @@ public enum LinearConnectionPresentationState: String, Hashable { } } +public enum LinearConnectionStateResolver { + public static func presentationState( + profileId: String?, + localProfilePresent: Bool, + localTokenResolved: Bool?, + remoteLocalAccessAvailable: Bool + ) -> LinearConnectionPresentationState { + let trimmedProfileId = profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmedProfileId.isEmpty else { + return .needsAttention + } + + let localAccessReady = localProfilePresent && (localTokenResolved ?? true) + return (localAccessReady || remoteLocalAccessAvailable) ? .connected : .needsAttention + } +} + public struct LinearStatusResponse: Decodable, Hashable { public let extensionId: String public let selectedProfile: String diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift index 55835e7f6..4fb045486 100644 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift @@ -3,6 +3,28 @@ import XCTest @testable import HackDesktopModels final class LinearSyncUXModelTests: XCTestCase { + func testRemoteLinearConnectionWithBrokerSeededLocalAccessRendersConnected() { + let state = LinearConnectionStateResolver.presentationState( + profileId: "default", + localProfilePresent: false, + localTokenResolved: nil, + remoteLocalAccessAvailable: true + ) + + XCTAssertEqual(state, .connected) + } + + func testRemoteLinearConnectionWithoutLocalOrBrokerAccessNeedsAttention() { + let state = LinearConnectionStateResolver.presentationState( + profileId: "default", + localProfilePresent: false, + localTokenResolved: nil, + remoteLocalAccessAvailable: false + ) + + XCTAssertEqual(state, .needsAttention) + } + func testHackOriginLinkedTicketIsHackAuthoritative() { let ticket = makeTicketSummary( owner: "hack", From aa0eed6d77134021db367d269a519301008c012a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 21:12:39 -0500 Subject: [PATCH 36/54] Recover from stale tickets git index locks --- .../extensions/tickets/tickets-git-channel.ts | 41 +++++++++++++++++++ tests/tickets-store.test.ts | 24 +++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/control-plane/extensions/tickets/tickets-git-channel.ts b/src/control-plane/extensions/tickets/tickets-git-channel.ts index 1f6550416..cb5539a47 100644 --- a/src/control-plane/extensions/tickets/tickets-git-channel.ts +++ b/src/control-plane/extensions/tickets/tickets-git-channel.ts @@ -78,6 +78,7 @@ export function createGitTicketsChannel(opts: { const localBranchRef = `refs/heads/${branch}`; const trackingRef = `refs/remotes/origin/${branch}`; const remoteName = gitEnabled ? (opts.config.remote ?? "origin").trim() : ""; + const bareIndexLockPath = resolve(bareDir, "index.lock"); const runGitDir = async (input: { readonly args: readonly string[]; @@ -86,6 +87,39 @@ export function createGitTicketsChannel(opts: { readonly stdout: string; readonly stderr: string; }> => { + for (let attempt = 0; attempt < 5; attempt += 1) { + const result = await runGit({ + cwd: opts.projectRoot, + args: [ + `--git-dir=${bareDir}`, + `--work-tree=${worktreeDir}`, + ...input.args, + ], + }); + if (result.ok) { + return result; + } + + const message = `${result.stderr}\n${result.stdout}`.trim(); + if (!isGitIndexLockError(message)) { + return result; + } + + if (attempt < 3) { + await Bun.sleep(150 * (attempt + 1)); + continue; + } + + opts.logger.warn({ + message: `tickets git index.lock blocked ${input.args.join(" ")}; removing stale lock and retrying`, + }); + try { + await rm(bareIndexLockPath, { force: true }); + } catch { + // Best-effort stale lock cleanup; retry will surface a real error if it still exists. + } + } + return await runGit({ cwd: opts.projectRoot, args: [ @@ -870,3 +904,10 @@ function isHiddenRefRejected(message: string): boolean { normalized.includes("not a valid ref") ); } + +function isGitIndexLockError(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes("index.lock") && normalized.includes("file exists") + ); +} diff --git a/tests/tickets-store.test.ts b/tests/tickets-store.test.ts index 0ae0d5377..099e38e01 100644 --- a/tests/tickets-store.test.ts +++ b/tests/tickets-store.test.ts @@ -316,6 +316,30 @@ test("tickets show json includes materialized sync metadata", async () => { ).toBe(true); }, 20_000); +test("tickets store recovers from a stale tickets bare repo index.lock", async () => { + const projectRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-stale-lock-", + }); + const store = await createStore({ projectRoot }); + + const created = await store.createTicket({ + title: "Stale lock ticket", + owner: "hack", + source: "hack", + actor: "creator@hack", + }); + expect(created.ok).toBe(true); + if (!created.ok) { + throw new Error(created.error); + } + + const lockPath = join(projectRoot, ".hack/tickets/git/bare.git/index.lock"); + await writeFile(lockPath, "stale lock\n"); + + const tickets = await store.listTickets(); + expect(tickets.map((ticket) => ticket.title)).toContain("Stale lock ticket"); +}, 20_000); + async function createStore(opts: { readonly projectRoot: string }) { const configResult = await readControlPlaneConfig({ projectDir: join(opts.projectRoot, ".hack"), From 3e1641b1b69add5982f7f3e2ab5acf927764f00e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 7 Mar 2026 22:25:37 -0500 Subject: [PATCH 37/54] Fix Linear project routing selector state --- .../DashboardFeature/ProjectDetailView.swift | 119 +++++++++++------- src/control-plane/extensions/linear/client.ts | 43 +++++-- tests/linear-client.test.ts | 64 ++++++++++ 3 files changed, 169 insertions(+), 57 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 637a03186..f981fa57f 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -140,6 +140,7 @@ struct ProjectDetailView: View { @State private var projectSystemGitIdentity: GitSystemIdentity? = nil @State private var executionTargetReloadTask: Task? = nil @State private var showLinearRoutingSheet = false + @State private var linearRoutingSheetLoading = false var body: some View { @Bindable var model = model @@ -176,6 +177,17 @@ struct ProjectDetailView: View { .onChange(of: project.kind) { _, _ in ensureSidebarSelection() } + .onChange(of: model.lastUpdated) { _, _ in + queueExecutionTargetReload() + } + .onChange(of: showLinearRoutingSheet) { _, isPresented in + guard isPresented else { + return + } + Task { + await refreshLinearRoutingSheetState() + } + } .onReceive(NotificationCenter.default.publisher(for: .hackProjectNavigationRequested)) { notification in guard let userInfo = notification.userInfo, @@ -919,75 +931,84 @@ struct ProjectDetailView: View { Text("Linear routing") .font(.system(size: 22, weight: .semibold)) + if linearRoutingSheetLoading { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Loading connected account and project routes…") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + } + routingSheetSection( - title: "Saved profile", - help: "Choose which saved Linear profile this repo routes through." + title: "Account", + help: "Choose which saved Linear account this repo routes through." ) { if hasConfiguredLinearProfile { projectSettingsControlGroup(footnote: selectedLinearProfileSummary) { VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel("Project profile") - projectSettingsControlShell(maxWidth: 320) { - Menu { - Button { - guard !linearProjectProfile.isEmpty else { - return - } - linearProjectProfile = "" - guard !executionTargetLoading else { - return - } - Task { await persistLinearProfileOverride() } - } label: { - if linearProjectProfile.isEmpty { - Label("Inherited", systemImage: "checkmark") - } else { - Text("Inherited") - } - } - ForEach(linearProfileOptions, id: \.self) { profile in + if linearProfileOptions.count <= 1 { + projectSettingsFieldLabel("Connected account") + projectSettingsControlShell(maxWidth: 320) { + projectSettingsStaticValue(selectedLinearProfileSummary) + .accessibilityLabel("Connected account") + } + } else { + projectSettingsFieldLabel("Connected account") + projectSettingsControlShell(maxWidth: 320) { + Menu { Button { - guard linearProjectProfile != profile else { + guard !linearProjectProfile.isEmpty else { return } - linearProjectProfile = profile + linearProjectProfile = "" guard !executionTargetLoading else { return } Task { await persistLinearProfileOverride() } } label: { - let label = linearProfileLabel(profileId: profile) - if linearProjectProfile == profile { - Label(label, systemImage: "checkmark") + if linearProjectProfile.isEmpty { + Label("Inherited", systemImage: "checkmark") } else { - Text(label) + Text("Inherited") + } + } + ForEach(linearProfileOptions, id: \.self) { profile in + Button { + guard linearProjectProfile != profile else { + return + } + linearProjectProfile = profile + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } + } label: { + let label = linearProfileLabel(profileId: profile) + if linearProjectProfile == profile { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } } } + } label: { + projectSettingsMenuLabel(selectedLinearProfileSummary) } - } label: { - projectSettingsMenuLabel(selectedLinearProfileSummary) + .buttonStyle(.plain) + .accessibilityLabel("Connected account") } - .buttonStyle(.plain) - .accessibilityLabel("Saved profile") } HStack(alignment: .center, spacing: 12) { StatusPill(text: "Connected", tone: .good) - Spacer() - Button("Open Linear settings") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) - } - .adaptiveToolbarButton() } } } } else { VStack(alignment: .leading, spacing: 10) { - Button("Connect Linear") { + Button("Connect account") { NotificationCenter.default.post( name: .hackSettingsRequested, object: nil, @@ -1145,10 +1166,10 @@ struct ProjectDetailView: View { } .padding(24) } - .frame(minWidth: 620, minHeight: 560) + .frame(minWidth: 620, minHeight: hasConfiguredLinearProfile ? 560 : 360) .background(Color.clear) .task { - await loadLinearRoutingProjectOptionsIfNeeded() + await refreshLinearRoutingSheetState() } } @@ -2171,6 +2192,16 @@ struct ProjectDetailView: View { } } + private func refreshLinearRoutingSheetState() async { + guard showLinearRoutingSheet else { + return + } + linearRoutingSheetLoading = true + defer { linearRoutingSheetLoading = false } + await reloadExecutionTargetState() + await loadLinearRoutingProjectOptionsIfNeeded() + } + private func linearAccountEmail(profileId: String) -> String? { let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/src/control-plane/extensions/linear/client.ts b/src/control-plane/extensions/linear/client.ts index 49177db88..bf8146c9f 100644 --- a/src/control-plane/extensions/linear/client.ts +++ b/src/control-plane/extensions/linear/client.ts @@ -253,16 +253,20 @@ export function createLinearClient(input: { fallback: DEFAULT_PAGE_SIZE, }); const result = await request<{ - readonly viewer?: unknown; + readonly projects?: unknown; }>({ query: [ "query LinearProjects($first: Int!) {", - " viewer {", - " projects(first: $first) {", - " nodes {", - " id", - " name", - " team { id key name }", + " projects(first: $first) {", + " nodes {", + " id", + " name", + " teams {", + " nodes {", + " id", + " key", + " name", + " }", " }", " }", " }", @@ -273,7 +277,7 @@ export function createLinearClient(input: { if (!result.ok) { return result; } - const projects = parseViewerProjects(result.data.viewer); + const projects = parseProjectsConnection(result.data.projects); return { ok: true, data: projects }; }, @@ -294,7 +298,13 @@ export function createLinearClient(input: { " project(id: $projectId) {", " id", " name", - " team { id key name }", + " teams {", + " nodes {", + " id", + " key", + " name", + " }", + " }", " }", "}", ].join("\n"), @@ -869,12 +879,11 @@ function parseViewer(value: unknown): { }; } -function parseViewerProjects(value: unknown): LinearProject[] { +function parseProjectsConnection(value: unknown): LinearProject[] { if (!isRecord(value)) { return []; } - const projects = isRecord(value.projects) ? value.projects : null; - const nodes = Array.isArray(projects?.nodes) ? projects.nodes : []; + const nodes = Array.isArray(value.nodes) ? value.nodes : []; const out: LinearProject[] = []; for (const node of nodes) { const parsed = parseProject(node); @@ -908,7 +917,15 @@ function parseProject(value: unknown): LinearProject | null { if (!(typeof value.id === "string" && typeof value.name === "string")) { return null; } - const team = isRecord(value.team) ? value.team : null; + const teams = isRecord(value.teams) ? value.teams : null; + const teamNodes = Array.isArray(teams?.nodes) ? teams.nodes : []; + const firstTeam = teamNodes.find((node) => isRecord(node)); + let team: Record | null = null; + if (isRecord(firstTeam)) { + team = firstTeam; + } else if (isRecord(value.team)) { + team = value.team; + } if (!(team && typeof team.id === "string")) { return null; } diff --git a/tests/linear-client.test.ts b/tests/linear-client.test.ts index e13dfde89..c528595ac 100644 --- a/tests/linear-client.test.ts +++ b/tests/linear-client.test.ts @@ -123,6 +123,70 @@ test("getViewer sends bearer authorization for oauth tokens", async () => { expect(requestedUrl).toBe("https://api.linear.app/graphql"); }); +test("listProjects queries the top-level projects connection", async () => { + let requestBody: { + readonly query?: unknown; + readonly variables?: unknown; + } | null = null; + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as { + readonly query?: unknown; + readonly variables?: unknown; + }; + return new Response( + JSON.stringify({ + data: { + projects: { + nodes: [ + { + id: "project_123", + name: "Event Agent", + teams: { + nodes: [ + { + id: "team_123", + key: "OPS", + name: "Operations", + }, + ], + }, + }, + ], + }, + }, + }), + { status: 200 } + ); + }) as typeof fetch; + + const client = createLinearClient({ token: "linear-token" }); + const result = await client.listProjects(); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect( + String((requestBody as { readonly query?: unknown } | null)?.query ?? "") + ).toContain("projects(first: $first)"); + expect( + String((requestBody as { readonly query?: unknown } | null)?.query ?? "") + ).not.toContain("viewer {"); + expect( + String((requestBody as { readonly query?: unknown } | null)?.query ?? "") + ).toContain("teams {"); + expect(result.data).toEqual([ + { + id: "project_123", + name: "Event Agent", + teamId: "team_123", + teamKey: "OPS", + teamName: "Operations", + }, + ]); +}); + test("listTeamUsers returns membership users for assignee mapping", async () => { let requestBody: { readonly query?: unknown; From 03bd184f6c53e10d7882e0eb6786ee2c3e3f429d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 15:45:09 -0400 Subject: [PATCH 38/54] Fix Linear project ticket settings UX --- .../DashboardFeature/ProjectDetailView.swift | 461 ++++++++++-------- 1 file changed, 267 insertions(+), 194 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index f981fa57f..02ac87ce0 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -141,6 +141,7 @@ struct ProjectDetailView: View { @State private var executionTargetReloadTask: Task? = nil @State private var showLinearRoutingSheet = false @State private var linearRoutingSheetLoading = false + @State private var linearProjectOptionsLoading = false var body: some View { @Bindable var model = model @@ -876,43 +877,69 @@ struct ProjectDetailView: View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Linear routing") + Text("Ticket sync") .font(.system(size: 15, weight: .semibold)) Text(linearRoutingSummaryLine) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) } Spacer() - Button(hasConfiguredLinearProfile ? "Edit routing" : "Connect Linear") { + Button(hasConfiguredLinearProfile ? "Advanced" : "Open Linear settings") { showLinearRoutingSheet = true } .adaptiveToolbarButtonProminent() } - HStack(spacing: 8) { - if hasConfiguredLinearProfile { - StatusPill(text: "Configured", tone: .good) - } else { - StatusPill(text: "Needs connection", tone: .warn) - } - if !linearBoundProjectName.isEmpty { - StatusPill(text: "Default \(linearBoundProjectName)", tone: .neutral) - } else if !linearBoundProjectId.isEmpty { - StatusPill(text: "Default bound", tone: .neutral) - } - if !linearAdditionalProjects.isEmpty { - StatusPill( - text: "\(linearAdditionalProjects.count) linked project\(linearAdditionalProjects.count == 1 ? "" : "s")", - tone: .neutral - ) + if hasConfiguredLinearProfile { + VStack(alignment: .leading, spacing: 14) { + if linearProfileOptions.count > 1 { + projectSettingsField( + title: "Linear account", + help: "Use a different saved Linear account for this repo when needed.", + footnote: selectedLinearProfileSummary + ) { + linearAccountPickerControl(accessibilityLabel: "Linear account") + } + } else { + projectSettingsField( + title: "Linear account", + help: "The connected Linear account this repo uses for ticket sync.", + footnote: selectedLinearProfileSummary + ) { + projectSettingsStaticValue(selectedLinearProfileSummary) + .accessibilityLabel("Linear account") + } + } + + projectSettingsField( + title: "Linear project", + help: "Choose the main Linear project this repo should pull from and push to.", + footnote: selectedLinearProjectFootnote + ) { + linearProjectPickerControl(accessibilityLabel: "Linear project") + } + + HStack(spacing: 8) { + if !linearAdditionalProjects.isEmpty { + StatusPill( + text: "\(linearAdditionalProjects.count) additional project\(linearAdditionalProjects.count == 1 ? "" : "s")", + tone: .neutral + ) + } + if hasAnyLinearAutosyncRoutes { + StatusPill( + text: "Autosync on for \(linearAutosyncRouteKeys.count)", + tone: .good + ) + } + Spacer(minLength: 0) + } } - if hasAnyLinearAutosyncRoutes { - StatusPill( - text: "\(linearAutosyncRouteKeys.count) autosync route\(linearAutosyncRouteKeys.count == 1 ? "" : "s")", - tone: .good - ) + } else { + HStack(spacing: 8) { + StatusPill(text: "Needs connection", tone: .warn) + Spacer(minLength: 0) } - Spacer(minLength: 0) } if !linearProjectMessage.isEmpty { @@ -923,179 +950,37 @@ struct ProjectDetailView: View { } .padding(16) .background(projectSettingsCardBackground) + .task(id: resolvedLinearRoutingProfileId ?? "") { + guard !showLinearRoutingSheet else { + return + } + await loadLinearRoutingProjectOptionsIfNeeded() + } } private var linearRoutingSheet: some View { ScrollView { - VStack(alignment: .leading, spacing: 20) { - Text("Linear routing") + VStack(alignment: .leading, spacing: 18) { + Text("Linear settings") .font(.system(size: 22, weight: .semibold)) if linearRoutingSheetLoading { HStack(spacing: 10) { ProgressView() .controlSize(.small) - Text("Loading connected account and project routes…") + Text("Loading Linear settings…") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } } - routingSheetSection( - title: "Account", - help: "Choose which saved Linear account this repo routes through." - ) { - if hasConfiguredLinearProfile { - projectSettingsControlGroup(footnote: selectedLinearProfileSummary) { - VStack(alignment: .leading, spacing: 10) { - if linearProfileOptions.count <= 1 { - projectSettingsFieldLabel("Connected account") - projectSettingsControlShell(maxWidth: 320) { - projectSettingsStaticValue(selectedLinearProfileSummary) - .accessibilityLabel("Connected account") - } - } else { - projectSettingsFieldLabel("Connected account") - projectSettingsControlShell(maxWidth: 320) { - Menu { - Button { - guard !linearProjectProfile.isEmpty else { - return - } - linearProjectProfile = "" - guard !executionTargetLoading else { - return - } - Task { await persistLinearProfileOverride() } - } label: { - if linearProjectProfile.isEmpty { - Label("Inherited", systemImage: "checkmark") - } else { - Text("Inherited") - } - } - ForEach(linearProfileOptions, id: \.self) { profile in - Button { - guard linearProjectProfile != profile else { - return - } - linearProjectProfile = profile - guard !executionTargetLoading else { - return - } - Task { await persistLinearProfileOverride() } - } label: { - let label = linearProfileLabel(profileId: profile) - if linearProjectProfile == profile { - Label(label, systemImage: "checkmark") - } else { - Text(label) - } - } - } - } label: { - projectSettingsMenuLabel(selectedLinearProfileSummary) - } - .buttonStyle(.plain) - .accessibilityLabel("Connected account") - } - } - - HStack(alignment: .center, spacing: 12) { - StatusPill(text: "Connected", tone: .good) - } - } - } - } else { - VStack(alignment: .leading, spacing: 10) { - Button("Connect account") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) - } - .adaptiveToolbarButtonProminent() - } - } - } - if hasConfiguredLinearProfile { routingSheetSection( - title: "Default project route", - help: "This is the primary Linear project for pull, push, and review." - ) { - projectSettingsControlGroup(footnote: selectedLinearProjectSummary) { - VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel("Primary route") - projectSettingsControlShell(maxWidth: 360) { - if linearProjectOptions.isEmpty { - projectSettingsStaticValue("No projects available") - .accessibilityLabel("Default project route") - } else { - Menu { - Button { - guard !selectedLinearProjectId.isEmpty else { - return - } - selectedLinearProjectId = "" - guard !executionTargetLoading else { - return - } - Task { await persistLinearProjectBindingSelection() } - } label: { - if selectedLinearProjectId.isEmpty { - Label("Unbound", systemImage: "checkmark") - } else { - Text("Unbound") - } - } - ForEach(linearProjectOptions) { linearProject in - Button { - guard selectedLinearProjectId != linearProject.id else { - return - } - selectedLinearProjectId = linearProject.id - guard !executionTargetLoading else { - return - } - Task { await persistLinearProjectBindingSelection() } - } label: { - let label = linearProjectMenuLabel(linearProject) - if selectedLinearProjectId == linearProject.id { - Label(label, systemImage: "checkmark") - } else { - Text(label) - } - } - } - } label: { - projectSettingsMenuLabel(selectedLinearProjectSummary) - } - .buttonStyle(.plain) - .accessibilityLabel("Default project route") - } - } - - if !linearBoundProjectId.isEmpty { - Button("Clear") { - Task { await clearLinearProjectBinding() } - } - .adaptiveToolbarButton() - } - } - } - if let defaultTarget = defaultLinearRouteTarget { - linearScopeRouteRow(target: defaultTarget, isDefault: true) - } - } - - routingSheetSection( - title: "Additional project routes", - help: "Link extra Linear projects when this repo needs review or autosync coverage beyond the default project route." + title: "Additional projects", + help: "Add more Linear projects when this repo needs broader review or autosync coverage than the main project above." ) { if linearAdditionalProjects.isEmpty { - Text("No additional linked projects.") + Text("No additional projects.") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } else { @@ -1107,7 +992,7 @@ struct ProjectDetailView: View { } projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel("Add linked project") + projectSettingsFieldLabel("Additional project") HStack(spacing: 8) { projectSettingsControlShell(maxWidth: 360) { if availableAdditionalLinearProjects.isEmpty { @@ -1154,6 +1039,38 @@ struct ProjectDetailView: View { } } } + + if let defaultTarget = defaultLinearRouteTarget { + routingSheetSection( + title: "Autosync", + help: "Autosync stays optional. Turn it on only for the projects this repo should keep current without a manual pull or push." + ) { + linearScopeRouteRow(target: defaultTarget, isDefault: true) + if !linearAdditionalProjects.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(linearAdditionalProjects) { target in + linearScopeRouteRow(target: target, isDefault: false) + } + } + } + } + } + } else { + VStack(alignment: .leading, spacing: 12) { + Text("Connect Linear in Settings first, then choose the project this repo should sync with.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + + Button("Open Linear settings") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + showLinearRoutingSheet = false + } + .adaptiveToolbarButtonProminent() + } } HStack { @@ -1166,7 +1083,7 @@ struct ProjectDetailView: View { } .padding(24) } - .frame(minWidth: 620, minHeight: hasConfiguredLinearProfile ? 560 : 360) + .frame(minWidth: 520, minHeight: hasConfiguredLinearProfile ? 420 : 220) .background(Color.clear) .task { await refreshLinearRoutingSheetState() @@ -1201,24 +1118,32 @@ struct ProjectDetailView: View { private var hasConfiguredLinearProfile: Bool { let resolvedProfile = resolvedLinearRoutingProfileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !resolvedProfile.isEmpty { - return linearProfilesById[resolvedProfile] != nil + return true } - return !linearProfileOptions.isEmpty + return !linearProfileOptions.isEmpty || !linearProfilesById.isEmpty } private var linearRoutingSummaryLine: String { if !hasConfiguredLinearProfile { - return "No Linear provider account connected for this repo yet." + return "Connect Linear in Settings, then choose the project this repo should sync with." } if !hasAnyLinearProjectRouting { - return "A Linear profile is connected, but this repo does not have a default project route yet." + return "Linear is connected. Choose the project this repo should pull from and push to." } if linearAdditionalProjects.isEmpty { - let defaultLabel = linearBoundProjectName.isEmpty ? resolvedLinearProjectId : linearBoundProjectName - return "\(selectedLinearProfileSummary) • default project \(defaultLabel)" + return "Linear · \(selectedLinearProfileSummary) · \(selectedLinearProjectSummary)" + } + return "Linear · \(selectedLinearProfileSummary) · \(selectedLinearProjectSummary) · \(linearAdditionalProjects.count) additional" + } + + private var selectedLinearProjectFootnote: String { + if linearProjectOptionsLoading { + return "Loading projects…" } - let defaultLabel = linearBoundProjectName.isEmpty ? resolvedLinearProjectId : linearBoundProjectName - return "\(selectedLinearProfileSummary) • default project \(defaultLabel) • \(linearAdditionalProjects.count) linked" + if !linearBoundTeamId.isEmpty { + return "Team \(linearBoundTeamId)" + } + return selectedLinearProjectSummary } private var projectSettingsCardBackground: some View { @@ -2177,12 +2102,11 @@ struct ProjectDetailView: View { let resolvedProfile = resolvedLinearRoutingProfileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !resolvedProfile.isEmpty else { linearProjectOptions = [] + linearProjectOptionsLoading = false return } - guard hasConfiguredLinearProfile else { - linearProjectOptions = [] - return - } + linearProjectOptionsLoading = true + defer { linearProjectOptionsLoading = false } if let projectCatalog = await model.listLinearProjects(profileId: resolvedProfile) { linearProjectOptions = projectCatalog.projects.sorted { lhs, rhs in lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending @@ -2199,7 +2123,104 @@ struct ProjectDetailView: View { linearRoutingSheetLoading = true defer { linearRoutingSheetLoading = false } await reloadExecutionTargetState() - await loadLinearRoutingProjectOptionsIfNeeded() + } + + @ViewBuilder + private func linearAccountPickerControl(accessibilityLabel: String) -> some View { + Menu { + Button { + guard !linearProjectProfile.isEmpty else { + return + } + linearProjectProfile = "" + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } + } label: { + if linearProjectProfile.isEmpty { + Label("Inherited", systemImage: "checkmark") + } else { + Text("Inherited") + } + } + ForEach(linearProfileOptions, id: \.self) { profile in + Button { + guard linearProjectProfile != profile else { + return + } + linearProjectProfile = profile + guard !executionTargetLoading else { + return + } + Task { await persistLinearProfileOverride() } + } label: { + let label = linearProfileLabel(profileId: profile) + if linearProjectProfile == profile { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } + } + } label: { + projectSettingsMenuLabel(selectedLinearProfileSummary) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + } + + @ViewBuilder + private func linearProjectPickerControl(accessibilityLabel: String) -> some View { + if linearProjectOptionsLoading { + projectSettingsStaticValue("Loading projects…") + .accessibilityLabel(accessibilityLabel) + } else if linearProjectOptions.isEmpty { + projectSettingsStaticValue("No projects available") + .accessibilityLabel(accessibilityLabel) + } else { + Menu { + Button { + guard !selectedLinearProjectId.isEmpty else { + return + } + selectedLinearProjectId = "" + guard !executionTargetLoading else { + return + } + Task { await persistLinearProjectBindingSelection() } + } label: { + if selectedLinearProjectId.isEmpty { + Label("Unbound", systemImage: "checkmark") + } else { + Text("Unbound") + } + } + ForEach(linearProjectOptions) { linearProject in + Button { + guard selectedLinearProjectId != linearProject.id else { + return + } + selectedLinearProjectId = linearProject.id + guard !executionTargetLoading else { + return + } + Task { await persistLinearProjectBindingSelection() } + } label: { + let label = linearProjectMenuLabel(linearProject) + if selectedLinearProjectId == linearProject.id { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } + } + } label: { + projectSettingsMenuLabel(selectedLinearProjectSummary) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + } } private func linearAccountEmail(profileId: String) -> String? { @@ -2279,6 +2300,9 @@ struct ProjectDetailView: View { executionTargetLoading = true defer { executionTargetLoading = false } let identityProjectPath = project.repoRoot ?? project.projectDir + let hackAccountAuthenticated = await MainActor.run { + model.hackAccountState?.authenticated == true + } async let nodeList = model.listNodes() async let executionModeRaw = model.getProjectConfig( @@ -2306,6 +2330,12 @@ struct ProjectDetailView: View { ) async let githubProfiles = model.inspectGitHubProfiles() async let linearProfiles = model.inspectLinearProfiles() + async let remoteLinearConnections: LinearConnectionsResponse? = { + guard hackAccountAuthenticated else { + return nil + } + return await model.listLinearConnections() + }() async let systemGitIdentity = model.inspectSystemGitIdentity( projectPath: identityProjectPath ) @@ -2319,7 +2349,8 @@ struct ProjectDetailView: View { let resolvedDefaultGitHubProfile = await defaultGitHubProfile let resolvedDefaultLinearProfile = await defaultLinearProfile let resolvedGitHubProfiles = await githubProfiles - let resolvedLinearProfiles = await linearProfiles + var resolvedLinearProfiles = await linearProfiles + let resolvedRemoteLinearConnections = await remoteLinearConnections let resolvedSystemGitIdentity = await systemGitIdentity if Task.isCancelled { @@ -2365,6 +2396,21 @@ struct ProjectDetailView: View { linearAdditionalProjects = resolvedLinearBinding?.additionalProjects ?? [] let fallbackDefaultLinearProfile = (resolvedDefaultLinearProfile ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) + let initialLinearDefaultProfile = resolvedLinearProfiles?.defaultProfile ?? fallbackDefaultLinearProfile + let explicitLinearProfile = (resolvedLinearBinding?.profileId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let initialResolvedLinearProfile = + explicitLinearProfile.isEmpty ? initialLinearDefaultProfile : explicitLinearProfile + if !initialResolvedLinearProfile.isEmpty, + !linearProfilesResponse(resolvedLinearProfiles, containsProfileId: initialResolvedLinearProfile), + remoteLinearConnection( + profileId: initialResolvedLinearProfile, + in: resolvedRemoteLinearConnections + )?.localAccessAvailable == true, + await model.seedLinearLocalAccess(profileId: initialResolvedLinearProfile) != nil + { + resolvedLinearProfiles = await model.inspectLinearProfiles() + } linearDefaultProfile = resolvedLinearProfiles?.defaultProfile ?? fallbackDefaultLinearProfile linearProfilesById = mapLinearProfilesById(response: resolvedLinearProfiles) linearProfileOptions = (resolvedLinearProfiles?.profiles ?? []) @@ -2382,6 +2428,7 @@ struct ProjectDetailView: View { if linearAdditionalProjects.contains(where: { $0.projectId == selectedAdditionalLinearProjectId }) { selectedAdditionalLinearProjectId = "" } + await loadLinearRoutingProjectOptionsIfNeeded() linearAutosyncRouteKeys = await loadLinearAutosyncRouteKeys(targets: allLinearRouteTargets) executionMode = RemoteExecutionMode.fromConfig(resolvedExecutionModeRaw) ?? (executionTargetNodeId.isEmpty ? .local : .localEditRemoteRun) @@ -2397,6 +2444,32 @@ struct ProjectDetailView: View { } } + private func linearProfilesResponse( + _ response: LinearProfilesResponse?, + containsProfileId profileId: String + ) -> Bool { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return false + } + return response?.profiles.contains(where: { + $0.id.caseInsensitiveCompare(trimmed) == .orderedSame + }) == true + } + + private func remoteLinearConnection( + profileId: String, + in response: LinearConnectionsResponse? + ) -> LinearRemoteConnection? { + let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return response?.connections.first(where: { connection in + connection.profileId?.caseInsensitiveCompare(trimmed) == .orderedSame + }) + } + /** Persist project execution mode, including local-mode safety resets. */ From 83fe2d9f6e1ac2992c05e29e2f888304c3afacc8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 16:10:25 -0400 Subject: [PATCH 39/54] Inline Linear ticket sync project settings --- .../DashboardFeature/ProjectDetailView.swift | 244 +++++++----------- 1 file changed, 91 insertions(+), 153 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 02ac87ce0..f14c294a7 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -139,8 +139,6 @@ struct ProjectDetailView: View { @State private var linearProjectMessage = "" @State private var projectSystemGitIdentity: GitSystemIdentity? = nil @State private var executionTargetReloadTask: Task? = nil - @State private var showLinearRoutingSheet = false - @State private var linearRoutingSheetLoading = false @State private var linearProjectOptionsLoading = false var body: some View { @@ -181,14 +179,6 @@ struct ProjectDetailView: View { .onChange(of: model.lastUpdated) { _, _ in queueExecutionTargetReload() } - .onChange(of: showLinearRoutingSheet) { _, isPresented in - guard isPresented else { - return - } - Task { - await refreshLinearRoutingSheetState() - } - } .onReceive(NotificationCenter.default.publisher(for: .hackProjectNavigationRequested)) { notification in guard let userInfo = notification.userInfo, @@ -215,7 +205,10 @@ struct ProjectDetailView: View { else { return } - showLinearRoutingSheet = true + model.selectedProjectTab = .overview + selectedSidebarItem = .remoteExecution + ensureSidebarSelection() + queueExecutionTargetReload() } .onDisappear { executionTargetReloadTask?.cancel() @@ -224,9 +217,6 @@ struct ProjectDetailView: View { .sheet(isPresented: $showAddBranchSheet) { addBranchSheet } - .sheet(isPresented: $showLinearRoutingSheet) { - linearRoutingSheet - } } private var projectPageHeader: some View { @@ -884,10 +874,16 @@ struct ProjectDetailView: View { .foregroundStyle(.secondary) } Spacer() - Button(hasConfiguredLinearProfile ? "Advanced" : "Open Linear settings") { - showLinearRoutingSheet = true + if !hasConfiguredLinearProfile { + Button("Open Linear settings") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: ["pane": "linear"] + ) + } + .adaptiveToolbarButtonProminent() } - .adaptiveToolbarButtonProminent() } if hasConfiguredLinearProfile { @@ -934,159 +930,110 @@ struct ProjectDetailView: View { } Spacer(minLength: 0) } - } - } else { - HStack(spacing: 8) { - StatusPill(text: "Needs connection", tone: .warn) - Spacer(minLength: 0) - } - } - - if !linearProjectMessage.isEmpty { - Text(linearProjectMessage) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(remoteConfigTone == .warn ? Color.orange : .secondary) - } - } - .padding(16) - .background(projectSettingsCardBackground) - .task(id: resolvedLinearRoutingProfileId ?? "") { - guard !showLinearRoutingSheet else { - return - } - await loadLinearRoutingProjectOptionsIfNeeded() - } - } - private var linearRoutingSheet: some View { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - Text("Linear settings") - .font(.system(size: 22, weight: .semibold)) - - if linearRoutingSheetLoading { - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) - Text("Loading Linear settings…") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } - } + projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { + VStack(alignment: .leading, spacing: 10) { + projectSettingsFieldLabel( + "Additional projects", + help: "Optionally include more Linear projects for this repo beyond the main project above." + ) - if hasConfiguredLinearProfile { - routingSheetSection( - title: "Additional projects", - help: "Add more Linear projects when this repo needs broader review or autosync coverage than the main project above." - ) { - if linearAdditionalProjects.isEmpty { - Text("No additional projects.") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(linearAdditionalProjects) { target in - linearScopeRouteRow(target: target, isDefault: false) + if linearAdditionalProjects.isEmpty { + projectSettingsStaticValue("Default project only") + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(linearAdditionalProjects) { target in + linearScopeRouteRow(target: target, isDefault: false) + } } } - } - projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { - VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel("Additional project") - HStack(spacing: 8) { - projectSettingsControlShell(maxWidth: 360) { - if availableAdditionalLinearProjects.isEmpty { - projectSettingsStaticValue("No more projects available") - .accessibilityLabel("Add linked project") - } else { - Menu { - ForEach(availableAdditionalLinearProjects) { linearProject in - Button { - selectedAdditionalLinearProjectId = linearProject.id - } label: { - let label = linearProjectMenuLabel(linearProject) - if selectedAdditionalLinearProjectId == linearProject.id { - Label(label, systemImage: "checkmark") - } else { - Text(label) - } + + HStack(spacing: 8) { + projectSettingsControlShell(maxWidth: 360) { + if availableAdditionalLinearProjects.isEmpty { + projectSettingsStaticValue("No more projects available") + .accessibilityLabel("Additional Linear project") + } else { + Menu { + ForEach(availableAdditionalLinearProjects) { linearProject in + Button { + selectedAdditionalLinearProjectId = linearProject.id + } label: { + let label = linearProjectMenuLabel(linearProject) + if selectedAdditionalLinearProjectId == linearProject.id { + Label(label, systemImage: "checkmark") + } else { + Text(label) } } - } label: { - projectSettingsMenuLabel( - additionalProjectMenuLabel( - selectedAdditionalLinearProjectId - ) - ) } - .buttonStyle(.plain) - .accessibilityLabel("Add linked project") + } label: { + projectSettingsMenuLabel( + additionalProjectMenuLabel(selectedAdditionalLinearProjectId) + ) } + .buttonStyle(.plain) + .accessibilityLabel("Additional Linear project") } + } - Button("Add") { - Task { await persistAdditionalLinearProjectSelection() } - } - .adaptiveToolbarButton() - .disabled( - selectedAdditionalLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - ) - - Spacer() + Button("Add") { + Task { await persistAdditionalLinearProjectSelection() } } + .adaptiveToolbarButton() + .disabled( + selectedAdditionalLinearProjectId + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + ) + + Spacer(minLength: 0) } } } if let defaultTarget = defaultLinearRouteTarget { - routingSheetSection( - title: "Autosync", - help: "Autosync stays optional. Turn it on only for the projects this repo should keep current without a manual pull or push." + projectSettingsControlGroup( + footnote: hasAnyLinearAutosyncRoutes + ? "Autosync enabled for \(linearAutosyncRouteKeys.count) project\(linearAutosyncRouteKeys.count == 1 ? "" : "s")." + : "Manual sync only" ) { - linearScopeRouteRow(target: defaultTarget, isDefault: true) - if !linearAdditionalProjects.isEmpty { - VStack(alignment: .leading, spacing: 8) { - ForEach(linearAdditionalProjects) { target in - linearScopeRouteRow(target: target, isDefault: false) + VStack(alignment: .leading, spacing: 10) { + projectSettingsFieldLabel( + "Autosync", + help: "Keep autosync scoped to the projects this repo should update without a manual pull or push." + ) + + linearScopeRouteRow(target: defaultTarget, isDefault: true) + + if !linearAdditionalProjects.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(linearAdditionalProjects) { target in + linearScopeRouteRow(target: target, isDefault: false) + } } } } } } - } else { - VStack(alignment: .leading, spacing: 12) { - Text("Connect Linear in Settings first, then choose the project this repo should sync with.") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - - Button("Open Linear settings") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) - showLinearRoutingSheet = false - } - .adaptiveToolbarButtonProminent() - } } - - HStack { - Spacer() - Button("Done") { - showLinearRoutingSheet = false - } - .adaptiveToolbarButtonProminent() + } else { + HStack(spacing: 8) { + StatusPill(text: "Needs connection", tone: .warn) + Spacer(minLength: 0) } } - .padding(24) + + if !linearProjectMessage.isEmpty { + Text(linearProjectMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(remoteConfigTone == .warn ? Color.orange : .secondary) + } } - .frame(minWidth: 520, minHeight: hasConfiguredLinearProfile ? 420 : 220) - .background(Color.clear) - .task { - await refreshLinearRoutingSheetState() + .padding(16) + .background(projectSettingsCardBackground) + .task(id: resolvedLinearRoutingProfileId ?? "") { + await loadLinearRoutingProjectOptionsIfNeeded() } } @@ -2116,15 +2063,6 @@ struct ProjectDetailView: View { } } - private func refreshLinearRoutingSheetState() async { - guard showLinearRoutingSheet else { - return - } - linearRoutingSheetLoading = true - defer { linearRoutingSheetLoading = false } - await reloadExecutionTargetState() - } - @ViewBuilder private func linearAccountPickerControl(accessibilityLabel: String) -> some View { Menu { From 57bdd478565f85bc2d3a3d5187bd5953193bdd75 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 16:31:49 -0400 Subject: [PATCH 40/54] Stabilize inline Linear project binding --- .../DashboardFeature/ProjectDetailView.swift | 79 ++++++------------- 1 file changed, 25 insertions(+), 54 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index f14c294a7..04af5dbc6 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -177,6 +177,12 @@ struct ProjectDetailView: View { ensureSidebarSelection() } .onChange(of: model.lastUpdated) { _, _ in + guard !executionTargetSaving else { + return + } + guard selectedSidebarItem != .remoteExecution else { + return + } queueExecutionTargetReload() } .onReceive(NotificationCenter.default.publisher(for: .hackProjectNavigationRequested)) { notification in @@ -931,64 +937,20 @@ struct ProjectDetailView: View { Spacer(minLength: 0) } - projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { - VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel( - "Additional projects", - help: "Optionally include more Linear projects for this repo beyond the main project above." - ) + if !linearAdditionalProjects.isEmpty { + projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { + VStack(alignment: .leading, spacing: 10) { + projectSettingsFieldLabel( + "Also sync", + help: "These extra Linear projects are already linked to this repo." + ) - if linearAdditionalProjects.isEmpty { - projectSettingsStaticValue("Default project only") - } else { VStack(alignment: .leading, spacing: 8) { ForEach(linearAdditionalProjects) { target in linearScopeRouteRow(target: target, isDefault: false) } } } - - HStack(spacing: 8) { - projectSettingsControlShell(maxWidth: 360) { - if availableAdditionalLinearProjects.isEmpty { - projectSettingsStaticValue("No more projects available") - .accessibilityLabel("Additional Linear project") - } else { - Menu { - ForEach(availableAdditionalLinearProjects) { linearProject in - Button { - selectedAdditionalLinearProjectId = linearProject.id - } label: { - let label = linearProjectMenuLabel(linearProject) - if selectedAdditionalLinearProjectId == linearProject.id { - Label(label, systemImage: "checkmark") - } else { - Text(label) - } - } - } - } label: { - projectSettingsMenuLabel( - additionalProjectMenuLabel(selectedAdditionalLinearProjectId) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("Additional Linear project") - } - } - - Button("Add") { - Task { await persistAdditionalLinearProjectSelection() } - } - .adaptiveToolbarButton() - .disabled( - selectedAdditionalLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - ) - - Spacer(minLength: 0) - } } } @@ -1087,8 +1049,8 @@ struct ProjectDetailView: View { if linearProjectOptionsLoading { return "Loading projects…" } - if !linearBoundTeamId.isEmpty { - return "Team \(linearBoundTeamId)" + if linearBoundProjectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Choose the main Linear project for this repo." } return selectedLinearProjectSummary } @@ -2361,7 +2323,6 @@ struct ProjectDetailView: View { ? linearDefaultProfile : linearProjectProfile linearResolvedProfile = resolvedLinearProfile - linearProjectOptions = [] selectedLinearProjectId = linearBoundProjectId if linearAdditionalProjects.contains(where: { $0.projectId == selectedAdditionalLinearProjectId }) { selectedAdditionalLinearProjectId = "" @@ -2579,6 +2540,9 @@ struct ProjectDetailView: View { } private func persistLinearProjectBindingSelection() async { + executionTargetSaving = true + defer { executionTargetSaving = false } + let selectedProjectId = selectedLinearProjectId .trimmingCharacters(in: .whitespacesAndNewlines) if selectedProjectId.isEmpty { @@ -2605,6 +2569,10 @@ struct ProjectDetailView: View { return } + linearBoundProjectId = linearProject.id + linearBoundProjectName = linearProject.name + linearBoundTeamId = linearProject.teamId + selectedLinearProjectId = linearProject.id linearProjectMessage = "Linear project bound to \(linearProjectMenuLabel(linearProject))." executionTargetMessage = "" githubProfileMessage = "" @@ -2769,6 +2737,9 @@ struct ProjectDetailView: View { return } + linearBoundProjectId = "" + linearBoundProjectName = "" + linearBoundTeamId = "" selectedLinearProjectId = "" linearProjectMessage = "Cleared project-level Linear binding." executionTargetMessage = "" From 638c6bb5dc2118e49ef0654752e8079d1a5c0019 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 19:05:03 -0400 Subject: [PATCH 41/54] Align Linear project label with picker state --- .../Sources/DashboardFeature/ProjectDetailView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 04af5dbc6..0f676697b 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -1049,7 +1049,7 @@ struct ProjectDetailView: View { if linearProjectOptionsLoading { return "Loading projects…" } - if linearBoundProjectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if resolvedLinearProjectId.isEmpty { return "Choose the main Linear project for this repo." } return selectedLinearProjectSummary @@ -1780,7 +1780,7 @@ struct ProjectDetailView: View { } private var selectedLinearProjectSummary: String { - let selectedProjectId = linearBoundProjectId.trimmingCharacters(in: .whitespacesAndNewlines) + let selectedProjectId = resolvedLinearProjectId guard !selectedProjectId.isEmpty else { return "Unbound" } From 55f29ed05dacb60a63f8c5f3c2ab680359e2b5c0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 19:17:33 -0400 Subject: [PATCH 42/54] Fix Linear project bind config writes --- .../extensions/linear/commands.ts | 136 +++++++++--------- src/lib/config.ts | 59 +++++++- tests/config.test.ts | 51 ++++++- 3 files changed, 175 insertions(+), 71 deletions(-) diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index 56dd7be7c..ebaf9b342 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -6,6 +6,7 @@ import { secrets } from "bun"; import { updateGlobalConfig, updateProjectConfig, + updateProjectConfigBatch, } from "../../../lib/config.ts"; import { isRecord } from "../../../lib/guards.ts"; import { openUrl } from "../../../lib/os.ts"; @@ -806,33 +807,31 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ } if (parsed.value.clear) { - await Promise.all([ - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.profile", - value: "", - }), - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.projectId", - value: "", - }), - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.projectName", - value: "", - }), - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.teamId", - value: "", - }), - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.additionalProjects", - value: [], - }), - ]); + await updateProjectConfigBatch({ + projectDir: ctx.project.projectDir, + values: [ + { + path: "controlPlane.routing.overrides.linear.profile", + value: "", + }, + { + path: "controlPlane.routing.overrides.linear.projectId", + value: "", + }, + { + path: "controlPlane.routing.overrides.linear.projectName", + value: "", + }, + { + path: "controlPlane.routing.overrides.linear.teamId", + value: "", + }, + { + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: [], + }, + ], + }); const payload = buildProjectBindingPayload({ binding: { additionalProjects: [] }, @@ -883,31 +882,30 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ return 1; } - await Promise.all([ - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.projectId", - value: projectId, - }), - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.projectName", - value: resolvedTeamAndName.projectName, - }), - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.teamId", - value: resolvedTeamAndName.teamId, - }), - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.additionalProjects", - value: removeAdditionalProjectBinding({ - existing: existingBinding.additionalProjects, - projectId, - }), - }), - ]); + await updateProjectConfigBatch({ + projectDir: ctx.project.projectDir, + values: [ + { + path: "controlPlane.routing.overrides.linear.projectId", + value: projectId, + }, + { + path: "controlPlane.routing.overrides.linear.projectName", + value: resolvedTeamAndName.projectName, + }, + { + path: "controlPlane.routing.overrides.linear.teamId", + value: resolvedTeamAndName.teamId, + }, + { + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: removeAdditionalProjectBinding({ + existing: existingBinding.additionalProjects, + projectId, + }), + }, + ], + }); const payload = buildProjectBindingPayload({ binding: { @@ -1000,23 +998,23 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ }, defaultProjectId: binding.projectId, }); - const writes: Promise<{ readonly changed: boolean }>[] = [ - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.additionalProjects", - value: additionalProjects, - }), - ]; - if (!binding.profileId && targetProfile) { - writes.push( - updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.profile", - value: targetProfile, - }) - ); - } - await Promise.all(writes); + await updateProjectConfigBatch({ + projectDir: ctx.project.projectDir, + values: [ + { + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: additionalProjects, + }, + ...(!binding.profileId && targetProfile + ? [ + { + path: "controlPlane.routing.overrides.linear.profile", + value: targetProfile, + }, + ] + : []), + ], + }); const payload = buildProjectBindingPayload({ binding: { diff --git a/src/lib/config.ts b/src/lib/config.ts index 189075b48..7cfbcf361 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -51,6 +51,30 @@ export async function updateProjectConfig({ }); } +/** + * Updates multiple values in the project config file atomically using a single read/write cycle. + * + * @param opts.projectDir - Project `.hack` directory path + * @param opts.values - Dot-separated config paths and values to set + * @returns Whether the config file changed + */ +export async function updateProjectConfigBatch({ + projectDir, + values, +}: { + readonly projectDir: string; + readonly values: ReadonlyArray<{ + readonly path: string; + readonly value: unknown; + }>; +}): Promise<{ readonly changed: boolean }> { + const configPath = resolve(projectDir, PROJECT_CONFIG_FILENAME); + return await updateConfigFileValuesAtPath({ + configPath, + values, + }); +} + async function updateConfigFileAtPath({ configPath, path, @@ -66,11 +90,44 @@ async function updateConfigFileAtPath({ throw new Error(`Invalid config path: ${path}`); } + return await updateConfigFileValuesAtPath({ + configPath, + values: [{ path, value }], + }); +} + +async function updateConfigFileValuesAtPath({ + configPath, + values, +}: { + readonly configPath: string; + readonly values: ReadonlyArray<{ + readonly path: string; + readonly value: unknown; + }>; +}): Promise<{ readonly changed: boolean }> { + if (values.length === 0) { + return { changed: false }; + } + + for (const entry of values) { + const parsedPath = parseKeyPath({ raw: entry.path }); + if (parsedPath.length === 0) { + throw new Error(`Invalid config path: ${entry.path}`); + } + } + const jsonText = await readTextFile(configPath); const config: Record = jsonText !== null ? parseJsonSafe(jsonText) : {}; - setPathValue({ target: config, path: parsedPath, value }); + for (const entry of values) { + setPathValue({ + target: config, + path: parseKeyPath({ raw: entry.path }), + value: entry.value, + }); + } const nextText = `${JSON.stringify(config, null, 2)}\n`; await ensureDir(dirname(configPath)); diff --git a/tests/config.test.ts b/tests/config.test.ts index 96adee643..7811e372e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -3,7 +3,11 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { readGlobalConfig, updateGlobalConfig } from "../src/lib/config.ts"; +import { + readGlobalConfig, + updateGlobalConfig, + updateProjectConfigBatch, +} from "../src/lib/config.ts"; describe("global config utilities", () => { let tempDir: string; @@ -133,3 +137,48 @@ describe("global config utilities", () => { expect(result.changed).toBe(false); }); }); + +describe("project config batch utilities", () => { + let tempDir: string; + let projectDir: string; + let configPath: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-project-test-")); + projectDir = join(tempDir, ".hack"); + configPath = join(projectDir, "hack.config.json"); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("updateProjectConfigBatch persists multi-key linear binding in one write", async () => { + await updateProjectConfigBatch({ + projectDir, + values: [ + { + path: "controlPlane.routing.overrides.linear.projectId", + value: "proj_live_nation", + }, + { + path: "controlPlane.routing.overrides.linear.projectName", + value: "Live Nation", + }, + { + path: "controlPlane.routing.overrides.linear.teamId", + value: "team_hack", + }, + ], + }); + + const content = await Bun.file(configPath).text(); + const parsed = JSON.parse(content); + + expect(parsed.controlPlane.routing.overrides.linear).toEqual({ + projectId: "proj_live_nation", + projectName: "Live Nation", + teamId: "team_hack", + }); + }); +}); From 93f25669b747a54f753050cb78bca8f217438d8c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 19:39:05 -0400 Subject: [PATCH 43/54] Fix Linear pull query and ticket ref retry --- src/control-plane/extensions/linear/client.ts | 8 +- .../extensions/tickets/tickets-git-channel.ts | 37 +++++++- tests/linear-client.test.ts | 93 +++++++++++++++++++ 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/src/control-plane/extensions/linear/client.ts b/src/control-plane/extensions/linear/client.ts index bf8146c9f..6581c8e75 100644 --- a/src/control-plane/extensions/linear/client.ts +++ b/src/control-plane/extensions/linear/client.ts @@ -539,7 +539,13 @@ export function createLinearClient(input: { " project(id: $projectId) {", " id", " name", - " team { id key name }", + " teams {", + " nodes {", + " id", + " key", + " name", + " }", + " }", " issues(first: $first, after: $after) {", " pageInfo {", " hasNextPage", diff --git a/src/control-plane/extensions/tickets/tickets-git-channel.ts b/src/control-plane/extensions/tickets/tickets-git-channel.ts index cb5539a47..65f3d9f3f 100644 --- a/src/control-plane/extensions/tickets/tickets-git-channel.ts +++ b/src/control-plane/extensions/tickets/tickets-git-channel.ts @@ -220,9 +220,30 @@ export function createGitTicketsChannel(opts: { | { readonly ok: true } | { readonly ok: false; readonly error: string; readonly missing: boolean } > => { - const fetched = await runGitDir({ - args: ["fetch", "--prune", "origin", `+${ref}:${trackingRef}`], + const fetchArgs = [ + "fetch", + "--prune", + "origin", + `+${ref}:${trackingRef}`, + ] as const; + let fetched = await runGitDir({ + args: fetchArgs, }); + if (!fetched.ok) { + const retryableLockFailure = isRetryableTrackingRefLockFailure({ + stderr: fetched.stderr, + trackingRef, + }); + if (retryableLockFailure) { + const deletedTrackingRef = await runGitDir({ + args: ["update-ref", "-d", trackingRef], + }); + void deletedTrackingRef; + fetched = await runGitDir({ + args: fetchArgs, + }); + } + } if (fetched.ok) { return { ok: true }; } @@ -864,6 +885,18 @@ function safeJsonParse(text: string): unknown { } } +function isRetryableTrackingRefLockFailure(input: { + readonly stderr: string; + readonly trackingRef: string; +}): boolean { + const message = input.stderr.toLowerCase(); + return ( + message.includes("cannot lock ref") && + message.includes(input.trackingRef.toLowerCase()) && + message.includes("expected") + ); +} + function isMissingRemoteRef(message: string): boolean { const normalized = message.toLowerCase(); return ( diff --git a/tests/linear-client.test.ts b/tests/linear-client.test.ts index c528595ac..e776c3b51 100644 --- a/tests/linear-client.test.ts +++ b/tests/linear-client.test.ts @@ -187,6 +187,99 @@ test("listProjects queries the top-level projects connection", async () => { ]); }); +test("listProjectIssuesPage queries project teams instead of deprecated project.team", async () => { + let requestBody: { + readonly query?: unknown; + readonly variables?: unknown; + } | null = null; + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as { + readonly query?: unknown; + readonly variables?: unknown; + }; + return new Response( + JSON.stringify({ + data: { + project: { + id: "project_live_nation", + name: "Live Nation", + teams: { + nodes: [ + { + id: "team_hack", + key: "HACK", + name: "Hack", + }, + ], + }, + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "issue_123", + identifier: "HACK-123", + title: "Sync Linear issues", + description: "Bring issues across.", + url: "https://linear.app/issue/HACK-123", + state: { + id: "state_1", + name: "Todo", + type: "unstarted", + }, + team: { + id: "team_hack", + key: "HACK", + name: "Hack", + }, + project: { + id: "project_live_nation", + name: "Live Nation", + }, + assignee: null, + labels: { nodes: [] }, + parent: null, + }, + ], + }, + }, + }, + }), + { status: 200 } + ); + }) as typeof fetch; + + const client = createLinearClient({ token: "linear-token" }); + const result = await client.listProjectIssuesPage({ + projectId: "project_live_nation", + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + const query = String( + (requestBody as { readonly query?: unknown } | null)?.query ?? "" + ); + expect(query).toContain("project(id: $projectId)"); + expect(query).toContain("teams {"); + expect(query).not.toContain( + "project(id: $projectId) {\n id\n name\n team {" + ); + expect(result.data.project).toEqual({ + id: "project_live_nation", + name: "Live Nation", + teamId: "team_hack", + teamKey: "HACK", + teamName: "Hack", + }); + expect(result.data.issues).toHaveLength(1); + expect(result.data.issues[0]?.projectName).toBe("Live Nation"); +}); + test("listTeamUsers returns membership users for assignee mapping", async () => { let requestBody: { readonly query?: unknown; From a3705471670de30b07c6f2967c68f0ad063bcde7 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 20:11:42 -0400 Subject: [PATCH 44/54] Fix tickets ref fallback during Linear sync --- .../extensions/tickets/tickets-git-channel.ts | 325 +++++++++++++++++- tests/tickets-git-channel.test.ts | 75 ++++ 2 files changed, 383 insertions(+), 17 deletions(-) create mode 100644 tests/tickets-git-channel.test.ts diff --git a/src/control-plane/extensions/tickets/tickets-git-channel.ts b/src/control-plane/extensions/tickets/tickets-git-channel.ts index 65f3d9f3f..c88e557e1 100644 --- a/src/control-plane/extensions/tickets/tickets-git-channel.ts +++ b/src/control-plane/extensions/tickets/tickets-git-channel.ts @@ -77,9 +77,22 @@ export function createGitTicketsChannel(opts: { refMode === "hidden" ? buildRemoteRef({ branch, refMode: "heads" }) : null; const localBranchRef = `refs/heads/${branch}`; const trackingRef = `refs/remotes/origin/${branch}`; + const legacyTrackingRef = legacyRemoteRef + ? `refs/remotes/origin/__legacy__/${branch}` + : null; const remoteName = gitEnabled ? (opts.config.remote ?? "origin").trim() : ""; const bareIndexLockPath = resolve(bareDir, "index.lock"); + const resolvePushRefForCheckout = (input: { + readonly checkoutRef: string; + }): string => + resolvePushRefForCheckoutRef({ + checkoutRef: input.checkoutRef, + remoteRef, + legacyTrackingRef, + legacyRemoteRef, + }); + const runGitDir = async (input: { readonly args: readonly string[]; }): Promise<{ @@ -214,8 +227,70 @@ export function createGitTicketsChannel(opts: { return { remoteUrl }; }; - const fetchRemoteRef = async ( - ref: string + const hasPendingWorktreeChanges = async (): Promise => { + const currentBranch = await runGitDir({ + args: ["branch", "--show-current"], + }); + if (!currentBranch.ok) { + return false; + } + if (currentBranch.stdout.trim() !== branch) { + return false; + } + + const status = await runGitDir({ + args: ["status", "--short"], + }); + return status.ok && status.stdout.trim().length > 0; + }; + + const resolvePreferredTrackingRef = async (): Promise => { + if (legacyTrackingRef) { + const legacy = await runGitDir({ + args: ["rev-parse", "--verify", legacyTrackingRef], + }); + if (legacy.ok) { + return legacyTrackingRef; + } + } + + const tracking = await runGitDir({ + args: ["rev-parse", "--verify", trackingRef], + }); + if (tracking.ok) { + return trackingRef; + } + + return null; + }; + + const hasAheadLocalBranchCommits = async (input: { + readonly trackingRef: string | null; + }): Promise => { + if (!input.trackingRef) { + return false; + } + + const currentBranch = await runGitDir({ + args: ["branch", "--show-current"], + }); + if (!currentBranch.ok || currentBranch.stdout.trim() !== branch) { + return false; + } + + const ahead = await runGitDir({ + args: ["rev-list", "--count", `${input.trackingRef}..${branch}`], + }); + if (!ahead.ok) { + return false; + } + + return Number.parseInt(ahead.stdout.trim(), 10) > 0; + }; + + const fetchRemoteRefToTracking = async ( + ref: string, + destinationRef: string ): Promise< | { readonly ok: true } | { readonly ok: false; readonly error: string; readonly missing: boolean } @@ -224,7 +299,7 @@ export function createGitTicketsChannel(opts: { "fetch", "--prune", "origin", - `+${ref}:${trackingRef}`, + `+${ref}:${destinationRef}`, ] as const; let fetched = await runGitDir({ args: fetchArgs, @@ -232,11 +307,11 @@ export function createGitTicketsChannel(opts: { if (!fetched.ok) { const retryableLockFailure = isRetryableTrackingRefLockFailure({ stderr: fetched.stderr, - trackingRef, + trackingRef: destinationRef, }); if (retryableLockFailure) { const deletedTrackingRef = await runGitDir({ - args: ["update-ref", "-d", trackingRef], + args: ["update-ref", "-d", destinationRef], }); void deletedTrackingRef; fetched = await runGitDir({ @@ -254,24 +329,54 @@ export function createGitTicketsChannel(opts: { return { ok: false, error: message, missing: false }; }; + const fetchRemoteRef = async ( + ref: string + ): Promise< + | { readonly ok: true } + | { readonly ok: false; readonly error: string; readonly missing: boolean } + > => { + return await fetchRemoteRefToTracking(ref, trackingRef); + }; + const checkoutHead = async (input: { readonly remoteUrl: string | null; }): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } + | { readonly ok: true; readonly pushRef: string } + | { readonly ok: false; readonly error: string } > => { await rm(worktreeDir, { recursive: true, force: true }); await mkdir(worktreeDir, { recursive: true }); if (input.remoteUrl) { let canCheckoutRemote = false; + let checkoutRef = `origin/${branch}`; const fetched = await fetchRemoteRef(remoteRef); if (fetched.ok) { canCheckoutRemote = true; + if (legacyRemoteRef && legacyTrackingRef) { + const legacyFetch = await fetchRemoteRefToTracking( + legacyRemoteRef, + legacyTrackingRef + ); + if (legacyFetch.ok) { + checkoutRef = legacyTrackingRef; + } else if (!legacyFetch.missing) { + return { + ok: false, + error: `git fetch failed: ${legacyFetch.error}`, + }; + } + } } else if (fetched.missing && legacyRemoteRef) { - const legacyFetch = await fetchRemoteRef(legacyRemoteRef); + const legacyFetch = legacyTrackingRef + ? await fetchRemoteRefToTracking(legacyRemoteRef, legacyTrackingRef) + : await fetchRemoteRef(legacyRemoteRef); if (legacyFetch.ok) { canCheckoutRemote = true; + if (legacyTrackingRef) { + checkoutRef = legacyTrackingRef; + } } else if (!legacyFetch.missing) { return { ok: false, error: `git fetch failed: ${legacyFetch.error}` }; } @@ -281,7 +386,7 @@ export function createGitTicketsChannel(opts: { if (canCheckoutRemote) { const rev = await runGitDir({ - args: ["rev-parse", "--verify", `origin/${branch}`], + args: ["rev-parse", "--verify", checkoutRef], }); if (rev.ok) { const checkout = await runGitDir({ @@ -302,7 +407,10 @@ export function createGitTicketsChannel(opts: { }; } - return { ok: true }; + return { + ok: true, + pushRef: resolvePushRefForCheckout({ checkoutRef }), + }; } } } @@ -341,7 +449,7 @@ export function createGitTicketsChannel(opts: { }; } - return { ok: true }; + return { ok: true, pushRef: remoteRef }; } const checkout = await runGitDir({ args: ["checkout", branch] }); @@ -357,11 +465,98 @@ export function createGitTicketsChannel(opts: { return { ok: false, error: `git reset failed: ${reset.stderr.trim()}` }; } - return { ok: true }; + return { ok: true, pushRef: remoteRef }; + }; + + const mergeLegacyRefIntoCurrentBranch = async (input: { + readonly remoteUrl: string | null; + }): Promise< + | { readonly ok: true; readonly imported: boolean } + | { readonly ok: false; readonly error: string } + > => { + if (!(input.remoteUrl && legacyRemoteRef && legacyTrackingRef)) { + return { ok: true, imported: false }; + } + + const fetched = await fetchRemoteRefToTracking( + legacyRemoteRef, + legacyTrackingRef + ); + if (!fetched.ok) { + if (fetched.missing) { + return { ok: true, imported: false }; + } + return { ok: false, error: `git fetch failed: ${fetched.error}` }; + } + + const listed = await runGitDir({ + args: [ + "ls-tree", + "-r", + "--name-only", + legacyTrackingRef, + ".hack/tickets/events", + ], + }); + if (!listed.ok) { + return { + ok: false, + error: `git ls-tree failed: ${listed.stderr.trim() || listed.stdout.trim()}`, + }; + } + + let imported = false; + const legacyPaths = listed.stdout + .split("\n") + .map((path) => path.trim()) + .filter((path) => path.startsWith(".hack/tickets/events/")); + + for (const relativePath of legacyPaths) { + const shown = await runGitDir({ + args: ["show", `${legacyTrackingRef}:${relativePath}`], + }); + if (!shown.ok) { + return { + ok: false, + error: `git show failed: ${shown.stderr.trim() || shown.stdout.trim()}`, + }; + } + + const targetPath = resolve(worktreeDir, relativePath); + const existing = await Bun.file(targetPath) + .text() + .catch(() => ""); + const merged = mergeTicketEventLogs({ + existing, + incoming: shown.stdout, + }); + if (merged === existing) { + continue; + } + + await mkdir(dirname(targetPath), { recursive: true }); + await Bun.write(targetPath, merged); + imported = true; + } + + if (!imported) { + return { ok: true, imported: false }; + } + + const normalized = await normalizeLogs(); + if (!normalized.ok) { + return normalized; + } + + return { ok: true, imported: true }; }; const ensureCheckedOut = async (): Promise< - | { readonly ok: true; readonly remoteUrl: string | null } + | { + readonly ok: true; + readonly remoteUrl: string | null; + readonly pushRef: string; + } | { readonly ok: false; readonly error: string } > => { await ensureDirs(); @@ -369,12 +564,34 @@ export function createGitTicketsChannel(opts: { await ensureSparseCheckout(); const { remoteUrl } = await ensureRemote(); + if (await hasPendingWorktreeChanges()) { + const pushRef = + refMode === "hidden" && legacyRemoteRef ? legacyRemoteRef : remoteRef; + return { ok: true, remoteUrl, pushRef }; + } + + const preferredTrackingRef = await resolvePreferredTrackingRef(); + if ( + await hasAheadLocalBranchCommits({ trackingRef: preferredTrackingRef }) + ) { + const pushRef = + preferredTrackingRef === legacyTrackingRef && legacyRemoteRef + ? legacyRemoteRef + : remoteRef; + return { ok: true, remoteUrl, pushRef }; + } + const checkedOut = await checkoutHead({ remoteUrl }); if (!checkedOut.ok) { return checkedOut; } - return { ok: true, remoteUrl }; + const migratedLegacy = await mergeLegacyRefIntoCurrentBranch({ remoteUrl }); + if (!migratedLegacy.ok) { + return migratedLegacy; + } + + return { ok: true, remoteUrl, pushRef: checkedOut.pushRef }; }; const resolveEventsPath = (tsSeconds: number): string => { @@ -537,6 +754,7 @@ export function createGitTicketsChannel(opts: { const pushWithRetry = async (input: { readonly remoteUrl: string | null; + readonly pushRef: string; readonly pendingEvents?: readonly Record[]; }): Promise< | { readonly ok: true; readonly didPush: boolean } @@ -547,7 +765,7 @@ export function createGitTicketsChannel(opts: { } const push = await runGitDir({ - args: ["push", "origin", `${localBranchRef}:${remoteRef}`], + args: ["push", "origin", `${localBranchRef}:${input.pushRef}`], }); if (push.ok) { return { ok: true, didPush: true }; @@ -583,7 +801,7 @@ export function createGitTicketsChannel(opts: { } const retry = await runGitDir({ - args: ["push", "origin", `${localBranchRef}:${remoteRef}`], + args: ["push", "origin", `${localBranchRef}:${checkedOut.pushRef}`], }); if (!retry.ok) { const retryMessage = `${retry.stderr}\n${retry.stdout}`.trim(); @@ -744,7 +962,10 @@ export function createGitTicketsChannel(opts: { return committed; } - const pushed = await pushWithRetry({ remoteUrl: checkedOut.remoteUrl }); + const pushed = await pushWithRetry({ + remoteUrl: checkedOut.remoteUrl, + pushRef: checkedOut.pushRef, + }); if (!pushed.ok) { return pushed; } @@ -794,6 +1015,7 @@ export function createGitTicketsChannel(opts: { const pushed = await pushWithRetry({ remoteUrl: checkedOut.remoteUrl, + pushRef: checkedOut.pushRef, pendingEvents: input.events, }); if (!pushed.ok) { @@ -828,7 +1050,10 @@ export function createGitTicketsChannel(opts: { return committed; } - const pushed = await pushWithRetry({ remoteUrl: checkedOut.remoteUrl }); + const pushed = await pushWithRetry({ + remoteUrl: checkedOut.remoteUrl, + pushRef: checkedOut.pushRef, + }); if (!pushed.ok) { return pushed; } @@ -885,6 +1110,51 @@ function safeJsonParse(text: string): unknown { } } +function mergeTicketEventLogs(input: { + readonly existing: string; + readonly incoming: string; +}): string { + const parsed: Record[] = []; + const seen = new Set(); + + for (const text of [input.existing, input.incoming]) { + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const value = safeJsonParse(trimmed); + if (!isRecord(value)) { + continue; + } + const eventId = typeof value.eventId === "string" ? value.eventId : ""; + const ts = typeof value.ts === "number" ? value.ts : Number.NaN; + if (!(eventId && Number.isFinite(ts))) { + continue; + } + if (seen.has(eventId)) { + continue; + } + seen.add(eventId); + parsed.push(value); + } + } + + parsed.sort((a, b) => { + const aTs = typeof a.ts === "number" ? (a.ts as number) : 0; + const bTs = typeof b.ts === "number" ? (b.ts as number) : 0; + if (aTs !== bTs) { + return aTs - bTs; + } + const aId = typeof a.eventId === "string" ? (a.eventId as string) : ""; + const bId = typeof b.eventId === "string" ? (b.eventId as string) : ""; + return aId.localeCompare(bId); + }); + + const next = parsed.map((event) => stableStringify(event)).join("\n"); + return next ? `${next}\n` : ""; +} + function isRetryableTrackingRefLockFailure(input: { readonly stderr: string; readonly trackingRef: string; @@ -944,3 +1214,24 @@ function isGitIndexLockError(message: string): boolean { normalized.includes("index.lock") && normalized.includes("file exists") ); } + +export const __testOnly = { + mergeTicketEventLogs, + resolvePushRefForCheckoutRef, +}; + +function resolvePushRefForCheckoutRef(input: { + readonly checkoutRef: string; + readonly remoteRef: string; + readonly legacyTrackingRef?: string | null; + readonly legacyRemoteRef?: string | null; +}): string { + if ( + input.legacyTrackingRef && + input.legacyRemoteRef && + input.checkoutRef === input.legacyTrackingRef + ) { + return input.legacyRemoteRef; + } + return input.remoteRef; +} diff --git a/tests/tickets-git-channel.test.ts b/tests/tickets-git-channel.test.ts new file mode 100644 index 000000000..c93f8419f --- /dev/null +++ b/tests/tickets-git-channel.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; + +import { __testOnly } from "../src/control-plane/extensions/tickets/tickets-git-channel.ts"; + +test("mergeTicketEventLogs dedupes by event id and preserves chronological order", () => { + const merged = __testOnly.mergeTicketEventLogs({ + existing: [ + JSON.stringify({ + eventId: "event-2", + ts: 2, + ticketId: "T-00002", + type: "ticket.created", + }), + JSON.stringify({ + eventId: "event-3", + ts: 3, + ticketId: "T-00003", + type: "ticket.created", + }), + "", + ].join("\n"), + incoming: [ + JSON.stringify({ + eventId: "event-1", + ts: 1, + ticketId: "T-00001", + type: "ticket.created", + }), + JSON.stringify({ + eventId: "event-2", + ts: 2, + ticketId: "T-00002", + type: "ticket.created", + }), + "", + ].join("\n"), + }); + + const lines = merged + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { readonly eventId: string; readonly ts: number } + ); + + expect(lines.map((line) => line.eventId)).toEqual([ + "event-1", + "event-2", + "event-3", + ]); + expect(lines.map((line) => line.ts)).toEqual([1, 2, 3]); +}); + +test("resolvePushRefForCheckoutRef prefers legacy branch when checkout came from legacy tracking ref", () => { + const pushRef = __testOnly.resolvePushRefForCheckoutRef({ + checkoutRef: "refs/remotes/origin/__legacy__/hack/tickets", + remoteRef: "refs/hack/tickets", + legacyTrackingRef: "refs/remotes/origin/__legacy__/hack/tickets", + legacyRemoteRef: "refs/heads/hack/tickets", + }); + + expect(pushRef).toBe("refs/heads/hack/tickets"); +}); + +test("resolvePushRefForCheckoutRef keeps hidden ref when checkout came from hidden tracking ref", () => { + const pushRef = __testOnly.resolvePushRefForCheckoutRef({ + checkoutRef: "origin/hack/tickets", + remoteRef: "refs/hack/tickets", + legacyTrackingRef: "refs/remotes/origin/__legacy__/hack/tickets", + legacyRemoteRef: "refs/heads/hack/tickets", + }); + + expect(pushRef).toBe("refs/hack/tickets"); +}); From aa98914d257d4cb7466ce250435c6a23073172c2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 20:31:44 -0400 Subject: [PATCH 45/54] Add tickets ref repair to doctor --- src/commands/doctor.ts | 204 +++++++++++++++++- .../extensions/tickets/commands.ts | 18 +- .../extensions/tickets/tickets-git-channel.ts | 50 ++++- 3 files changed, 258 insertions(+), 14 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 06f2eb768..37c3bcd45 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -23,6 +23,8 @@ import { } from "../constants.ts"; import { resolveGatewayConfig } from "../control-plane/extensions/gateway/config.ts"; import { listGatewayTokens } from "../control-plane/extensions/gateway/tokens.ts"; +import { createGitTicketsChannel } from "../control-plane/extensions/tickets/tickets-git-channel.ts"; +import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { requestDaemonJson } from "../daemon/client.ts"; import { resolveDaemonPaths } from "../daemon/paths.ts"; import { buildDaemonStatusReport, readDaemonStatus } from "../daemon/status.ts"; @@ -78,6 +80,15 @@ interface TimedCheckResult extends CheckResult { readonly durationMs: number; } +const noopLogger = { + info: (_input: { readonly message: string }) => { + // Intentionally quiet during read-only health checks. + }, + warn: (_input: { readonly message: string }) => { + // Intentionally quiet during read-only health checks. + }, +} as const; + const optFix = defineOption({ name: "fix", type: "boolean", @@ -343,6 +354,11 @@ const handleDoctor: CommandHandlerFor = async ({ } ) ); + results.push( + await runCheck(s, "tickets git", () => + checkProjectTicketsGitHealth({ startDir }) + ) + ); } else { results.push({ name: "DEV_HOST", @@ -350,13 +366,19 @@ const handleDoctor: CommandHandlerFor = async ({ message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, durationMs: 0, }); + results.push({ + name: "tickets git", + status: "warn", + message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, + durationMs: 0, + }); } emitSlowChecksNote(results); renderMacNote(); if (args.options.fix) { - await runDoctorFix(); + await runDoctorFix({ startDir }); note("Re-run: hack doctor", "doctor"); } @@ -1339,6 +1361,76 @@ async function checkDevHost({ }; } +async function checkProjectTicketsGitHealth({ + startDir, +}: { + readonly startDir: string; +}): Promise { + const project = await findProjectContext(startDir); + if (!project) { + return { + name: "tickets git", + status: "warn", + message: `Missing ${HACK_PROJECT_DIR_PRIMARY}/ (run 'hack init' in a repo)`, + }; + } + + const controlPlane = await readControlPlaneConfig({ + projectDir: project.projectDir, + }); + const gitConfig = controlPlane.config.tickets.git; + if (!gitConfig.enabled) { + return { + name: "tickets git", + status: "ok", + message: "Disabled", + }; + } + + const channel = createGitTicketsChannel({ + projectRoot: project.projectRoot, + config: gitConfig, + logger: noopLogger, + }); + const inspected = await channel.inspect(); + if (!inspected.ok) { + return { + name: "tickets git", + status: "warn", + message: inspected.error, + }; + } + + const health = inspected.health; + const problems: string[] = []; + if (health.hasRefDivergence) { + const remoteOid = health.remoteRefOid?.slice(0, 8) ?? "missing"; + const legacyOid = health.legacyRefOid?.slice(0, 8) ?? "missing"; + problems.push( + `hidden ref diverges from legacy branch (${remoteOid} vs ${legacyOid})` + ); + } else if (health.hasLegacyRef && health.legacyRef) { + problems.push(`legacy ref ${health.legacyRef} still exists`); + } + if (health.hasNonTicketFiles) { + problems.push("non-ticket files present"); + } + + if (problems.length === 0) { + return { + name: "tickets git", + status: "ok", + message: `Healthy (${health.remoteRef})`, + }; + } + + return { + name: "tickets git", + status: "warn", + message: `${problems.join("; ")} (run: hack doctor --fix)`, + }; +} + async function checkCaddyHostMapping({ startDir, }: { @@ -1448,9 +1540,12 @@ async function readLegacyEnvDevHost(envFile: string): Promise { return typeof host === "string" && host.length > 0 ? host : null; } -async function runDoctorFix(): Promise { +async function runDoctorFix(opts: { + readonly startDir: string; +}): Promise { const ok = await confirmOrThrow({ - message: "Attempt safe auto-remediations now? (network + CoreDNS + CA)", + message: + "Attempt safe auto-remediations now? (network + CoreDNS + CA + tickets refs)", initialValue: true, }); if (!ok) { @@ -1489,6 +1584,109 @@ async function runDoctorFix(): Promise { await maybeStartGlobalCaddyCompose({ paths }); await maybeExportCaddyCaCert({ paths }); await maybeMigrateDnsmasq(); + await maybeRepairProjectTicketsGitHealth({ startDir: opts.startDir }); +} + +async function maybeRepairProjectTicketsGitHealth(opts: { + readonly startDir: string; +}): Promise { + const project = await findProjectContext(opts.startDir); + if (!project) { + return; + } + + const controlPlane = await readControlPlaneConfig({ + projectDir: project.projectDir, + }); + const gitConfig = controlPlane.config.tickets.git; + if (!gitConfig.enabled) { + return; + } + + const channel = createGitTicketsChannel({ + projectRoot: project.projectRoot, + config: gitConfig, + logger: { + info: ({ message }) => note(message, "tickets"), + warn: ({ message }) => note(message, "tickets"), + }, + }); + const inspected = await channel.inspect(); + if (!inspected.ok) { + note(`Unable to inspect tickets git health: ${inspected.error}`, "doctor"); + return; + } + + const health = inspected.health; + if ( + !( + health.hasRefDivergence || + health.hasLegacyRef || + health.hasNonTicketFiles + ) + ) { + return; + } + + const reasons: string[] = []; + if (health.hasRefDivergence) { + reasons.push("hidden ref diverges from legacy branch"); + } else if (health.hasLegacyRef) { + reasons.push("legacy branch still exists"); + } + if (health.hasNonTicketFiles) { + reasons.push("non-ticket files present"); + } + + const okRepair = await confirmOrThrow({ + message: `Repair tickets git storage now? (${reasons.join("; ")})`, + initialValue: true, + }); + if (!okRepair) { + return; + } + + const pruneLegacyRef = + health.hasLegacyRef && + (await confirmOrThrow({ + message: `Prune legacy tickets ref ${health.legacyRef ?? "refs/heads/hack/tickets"} after repair?`, + initialValue: true, + })); + + const repaired = await channel.repair({ pruneLegacyRef }); + if (!repaired.ok) { + note(`Tickets repair failed: ${repaired.error}`, "doctor"); + return; + } + + const legacyRepairStatus = describeLegacyRepairStatus({ + hadLegacyRef: health.hasLegacyRef, + pruneLegacyRef, + didPruneLegacy: repaired.didPruneLegacy, + }); + const lines = [ + `commit: ${repaired.didCommit ? "created" : "noop"}`, + `push: ${repaired.didPush ? "pushed" : "skipped"}`, + `legacy ref: ${legacyRepairStatus}`, + ]; + if (repaired.pruneError) { + lines.push(`legacy prune error: ${repaired.pruneError}`); + } + note(lines.join("\n"), "tickets repair"); +} + +function describeLegacyRepairStatus(input: { + readonly hadLegacyRef: boolean; + readonly pruneLegacyRef: boolean; + readonly didPruneLegacy: boolean; +}): string { + if (!input.hadLegacyRef) { + return "not present"; + } + if (!input.pruneLegacyRef) { + return "left intact"; + } + return input.didPruneLegacy ? "pruned" : "prune failed"; } async function maybeInstallMutagenForDoctorFix(): Promise { diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts index cd8eef03d..673e0154c 100644 --- a/src/control-plane/extensions/tickets/commands.ts +++ b/src/control-plane/extensions/tickets/commands.ts @@ -1937,11 +1937,20 @@ async function maybeEnsureTicketsGitHealth(opts: { } const health = inspected.health; - if (!(health.hasLegacyRef || health.hasNonTicketFiles)) { + if ( + !( + health.hasLegacyRef || + health.hasRefDivergence || + health.hasNonTicketFiles + ) + ) { return; } const reasons: string[] = []; + if (health.hasRefDivergence) { + reasons.push("hidden ref diverges from legacy branch"); + } if (health.hasLegacyRef && health.legacyRef) { reasons.push(`legacy ref ${health.legacyRef}`); } @@ -1999,6 +2008,13 @@ async function maybeEnsureTicketsGitHealth(opts: { `legacy ref: ${health.legacyRef} ${pruneLegacyRef ? "pruned" : "left intact"}` ); } + if (health.hasRefDivergence) { + lines.push( + `ref divergence: ${ + health.remoteRefOid?.slice(0, 8) ?? "missing" + } vs ${health.legacyRefOid?.slice(0, 8) ?? "missing"}` + ); + } lines.push(`commit: ${repaired.didCommit ? "created" : "noop"}`); lines.push(`push: ${repaired.didPush ? "pushed" : "skipped"}`); if (repaired.pruneError) { diff --git a/src/control-plane/extensions/tickets/tickets-git-channel.ts b/src/control-plane/extensions/tickets/tickets-git-channel.ts index c88e557e1..c0e1a199f 100644 --- a/src/control-plane/extensions/tickets/tickets-git-channel.ts +++ b/src/control-plane/extensions/tickets/tickets-git-channel.ts @@ -38,6 +38,9 @@ export type TicketsGitHealth = { readonly remoteRef: string; readonly legacyRef?: string; readonly hasLegacyRef: boolean; + readonly hasRefDivergence: boolean; + readonly remoteRefOid?: string; + readonly legacyRefOid?: string; readonly hasNonTicketFiles: boolean; readonly nonTicketPaths: readonly string[]; }; @@ -245,6 +248,13 @@ export function createGitTicketsChannel(opts: { }; const resolvePreferredTrackingRef = async (): Promise => { + const tracking = await runGitDir({ + args: ["rev-parse", "--verify", trackingRef], + }); + if (tracking.ok) { + return trackingRef; + } + if (legacyTrackingRef) { const legacy = await runGitDir({ args: ["rev-parse", "--verify", legacyTrackingRef], @@ -254,13 +264,6 @@ export function createGitTicketsChannel(opts: { } } - const tracking = await runGitDir({ - args: ["rev-parse", "--verify", trackingRef], - }); - if (tracking.ok) { - return trackingRef; - } - return null; }; @@ -359,9 +362,7 @@ export function createGitTicketsChannel(opts: { legacyRemoteRef, legacyTrackingRef ); - if (legacyFetch.ok) { - checkoutRef = legacyTrackingRef; - } else if (!legacyFetch.missing) { + if (!(legacyFetch.ok || legacyFetch.missing)) { return { ok: false, error: `git fetch failed: ${legacyFetch.error}`, @@ -845,6 +846,17 @@ export function createGitTicketsChannel(opts: { return listed.ok && listed.stdout.trim().length > 0; }; + const resolveRefOid = async (ref: string): Promise => { + const resolved = await runGitDir({ + args: ["rev-parse", "--verify", ref], + }); + if (!resolved.ok) { + return null; + } + const oid = resolved.stdout.trim(); + return oid.length > 0 ? oid : null; + }; + const pruneWorktreeToTickets = async (): Promise< { readonly ok: true } | { readonly ok: false; readonly error: string } > => { @@ -907,6 +919,16 @@ export function createGitTicketsChannel(opts: { const hasLegacyRef = legacyRemoteRef ? await hasRemoteRef(legacyRemoteRef) : false; + const remoteRefOid = await resolveRefOid(trackingRef); + const legacyRefOid = + hasLegacyRef && legacyTrackingRef + ? await resolveRefOid(legacyTrackingRef) + : null; + const hasRefDivergence = + hasLegacyRef && + remoteRefOid !== null && + legacyRefOid !== null && + remoteRefOid !== legacyRefOid; return { ok: true, @@ -917,6 +939,9 @@ export function createGitTicketsChannel(opts: { legacyRef: legacyRemoteRef ?? undefined, remote: checkedOut.remoteUrl ? remoteName : undefined, hasLegacyRef, + hasRefDivergence, + remoteRefOid: remoteRefOid ?? undefined, + legacyRefOid: legacyRefOid ?? undefined, hasNonTicketFiles: nonTicketPaths.length > 0, nonTicketPaths, }, @@ -979,6 +1004,11 @@ export function createGitTicketsChannel(opts: { }); if (prunedLegacy.ok) { didPruneLegacy = true; + if (legacyTrackingRef) { + await runGitDir({ + args: ["update-ref", "-d", legacyTrackingRef], + }); + } } else { pruneError = `${prunedLegacy.stderr}\n${prunedLegacy.stdout}`.trim(); } From 9749f70fae4130dd39e8cec38036ff978938a166 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sun, 8 Mar 2026 20:48:08 -0400 Subject: [PATCH 46/54] Stabilize desktop ticket sync state --- .../DashboardFeature/DashboardModel.swift | 54 +++++++++++---- .../DashboardFeature/ProjectDetailView.swift | 66 +++---------------- .../DashboardFeature/TicketsView.swift | 43 +++++++++--- 3 files changed, 83 insertions(+), 80 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 72ab1aa29..250a6830e 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -317,7 +317,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - let response = await runActionResult(message: "Creating ticket…") { + let response = await runActionResult( + message: "Creating ticket…", + refreshDashboard: false + ) { try await self.ticketsClient.createTicket( path: path, title: title, @@ -338,7 +341,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - return await runActionResult(message: "Updating ticket status…") { + return await runActionResult( + message: "Updating ticket status…", + refreshDashboard: false + ) { try await self.ticketsClient.setTicketStatus(path: path, ticketId: ticketId, status: status) } } @@ -354,7 +360,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - return await runActionResult(message: "Appending ticket comment…") { + return await runActionResult( + message: "Appending ticket comment…", + refreshDashboard: false + ) { try await self.ticketsClient.appendTicketComment( path: path, ticketId: ticketId, @@ -375,7 +384,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - return await runActionResult(message: "Appending review note…") { + return await runActionResult( + message: "Appending review note…", + refreshDashboard: false + ) { try await self.ticketsClient.appendTicketReviewNote( path: path, ticketId: ticketId, @@ -390,7 +402,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - let response = await runActionResult(message: "Syncing tickets…") { + let response = await runActionResult( + message: "Syncing tickets…", + refreshDashboard: false + ) { try await self.ticketsClient.syncTickets(path: path) } return response?.sync @@ -407,7 +422,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - return await runActionResult(message: "Resolving sync conflict…") { + return await runActionResult( + message: "Resolving sync conflict…", + refreshDashboard: false + ) { try await self.ticketsClient.resolveTicketConflict( path: path, ticketId: ticketId, @@ -990,7 +1008,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - return await runActionResult(message: "Adding Linear project to sync scope…") { + return await runActionResult( + message: "Adding Linear project to sync scope…", + refreshDashboard: false + ) { try await self.client.linkLinearProject( path: path, profileId: profileId, @@ -1009,7 +1030,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - return await runActionResult(message: "Removing Linear project from sync scope…") { + return await runActionResult( + message: "Removing Linear project from sync scope…", + refreshDashboard: false + ) { try await self.client.unlinkLinearProject(path: path, projectId: projectId) } } @@ -1031,7 +1055,7 @@ public final class DashboardModel { let message = direction == "linear" ? "Syncing Linear issues into tickets…" : "Syncing tickets into Linear…" - return await runActionResult(message: message) { + return await runActionResult(message: message, refreshDashboard: false) { try await self.client.syncLinearProject( path: path, from: direction, @@ -1056,7 +1080,10 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return nil } - return await runActionResult(message: "Running Linear autosync…") { + return await runActionResult( + message: "Running Linear autosync…", + refreshDashboard: false + ) { try await self.client.runLinearAutosync( path: path, profileId: profileId, @@ -1084,7 +1111,7 @@ public final class DashboardModel { let message = direction == "linear" ? "Refreshing ticket from Linear…" : "Syncing ticket to Linear…" - return await runActionResult(message: message) { + return await runActionResult(message: message, refreshDashboard: false) { try await self.client.syncLinearIssue( path: path, from: direction, @@ -1536,6 +1563,7 @@ public final class DashboardModel { private func runActionResult( message: String, + refreshDashboard: Bool = true, action: @escaping () async throws -> T ) async -> T? { statusMessage = message @@ -1544,7 +1572,9 @@ public final class DashboardModel { do { let result = try await action() statusMessage = "Done" - await refresh() + if refreshDashboard { + await refresh() + } statusClearTask = Task { [weak self] in try? await Task.sleep(for: .seconds(2)) self?.statusMessage = nil diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 0f676697b..9ff4a9c31 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -176,15 +176,6 @@ struct ProjectDetailView: View { .onChange(of: project.kind) { _, _ in ensureSidebarSelection() } - .onChange(of: model.lastUpdated) { _, _ in - guard !executionTargetSaving else { - return - } - guard selectedSidebarItem != .remoteExecution else { - return - } - queueExecutionTargetReload() - } .onReceive(NotificationCenter.default.publisher(for: .hackProjectNavigationRequested)) { notification in guard let userInfo = notification.userInfo, @@ -921,39 +912,6 @@ struct ProjectDetailView: View { linearProjectPickerControl(accessibilityLabel: "Linear project") } - HStack(spacing: 8) { - if !linearAdditionalProjects.isEmpty { - StatusPill( - text: "\(linearAdditionalProjects.count) additional project\(linearAdditionalProjects.count == 1 ? "" : "s")", - tone: .neutral - ) - } - if hasAnyLinearAutosyncRoutes { - StatusPill( - text: "Autosync on for \(linearAutosyncRouteKeys.count)", - tone: .good - ) - } - Spacer(minLength: 0) - } - - if !linearAdditionalProjects.isEmpty { - projectSettingsControlGroup(footnote: additionalLinearProjectsSummary) { - VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel( - "Also sync", - help: "These extra Linear projects are already linked to this repo." - ) - - VStack(alignment: .leading, spacing: 8) { - ForEach(linearAdditionalProjects) { target in - linearScopeRouteRow(target: target, isDefault: false) - } - } - } - } - } - if let defaultTarget = defaultLinearRouteTarget { projectSettingsControlGroup( footnote: hasAnyLinearAutosyncRoutes @@ -969,6 +927,14 @@ struct ProjectDetailView: View { linearScopeRouteRow(target: defaultTarget, isDefault: true) if !linearAdditionalProjects.isEmpty { + Divider() + .opacity(0.12) + + projectSettingsFieldLabel( + "Additional projects", + help: "Only shown when this repo already has extra Linear projects linked." + ) + VStack(alignment: .leading, spacing: 8) { ForEach(linearAdditionalProjects) { target in linearScopeRouteRow(target: target, isDefault: false) @@ -1885,13 +1851,6 @@ struct ProjectDetailView: View { return !linearAdditionalProjects.isEmpty } - private var additionalLinearProjectsSummary: String { - if linearAdditionalProjects.isEmpty { - return "Default only" - } - return "\(linearAdditionalProjects.count) linked" - } - private var remoteConfigMessage: String { if !executionTargetMessage.isEmpty { return executionTargetMessage @@ -2408,7 +2367,6 @@ struct ProjectDetailView: View { executionTargetMessage = "Execution mode set to \(executionMode.title)." githubProfileMessage = "" - await model.refresh() queueExecutionTargetReload() } @@ -2458,7 +2416,6 @@ struct ProjectDetailView: View { executionTargetMessage = "Default node updated." } githubProfileMessage = "" - await model.refresh() queueExecutionTargetReload() } @@ -2503,7 +2460,6 @@ struct ProjectDetailView: View { ? "GitHub profile set to Local." : "GitHub profile set to \(githubProfileLabel(profileId: trimmed))." executionTargetMessage = "" - await model.refresh() queueExecutionTargetReload() } @@ -2535,7 +2491,6 @@ struct ProjectDetailView: View { : "Linear profile set to \(linearProfileLabel(profileId: trimmed))." executionTargetMessage = "" githubProfileMessage = "" - await model.refresh() queueExecutionTargetReload() } @@ -2576,7 +2531,6 @@ struct ProjectDetailView: View { linearProjectMessage = "Linear project bound to \(linearProjectMenuLabel(linearProject))." executionTargetMessage = "" githubProfileMessage = "" - await model.refresh() queueExecutionTargetReload() } @@ -2609,7 +2563,6 @@ struct ProjectDetailView: View { linearProjectMessage = "Added \(linearProjectMenuLabel(linearProject)) to the sync scope." executionTargetMessage = "" githubProfileMessage = "" - await model.refresh() queueExecutionTargetReload() } @@ -2654,7 +2607,6 @@ struct ProjectDetailView: View { linearProjectMessage = "Removed \(linearProjectBindingTargetLabel(target)) from the sync scope." executionTargetMessage = "" githubProfileMessage = "" - await model.refresh() queueExecutionTargetReload() } @@ -2718,7 +2670,6 @@ struct ProjectDetailView: View { } linearProjectMessage = "Default Linear route set to \(linearProjectBindingTargetLabel(normalizedTarget))." - await model.refresh() queueExecutionTargetReload() } @@ -2744,7 +2695,6 @@ struct ProjectDetailView: View { linearProjectMessage = "Cleared project-level Linear binding." executionTargetMessage = "" githubProfileMessage = "" - await model.refresh() queueExecutionTargetReload() } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index f172664ba..07c1de08a 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -41,8 +41,11 @@ struct TicketsView: View { @State private var postingReviewNoteTicketIds: Set = [] @State private var ticketDetailCache: [String: TicketDetailResponse] = [:] @State private var reviewComposerDrafts: [String: String] = [:] + @State private var refreshGeneration = 0 @FocusState private var ticketsListFocused: Bool + private static let ticketCacheTTL: TimeInterval = 60 * 10 + var body: some View { VStack(alignment: .leading, spacing: 16) { header @@ -58,17 +61,15 @@ struct TicketsView: View { .padding(.bottom, 24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { - await refreshLinearRouting() loadCachedTickets() + async let routingRefresh: Void = refreshLinearRouting() await refreshTickets() + _ = await routingRefresh ticketsListFocused = true } .task(id: selectedTicketId) { await loadTicketDetail() } - .onChange(of: model.lastUpdated) { _, _ in - Task { await refreshLinearRouting() } - } .onChange(of: selectedFilter) { _, _ in updateSelectionAfterRefresh() } @@ -1896,14 +1897,24 @@ struct TicketsView: View { } private func refreshTickets() async { + refreshGeneration += 1 + let generation = refreshGeneration + let previousSelectedTicketId = selectedTicketId isLoading = true errorMessage = nil - loadNotice = nil - let result = await loadTicketsWithTimeout(seconds: 4) + let result = await loadTicketsWithTimeout(seconds: 12) + guard generation == refreshGeneration else { + return + } switch result { case let .success(fetched): tickets = fetched updateSelectionAfterRefresh() + if selectedTicketId != nil, + selectedTicketId == previousSelectedTicketId + { + await loadTicketDetail() + } hasLoadedOnce = true persistCachedTickets() case .timedOut: @@ -1943,9 +1954,11 @@ struct TicketsView: View { let filtered = filteredTickets if let selectedTicketId, !filtered.contains(where: { $0.ticketId == selectedTicketId }) { - self.selectedTicketId = nil - ticketDetail = nil - detailErrorMessage = nil + self.selectedTicketId = filtered.first?.ticketId + if self.selectedTicketId == nil { + ticketDetail = nil + detailErrorMessage = nil + } } } @@ -2066,6 +2079,7 @@ struct TicketsView: View { } let routedProjects = result.projectIds?.count ?? (linearAdditionalProjects.isEmpty ? 1 : linearAdditionalProjects.count + 1) loadNotice = "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from \(routedProjects) routed Linear project\(routedProjects == 1 ? "" : "s")." + await refreshLinearRouting() await refreshTickets() } } @@ -2080,6 +2094,7 @@ struct TicketsView: View { } loadNotice = "Processed \(result.processedDeliveries) pending delivery\(result.processedDeliveries == 1 ? "" : "ies") across \(result.subscribedRoutes) subscribed route\(result.subscribedRoutes == 1 ? "" : "s") • applied \(result.appliedDeliveries) • failed \(result.failedDeliveries)." + await refreshLinearRouting() await refreshTickets() } } @@ -2101,6 +2116,7 @@ struct TicketsView: View { return } loadNotice = "Pushed \(result.processed) Hack ticket\(result.processed == 1 ? "" : "s") to Linear." + await refreshLinearRouting() await refreshTickets() } } @@ -2122,6 +2138,7 @@ struct TicketsView: View { return } loadNotice = "Synced \(result.ticketId) to \(result.issueIdentifier)." + await refreshLinearRouting() await refreshTickets() } } @@ -2147,6 +2164,7 @@ struct TicketsView: View { return } loadNotice = "Refreshed \(result.ticketId) from \(result.issueIdentifier)." + await refreshLinearRouting() await refreshTickets() } } @@ -2489,7 +2507,8 @@ struct TicketsView: View { } private func cacheKey() -> String { - "tickets.cache.\(project.id)" + let projectPath = project.repoRoot ?? project.projectDir ?? project.id + return "tickets.cache.\(project.id).\(projectPath)" } private func loadCachedTickets() { @@ -2497,6 +2516,10 @@ struct TicketsView: View { guard let data = UserDefaults.standard.data(forKey: key) else { return } let decoder = JSONDecoder() if let payload = try? decoder.decode(TicketCachePayload.self, from: data) { + guard Date().timeIntervalSince(payload.updatedAt) <= Self.ticketCacheTTL else { + UserDefaults.standard.removeObject(forKey: key) + return + } tickets = payload.tickets hasLoadedOnce = true updateSelectionAfterRefresh() From f61e889b85f166849bbd0b45e787407e582e9c28 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 9 Mar 2026 13:04:30 -0400 Subject: [PATCH 47/54] Add autosync-first tickets design docs --- ...26-03-09-linear-autosync-tickets-design.md | 165 ++++++++++++++ ...2026-03-09-linear-autosync-tickets-plan.md | 212 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 docs/plans/2026-03-09-linear-autosync-tickets-design.md create mode 100644 docs/plans/2026-03-09-linear-autosync-tickets-plan.md diff --git a/docs/plans/2026-03-09-linear-autosync-tickets-design.md b/docs/plans/2026-03-09-linear-autosync-tickets-design.md new file mode 100644 index 000000000..67b6dda5b --- /dev/null +++ b/docs/plans/2026-03-09-linear-autosync-tickets-design.md @@ -0,0 +1,165 @@ +# Linear Autosync Tickets Design + +## Context + +Hack Desktop now has the core Linear integration working: OAuth/app install, project binding, webhook ingress, manual sync, and project-level binding UI. The remaining problem is product shape. The tickets surface still behaves like an operator console with explicit `Pull` and `Push` actions, extra sync chrome, and too much decorative framing. The project settings surface still exposes concepts like `routing` and optional extra-project controls more prominently than the common case requires. + +The desired end state is closer to Linear's interaction model: tickets are the working surface, sync is mostly automatic, and manual repair paths exist but are secondary. + +## Goals + +- Make Linear sync autosync-first rather than manual-first in the main desktop workflow. +- Remove primary `Pull` / `Push` actions from the main Tickets header. +- Keep Hack-origin ticket creation in Linear opt-in per project. +- Let Linear-origin tickets auto-ingest into Hack for the bound project. +- Ensure dual-homed tickets continue syncing back to their source side according to existing field-ownership rules. +- Simplify `Project settings -> Ticket sync` to only the common configuration: + - bound Linear project + - inbound autosync from Linear + - optional Hack-to-Linear auto-create toggle +- Make the tickets surface denser, calmer, more monospaced, and visually closer to Linear. +- Remove redundant status/source/authority badges from the main list. + +## Non-Goals + +- No new ticket ownership model. +- No new conflict-resolution semantics. +- No migration away from git-backed tickets. +- No removal of manual sync/repair commands from the CLI. +- No redesign of the entire desktop shell outside the tickets/detail/project-settings surfaces touched here. + +## Sync Model + +### Default behavior + +For a project with a bound Linear project: + +- `Sync from Linear`: enabled by default + - inbound Linear updates arrive via webhook-backed autosync where possible + - periodic reconcile remains as a fallback/backstop +- `Create Hack tickets in Linear`: disabled by default + - Hack-origin tickets stay local unless explicitly enabled per project + +### Linear-origin tickets + +- Auto-ingest into Hack for the bound project. +- After ingestion, edits from Hack still sync back to Linear according to current field ownership and append-only comment rules. +- External updates should reach the app without requiring the user to manually press `Pull`. + +### Hack-origin tickets + +- Stay local by default. +- If `Create Hack tickets in Linear` is enabled for the project, new Hack-origin tickets auto-create in Linear. +- Once created remotely, they become dual-homed and continue syncing in both directions according to the existing source/authority rules. + +### Manual repair + +Manual sync remains available, but not as the main desktop control. + +It should move to a smaller recovery surface such as: +- Tickets overflow menu +- project advanced settings +- CLI-only repair flows + +## UI Model + +### Tickets surface + +The tickets page should behave like a working list, not a dashboard card. + +#### Layout +- Use the full content width in the context pane. +- Remove the outer rounded-card treatment around the ticket list. +- Keep row dividers, group headers, and split-pane structure. +- Use monospaced typography through the tickets workspace where practical: + - headers + - filters + - ids + - metadata + - detail sidebar/meta blocks + +#### Header controls +Keep only: +- status filter +- source filter +- search +- primary Linear project selector / summary +- `New` +- small overflow menu + +Remove primary `Pull` and `Push` buttons from the main Tickets header. + +#### Sync strip +Replace the current operational sync strip with a quiet status/config strip: +- bound Linear project +- inbound sync state +- whether Hack-to-Linear auto-create is enabled +- subtle health indicator if sync is unhealthy + +This strip should be informational and lightly configurable, not action-heavy. + +#### Ticket rows +Borrow Linear's row structure: +- left: status icon, ticket id, title +- optional inline hierarchy hint for parent/sub-issue relationship +- right: only relevant metadata when present (assignee, project, updated date, etc.) + +Remove from the main list rows: +- grouped status badges inside each row +- duplicate `hack` badges +- `Hack authority` pills as persistent default chrome + +Authority/source can still appear in detail metadata or only when it matters. + +### Ticket detail + +Move closer to Linear's issue detail structure: +- calmer title/meta top band +- activity/comments/review notes in a single chronological stream +- tighter grouped metadata +- fewer decorative pills and callouts + +Review/conflict state should remain visible, but only when relevant. + +### Project settings + +Rename the mental model from `routing` to `Ticket sync`. + +Common-case controls only: +- `Linear project` +- `Sync from Linear` toggle +- `Create Hack tickets in Linear` toggle + +Do not show additional project linking controls by default. +Keep advanced or rare multi-project scope out of the common settings path unless the repo already uses it. + +## Data / State Impact + +### Desktop state + +- Tickets view should stop doing full dashboard refreshes after each sync mutation. +- Project settings should not live-reload off generic dashboard refresh ticks. +- Autosync state should be refreshed explicitly where needed, not by page-wide churn. +- Ticket list caching should remain bounded and stale-safe. + +### Backend / sync behavior + +- Webhook ingress is already live on the Railway-backed auth broker. +- The remaining behavior work is about auto-applying inbound sync to local state and reducing visible reliance on manual pull/push. +- Periodic reconcile should remain available as a silent fallback. + +## Risks + +- Hiding manual sync too aggressively can make repair harder if autosync state is unclear. +- Making Hack-to-Linear creation automatic once enabled must avoid surprising users with project-wide noise. +- Density changes must preserve scannability and keyboard navigation. +- Linear-style simplification should not erase Hack-specific review/conflict affordances. + +## Validation + +- Verify webhook-backed sync path remains live and configured on the broker. +- Verify bound project autosync works without `Pull` / `Push` in the main tickets flow. +- Verify Hack-origin auto-create stays off by default and only activates when enabled. +- Verify ticket list remains responsive during sync. +- Verify project settings no longer expose unnecessary modal/routing chrome. +- Verify the desktop build and targeted sync tests pass. diff --git a/docs/plans/2026-03-09-linear-autosync-tickets-plan.md b/docs/plans/2026-03-09-linear-autosync-tickets-plan.md new file mode 100644 index 000000000..17158f65b --- /dev/null +++ b/docs/plans/2026-03-09-linear-autosync-tickets-plan.md @@ -0,0 +1,212 @@ +# Linear Autosync Tickets Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make Hack Desktop tickets autosync-first for Linear, remove primary manual sync controls from the main UI, and simplify the ticket/workflow surface to feel denser and closer to Linear. + +**Architecture:** Keep the existing Linear sync engine, webhook ingress, and field-ownership rules. Shift the product boundary instead: default to inbound autosync, keep outbound Hack-to-Linear creation opt-in per project, move manual repair flows out of the primary tickets header, and simplify desktop state management so sync no longer causes visible churn. + +**Tech Stack:** SwiftUI, Hack Desktop shared models, HackCLI macOS client, existing Linear CLI commands, auth-broker webhook/autosync plumbing, Bun tests. + +--- + +### Task 1: Encode the autosync-first project sync policy + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift` +- Modify: `apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift` +- Modify: `apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift` +- Test: `apps/macos` Swift package build + +**Step 1: Define the common-case project sync fields** + +Reduce the project settings `Ticket sync` surface to the fields that should remain visible by default: +- bound Linear project +- inbound autosync enabled/disabled +- Hack-to-Linear auto-create enabled/disabled + +**Step 2: Remove primary multi-project routing from the default surface** + +Keep additional project controls out of the common settings path unless the repo already has them configured. + +**Step 3: Wire the project toggles to the existing sync model** + +Use the current CLI/model plumbing or extend it minimally so the UI can persist the autosync-first settings without reintroducing modal routing behavior. + +**Step 4: Build the macOS package** + +Run: `swift build --package-path apps/macos` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +git commit -m "Simplify project ticket sync defaults" +``` + +### Task 2: Remove primary Pull/Push controls from the tickets header + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift` +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift` + +**Step 1: Replace manual sync chrome with a compact sync status strip** + +The Tickets header should show: +- bound Linear project +- inbound sync state +- outbound auto-create state +- health only when needed + +**Step 2: Move manual repair actions to overflow** + +Retain manual recovery paths such as reconcile/repair, but remove `Pull` and `Push` from the primary header. + +**Step 3: Ensure sync actions no longer depend on full dashboard refresh** + +Keep the responsiveness work intact while removing the visible manual action model. + +**Step 4: Build the macOS package** + +Run: `swift build --package-path apps/macos` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +git commit -m "Make desktop tickets autosync-first" +``` + +### Task 3: Densify the tickets list and remove redundant badges + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift` + +**Step 1: Remove outer card framing around the ticket list** + +Keep section headers and row dividers, but remove the enclosing rounded-box treatment in the tickets content area. + +**Step 2: Tighten row density and switch ticket workspace typography toward monospace** + +Apply denser spacing and monospaced treatment where appropriate for the tickets list and related metadata. + +**Step 3: Remove redundant per-row badges** + +Drop row-level grouped status badges and duplicate source/authority pills that do not add useful information in the grouped list view. + +**Step 4: Keep row structure closer to Linear** + +Use a left-heavy structure: status icon, id, title, inline hierarchy hint; only show right-side metadata when present. + +**Step 5: Build the macOS package** + +Run: `swift build --package-path apps/macos` +Expected: PASS + +**Step 6: Commit** + +```bash +git add apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +git commit -m "Densify desktop ticket list" +``` + +### Task 4: Reshape the ticket detail pane closer to Linear + +**Files:** +- Modify: `apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift` +- Modify: any supporting detail subviews already used by the tickets detail path + +**Step 1: Simplify the title/meta band** + +Reduce decorative badges and restructure the top of the detail pane around title, hierarchy, and compact metadata. + +**Step 2: Keep activity/comments/review notes in one chronological flow** + +Do not split the detail pane into multiple competing content blocks unless required for functionality. + +**Step 3: Keep Hack-specific review/conflict state visible but secondary** + +Only show stronger callouts when the issue actually needs intervention. + +**Step 4: Build the macOS package** + +Run: `swift build --package-path apps/macos` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +git commit -m "Refine desktop ticket detail layout" +``` + +### Task 5: Ensure autosync behavior is the default desktop path + +**Files:** +- Modify: `src/control-plane/extensions/linear/commands.ts` +- Modify: `src/mcp/server.ts` if desktop-exposed sync behavior needs plumbing +- Modify: any auth-broker autosync route files only if behavior changes are required there +- Test: `tests/linear-commands.test.ts`, relevant autosync tests + +**Step 1: Confirm the current desktop-visible sync path matches the approved contract** + +Ensure: +- inbound Linear sync is default for bound projects +- Hack-to-Linear auto-create is opt-in +- dual-homed tickets still sync back to their source side + +**Step 2: Add or update any missing config/state needed for those defaults** + +Keep this minimal and avoid reopening the sync model. + +**Step 3: Run targeted tests** + +Run: `bun test tests/linear-commands.test.ts tests/linear-client.test.ts tests/tickets-extension.test.ts tests/tickets-git-channel.test.ts` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/control-plane/extensions/linear/commands.ts src/mcp/server.ts tests/linear-commands.test.ts tests/linear-client.test.ts tests/tickets-extension.test.ts tests/tickets-git-channel.test.ts +git commit -m "Default Linear desktop sync to autosync behavior" +``` + +### Task 6: Full verification and live sanity check + +**Files:** +- Verify repo gates and desktop build +- Modify docs if implementation details changed materially from design + +**Step 1: Run verification gates** + +Run: +```bash +bun run build +bun run typecheck +bun run test +bun run check +hack ps +swift build --package-path apps/macos +xcodebuild -project apps/macos/HackDesktop.xcodeproj -scheme HackDesktop -configuration Debug -derivedDataPath /tmp/hackdesktop-derived-linear-autosync build +``` +Expected: PASS + +**Step 2: Perform live desktop sanity check** + +Verify: +- no primary `Pull` / `Push` +- compact sync strip +- denser ticket rows +- bound Linear project visible +- ticket detail still usable +- project `Ticket sync` settings reflect the autosync-first defaults + +**Step 3: Commit final integrated changes** + +```bash +git add docs/plans/2026-03-09-linear-autosync-tickets-design.md docs/plans/2026-03-09-linear-autosync-tickets-plan.md apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift src/control-plane/extensions/linear/commands.ts src/mcp/server.ts tests/linear-commands.test.ts tests/linear-client.test.ts tests/tickets-extension.test.ts tests/tickets-git-channel.test.ts +git commit -m "Finish autosync-first desktop tickets flow" +``` From 4683adf09669f91ab8dd0174e984fc616c0aa732 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 9 Mar 2026 13:28:15 -0400 Subject: [PATCH 48/54] Make desktop tickets autosync-first --- .../DashboardFeature/ProjectDetailView.swift | 304 ++++++++++--- .../DashboardFeature/TicketsView.swift | 406 ++++++++++-------- 2 files changed, 461 insertions(+), 249 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 9ff4a9c31..072c128ed 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -136,6 +136,8 @@ struct ProjectDetailView: View { @State private var linearProjectOptions: [LinearProjectSummary] = [] @State private var selectedLinearProjectId = "" @State private var selectedAdditionalLinearProjectId = "" + @State private var linearSyncFromLinearEnabled = true + @State private var linearCreateHackTicketsEnabled = false @State private var linearProjectMessage = "" @State private var projectSystemGitIdentity: GitSystemIdentity? = nil @State private var executionTargetReloadTask: Task? = nil @@ -893,52 +895,60 @@ struct ProjectDetailView: View { ) { linearAccountPickerControl(accessibilityLabel: "Linear account") } - } else { - projectSettingsField( - title: "Linear account", - help: "The connected Linear account this repo uses for ticket sync.", - footnote: selectedLinearProfileSummary - ) { - projectSettingsStaticValue(selectedLinearProfileSummary) - .accessibilityLabel("Linear account") - } } projectSettingsField( title: "Linear project", - help: "Choose the main Linear project this repo should pull from and push to.", + help: "Choose the main Linear project this repo should sync with.", footnote: selectedLinearProjectFootnote ) { linearProjectPickerControl(accessibilityLabel: "Linear project") } - if let defaultTarget = defaultLinearRouteTarget { - projectSettingsControlGroup( - footnote: hasAnyLinearAutosyncRoutes - ? "Autosync enabled for \(linearAutosyncRouteKeys.count) project\(linearAutosyncRouteKeys.count == 1 ? "" : "s")." - : "Manual sync only" - ) { - VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel( - "Autosync", - help: "Keep autosync scoped to the projects this repo should update without a manual pull or push." - ) + projectSettingsControlGroup( + footnote: linearSyncPolicySummary + ) { + VStack(alignment: .leading, spacing: 10) { + projectSettingsToggleRow( + title: "Sync from Linear", + subtitle: "Receive webhook-backed updates from the bound Linear project.", + isOn: Binding( + get: { linearSyncFromLinearEnabled }, + set: { enabled in + linearSyncFromLinearEnabled = enabled + Task { await persistLinearSyncFromLinearSetting(enabled: enabled) } + } + ), + isBusy: togglingLinearAutosyncRouteKeys.contains(defaultLinearRouteKey), + isEnabled: defaultLinearRouteTarget != nil + ) - linearScopeRouteRow(target: defaultTarget, isDefault: true) + projectSettingsToggleRow( + title: "Create Hack tickets in Linear", + subtitle: "Automatically create linked Linear issues for new Hack tickets in this repo.", + isOn: Binding( + get: { linearCreateHackTicketsEnabled }, + set: { enabled in + linearCreateHackTicketsEnabled = enabled + Task { await persistLinearCreateHackTicketsSetting(enabled: enabled) } + } + ), + isBusy: executionTargetSaving, + isEnabled: defaultLinearRouteTarget != nil + ) - if !linearAdditionalProjects.isEmpty { - Divider() - .opacity(0.12) + if !linearAdditionalProjects.isEmpty { + Divider() + .opacity(0.12) - projectSettingsFieldLabel( - "Additional projects", - help: "Only shown when this repo already has extra Linear projects linked." - ) + projectSettingsFieldLabel( + "Additional projects", + help: "Advanced multi-project sync scope already linked for this repo." + ) - VStack(alignment: .leading, spacing: 8) { - ForEach(linearAdditionalProjects) { target in - linearScopeRouteRow(target: target, isDefault: false) - } + VStack(alignment: .leading, spacing: 8) { + ForEach(linearAdditionalProjects) { target in + linearScopeRouteRow(target: target, isDefault: false) } } } @@ -1003,12 +1013,15 @@ struct ProjectDetailView: View { return "Connect Linear in Settings, then choose the project this repo should sync with." } if !hasAnyLinearProjectRouting { - return "Linear is connected. Choose the project this repo should pull from and push to." + return "Linear is connected. Choose the main Linear project for this repo." + } + if !linearSyncFromLinearEnabled { + return "Linear · \(selectedLinearProjectSummary) · inbound sync off" } - if linearAdditionalProjects.isEmpty { - return "Linear · \(selectedLinearProfileSummary) · \(selectedLinearProjectSummary)" + if linearCreateHackTicketsEnabled { + return "Linear · \(selectedLinearProjectSummary) · inbound on · create in Linear on" } - return "Linear · \(selectedLinearProfileSummary) · \(selectedLinearProjectSummary) · \(linearAdditionalProjects.count) additional" + return "Linear · \(selectedLinearProjectSummary) · inbound on" } private var selectedLinearProjectFootnote: String { @@ -1021,6 +1034,29 @@ struct ProjectDetailView: View { return selectedLinearProjectSummary } + private var linearSyncPolicySummary: String { + guard defaultLinearRouteTarget != nil else { + return "Choose a Linear project before sync can start." + } + if linearSyncFromLinearEnabled && linearCreateHackTicketsEnabled { + return "Inbound sync is on. New Hack tickets will also create linked Linear issues." + } + if linearSyncFromLinearEnabled { + return "Inbound sync is on for the bound Linear project. Hack tickets stay local unless you turn on outbound creation." + } + if linearCreateHackTicketsEnabled { + return "Inbound sync is off. New Hack tickets will still create linked Linear issues." + } + return "Sync is manual until you enable one of the project sync toggles." + } + + private var defaultLinearRouteKey: String { + guard let defaultLinearRouteTarget else { + return "__no_linear_route__" + } + return linearRouteKey(for: defaultLinearRouteTarget) + } + private var projectSettingsCardBackground: some View { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.primary.opacity(colorScheme == .dark ? 0.045 : 0.03)) @@ -1061,6 +1097,36 @@ struct ProjectDetailView: View { } } + private func projectSettingsToggleRow( + title: String, + subtitle: String, + isOn: Binding, + isBusy: Bool, + isEnabled: Bool = true + ) -> some View { + projectSettingsControlShell(maxWidth: 520) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isEnabled ? .primary : .secondary) + Text(subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.tertiary) + } + Spacer(minLength: 12) + if isBusy { + ProgressView() + .controlSize(.small) + } + Toggle(title, isOn: isOn) + .labelsHidden() + .toggleStyle(.switch) + .disabled(!isEnabled || isBusy) + } + } + } + private func projectSettingsFieldLabel(_ text: String, help: String? = nil) -> some View { HStack(alignment: .center, spacing: 6) { Text(text) @@ -1682,7 +1748,9 @@ struct ProjectDetailView: View { isOn: Binding( get: { isLinearAutosyncEnabled(for: normalizedTarget) }, set: { enabled in - toggleLinearAutosync(for: normalizedTarget, enabled: enabled) + Task { + await toggleLinearAutosync(for: normalizedTarget, enabled: enabled) + } } ) ) { @@ -2180,6 +2248,14 @@ struct ProjectDetailView: View { for: project, key: "controlPlane.routing.overrides.github.profile" ) + async let linearSyncFromLinearConfig = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.syncFromLinear" + ) + async let linearCreateHackTicketsConfig = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.createHackTicketsInLinear" + ) async let linearBinding = model.inspectLinearProjectBinding(for: project) async let defaultGitHubProfile = model.getGlobalConfig( key: "controlPlane.extensions[\"dance.hack.github\"].config.defaultProfile" @@ -2204,6 +2280,8 @@ struct ProjectDetailView: View { let resolvedExecutionNodeId = await executionNodeId let resolvedProjectNodeId = await projectNodeId let resolvedProjectGitHubProfile = await projectGitHubProfile + let resolvedLinearSyncFromLinearConfig = await linearSyncFromLinearConfig + let resolvedLinearCreateHackTicketsConfig = await linearCreateHackTicketsConfig let resolvedLinearBinding = await linearBinding let resolvedDefaultGitHubProfile = await defaultGitHubProfile let resolvedDefaultLinearProfile = await defaultLinearProfile @@ -2282,6 +2360,14 @@ struct ProjectDetailView: View { ? linearDefaultProfile : linearProjectProfile linearResolvedProfile = resolvedLinearProfile + linearSyncFromLinearEnabled = parseProjectBooleanConfig( + resolvedLinearSyncFromLinearConfig, + default: true + ) + linearCreateHackTicketsEnabled = parseProjectBooleanConfig( + resolvedLinearCreateHackTicketsConfig, + default: false + ) selectedLinearProjectId = linearBoundProjectId if linearAdditionalProjects.contains(where: { $0.projectId == selectedAdditionalLinearProjectId }) { selectedAdditionalLinearProjectId = "" @@ -2441,6 +2527,20 @@ struct ProjectDetailView: View { } } + private func parseProjectBooleanConfig(_ value: String?, default defaultValue: Bool) -> Bool { + guard let value else { + return defaultValue + } + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + default: + return defaultValue + } + } + private func persistGitHubProfileOverride() async { executionTargetSaving = true defer { executionTargetSaving = false } @@ -2494,6 +2594,53 @@ struct ProjectDetailView: View { queueExecutionTargetReload() } + private func persistLinearSyncFromLinearSetting( + enabled: Bool, + refreshAfterSave: Bool = true + ) async { + let didSave = await model.setProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.syncFromLinear", + value: enabled ? "true" : "false" + ) + guard didSave else { + linearSyncFromLinearEnabled.toggle() + linearProjectMessage = "Failed to save inbound Linear sync." + return + } + + if let defaultTarget = defaultLinearRouteTarget { + await toggleLinearAutosync( + for: defaultTarget, + enabled: enabled, + refreshAfterSave: false + ) + } + + linearProjectMessage = enabled + ? "Inbound Linear sync enabled." + : "Inbound Linear sync disabled." + if refreshAfterSave { + queueExecutionTargetReload() + } + } + + private func persistLinearCreateHackTicketsSetting(enabled: Bool) async { + let didSave = await model.setProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.createHackTicketsInLinear", + value: enabled ? "true" : "false" + ) + guard didSave else { + linearCreateHackTicketsEnabled.toggle() + linearProjectMessage = "Failed to save outbound Linear creation." + return + } + linearProjectMessage = enabled + ? "New Hack tickets will now create linked Linear issues." + : "New Hack tickets will stay local by default." + } + private func persistLinearProjectBindingSelection() async { executionTargetSaving = true defer { executionTargetSaving = false } @@ -2528,6 +2675,20 @@ struct ProjectDetailView: View { linearBoundProjectName = linearProject.name linearBoundTeamId = linearProject.teamId selectedLinearProjectId = linearProject.id + if linearSyncFromLinearEnabled { + await toggleLinearAutosync( + for: normalizedLinearRouteTarget( + LinearProjectBindingTarget( + profileId: profileOverride.isEmpty ? nil : profileOverride, + projectId: linearProject.id, + projectName: linearProject.name, + teamId: linearProject.teamId + ) + ), + enabled: true, + refreshAfterSave: false + ) + } linearProjectMessage = "Linear project bound to \(linearProjectMenuLabel(linearProject))." executionTargetMessage = "" githubProfileMessage = "" @@ -2612,45 +2773,50 @@ struct ProjectDetailView: View { private func toggleLinearAutosync( for target: LinearProjectBindingTarget, - enabled: Bool - ) { + enabled: Bool, + refreshAfterSave: Bool = true + ) async { let routeKey = linearRouteKey(for: target) togglingLinearAutosyncRouteKeys.insert(routeKey) - Task { - defer { togglingLinearAutosyncRouteKeys.remove(routeKey) } - let normalizedTarget = normalizedLinearRouteTarget(target) - guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { - linearProjectMessage = "Choose a Linear profile before changing autosync." - return - } - if enabled { - let response = await model.setLinearAutosyncSubscription( - profileId: profileId, - projectId: normalizedTarget.projectId, - teamId: normalizedTarget.teamId, - mode: "auto_apply", - status: "active" - ) - guard response != nil else { - linearProjectMessage = model.errorMessage ?? "Failed to enable Linear autosync." - return - } - linearAutosyncRouteKeys.insert(routeKey) - linearProjectMessage = "Autosync enabled for \(linearProjectBindingTargetLabel(normalizedTarget))." - return - } - - let response = await model.removeLinearAutosyncSubscription( + defer { togglingLinearAutosyncRouteKeys.remove(routeKey) } + let normalizedTarget = normalizedLinearRouteTarget(target) + guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { + linearProjectMessage = "Choose a Linear profile before changing autosync." + return + } + if enabled { + let response = await model.setLinearAutosyncSubscription( profileId: profileId, projectId: normalizedTarget.projectId, - teamId: normalizedTarget.teamId + teamId: normalizedTarget.teamId, + mode: "auto_apply", + status: "active" ) guard response != nil else { - linearProjectMessage = model.errorMessage ?? "Failed to disable Linear autosync." + linearProjectMessage = model.errorMessage ?? "Failed to enable Linear autosync." return } - linearAutosyncRouteKeys.remove(routeKey) - linearProjectMessage = "Autosync disabled for \(linearProjectBindingTargetLabel(normalizedTarget))." + linearAutosyncRouteKeys.insert(routeKey) + linearProjectMessage = "Autosync enabled for \(linearProjectBindingTargetLabel(normalizedTarget))." + if refreshAfterSave { + queueExecutionTargetReload() + } + return + } + + let response = await model.removeLinearAutosyncSubscription( + profileId: profileId, + projectId: normalizedTarget.projectId, + teamId: normalizedTarget.teamId + ) + guard response != nil else { + linearProjectMessage = model.errorMessage ?? "Failed to disable Linear autosync." + return + } + linearAutosyncRouteKeys.remove(routeKey) + linearProjectMessage = "Autosync disabled for \(linearProjectBindingTargetLabel(normalizedTarget))." + if refreshAfterSave { + queueExecutionTargetReload() } } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index 07c1de08a..5eab385d5 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -33,6 +33,8 @@ struct TicketsView: View { @State private var linearRouteTeamId = "" @State private var linearAdditionalProjects: [LinearProjectBindingTarget] = [] @State private var linearAutosyncRouteKeys: Set = [] + @State private var linearSyncFromLinearEnabled = true + @State private var linearCreateHackTicketsEnabled = false @State private var linearRouteStatus: LinearStatusResponse? = nil @State private var pendingBulkSyncAction: TicketBulkSyncAction? = nil @State private var activeSyncAction: TicketSyncAction? = nil @@ -67,6 +69,16 @@ struct TicketsView: View { _ = await routingRefresh ticketsListFocused = true } + .task(id: backgroundSyncTaskKey) { + guard shouldRunBackgroundSyncLoop else { + return + } + await runBackgroundSyncCycle() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(90)) + await runBackgroundSyncCycle() + } + } .task(id: selectedTicketId) { await loadTicketDetail() } @@ -144,44 +156,24 @@ struct TicketsView: View { linearSyncStatusRow Spacer(minLength: 12) - Button { - requestBulkSyncConfirmation(.pullLinear) - } label: { - Label("Pull", systemImage: "arrow.down.left") - } - .adaptiveToolbarButtonProminent() - .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) - - Button { - requestBulkSyncConfirmation(.pushHack) - } label: { - Label("Push", systemImage: "arrow.up.right") - } - .adaptiveToolbarButton() - .disabled(!canSyncProjectToLinear || isAnySyncInFlight) - - if hasAnyLinearAutosyncRoutes { - Button { - Task { await runProjectLinearAutosync() } - } label: { - Label("Run autosync", systemImage: "bolt.badge.clock") - } - .adaptiveToolbarButton() - .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) - } - - Button("Routing") { - openProjectRouting() - } - .adaptiveToolbarButton() - Menu { - Button("Refresh") { + Button("Refresh tickets") { Task { await refreshTickets() } } Button("Sync ticket ref store") { Task { await syncTickets() } } + if canSyncProjectFromLinear { + Divider() + Button("Reconcile from Linear") { + requestBulkSyncConfirmation(.pullLinear) + } + } + if linearRouteStatus?.tokenResolved == true && hasAnyLinearAutosyncRoutes { + Button("Run autosync now") { + Task { await runProjectLinearAutosync() } + } + } if let selectedTicket { Divider() Button("Push selected ticket to Linear") { @@ -200,6 +192,9 @@ struct TicketsView: View { } } Divider() + Button("Open ticket sync settings") { + openProjectRouting() + } Button("Open Linear settings") { NotificationCenter.default.post( name: .hackSettingsRequested, @@ -207,9 +202,6 @@ struct TicketsView: View { userInfo: ["pane": "linear"] ) } - Button("Open project routing") { - openProjectRouting() - } } label: { Image(systemName: "ellipsis.circle") .font(.system(size: 15, weight: .semibold)) @@ -270,11 +262,11 @@ struct TicketsView: View { } private var canSyncProjectFromLinear: Bool { - linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting + linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting && linearSyncFromLinearEnabled } private var canSyncProjectToLinear: Bool { - linearRouteStatus?.tokenResolved == true + linearRouteStatus?.tokenResolved == true && linearCreateHackTicketsEnabled } private var isAnySyncInFlight: Bool { @@ -315,42 +307,57 @@ struct TicketsView: View { !linearAutosyncRouteKeys.isEmpty } + private var backgroundSyncTaskKey: String { + [ + project.id, + linearRouteProfile, + linearRouteProjectId, + linearSyncFromLinearEnabled ? "inbound-on" : "inbound-off", + linearCreateHackTicketsEnabled ? "outbound-on" : "outbound-off" + ].joined(separator: "::") + } + + private var shouldRunBackgroundSyncLoop: Bool { + linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting && + (linearSyncFromLinearEnabled || linearCreateHackTicketsEnabled) + } + private var linearSyncStatusRow: some View { - HStack(spacing: 8) { + HStack(spacing: 10) { Image(systemName: linearRouteStatus?.tokenResolved == true ? "point.3.connected.trianglepath.dotted" : "exclamationmark.triangle.fill") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 11, weight: .semibold)) .foregroundStyle(linearRouteStatus?.tokenResolved == true ? Color.secondary : Color.orange) Text(linearSyncStatusSummary) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) + Spacer(minLength: 0) + if hasAnyLinearAutosyncRoutes { - StatusPill( - text: "\(linearAutosyncRouteKeys.count) autosync", - tone: .good - ) + Text("autosync on") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) } if reviewQueueCount > 0 { - StatusPill( - text: reviewQueueCount == 1 ? "1 review" : "\(reviewQueueCount) reviews", - tone: .warn - ) + Text(reviewQueueCount == 1 ? "1 review" : "\(reviewQueueCount) reviews") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.orange) } } - .padding(.horizontal, 12) - .padding(.vertical, 9) + .padding(.horizontal, 10) + .padding(.vertical, 7) .frame(maxWidth: .infinity, alignment: .leading) .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(colorScheme == .dark ? Color.white.opacity(0.06) : Color.white.opacity(0.86)) + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.04) : Color.black.opacity(0.03)) .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) + RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke( linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting - ? Color.black.opacity(colorScheme == .dark ? 0.0 : 0.06) + ? Color.primary.opacity(colorScheme == .dark ? 0.08 : 0.06) : Color.orange.opacity(colorScheme == .dark ? 0.32 : 0.22), lineWidth: 1 ) @@ -363,14 +370,19 @@ struct TicketsView: View { return "Connect Linear to route this repo's tickets." } if !hasAnyLinearProjectRouting { - return "Choose a default Linear project for this repo." - } - if linearAdditionalProjects.isEmpty { - let defaultLabel = linearRouteProjectName.isEmpty ? linearRouteProjectId : linearRouteProjectName - return "\(linearProfileDisplayName) · \(defaultLabel)" + return "Choose a Linear project for this repo." } let defaultLabel = linearRouteProjectName.isEmpty ? linearRouteProjectId : linearRouteProjectName - return "\(linearProfileDisplayName) · \(defaultLabel) + \(linearAdditionalProjects.count)" + if linearSyncFromLinearEnabled && linearCreateHackTicketsEnabled { + return "\(defaultLabel) · inbound on · create in Linear on" + } + if linearSyncFromLinearEnabled { + return "\(defaultLabel) · syncing from Linear" + } + if linearCreateHackTicketsEnabled { + return "\(defaultLabel) · create in Linear on" + } + return "\(defaultLabel) · sync off" } private var linearProfileDisplayName: String { @@ -576,7 +588,7 @@ struct TicketsView: View { .foregroundStyle(.secondary) TextField("Search title or ID", text: $searchText) .textFieldStyle(.plain) - .font(.system(size: 14, weight: .medium)) + .font(.system(size: 14, weight: .medium, design: .monospaced)) .frame(minWidth: 180, maxWidth: 320) if !searchText.isEmpty { Button { @@ -597,11 +609,11 @@ struct TicketsView: View { } private var content: some View { - HStack(alignment: .top, spacing: 16) { + HStack(alignment: .top, spacing: 0) { ticketsListCard .frame( minWidth: 320, - maxWidth: hasSelection ? 430 : .infinity, + maxWidth: hasSelection ? 520 : .infinity, maxHeight: .infinity, alignment: .topLeading ) @@ -616,44 +628,42 @@ struct TicketsView: View { } private var ticketsListCard: some View { - ticketPanel(contentPadding: 0) { - VStack(alignment: .leading, spacing: 0) { - if selectedFilter == .reviewQueue { - reviewQueueListHeader - } else { - standardTicketsListHeader - } - Divider() - .opacity(0.22) - ScrollView { - if isLoading && !hasLoadedOnce { - skeletonList - } else if selectedFilter == .reviewQueue && filteredReviewQueueEntries.isEmpty { - reviewQueueEmptyView - } else if filteredTickets.isEmpty { - emptyTicketsView - } else if selectedFilter == .all { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - ForEach(TicketStatus.allCases, id: \.self) { status in - ticketSection(status: status) - } + VStack(alignment: .leading, spacing: 0) { + if selectedFilter == .reviewQueue { + reviewQueueListHeader + } else { + standardTicketsListHeader + } + Divider() + .opacity(0.12) + ScrollView { + if isLoading && !hasLoadedOnce { + skeletonList + } else if selectedFilter == .reviewQueue && filteredReviewQueueEntries.isEmpty { + reviewQueueEmptyView + } else if filteredTickets.isEmpty { + emptyTicketsView + } else if selectedFilter == .all { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(TicketStatus.allCases, id: \.self) { status in + ticketSection(status: status) } - } else if selectedFilter == .reviewQueue { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(filteredReviewQueueEntries) { entry in - reviewQueueRow(entry) - } + } + } else if selectedFilter == .reviewQueue { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredReviewQueueEntries) { entry in + reviewQueueRow(entry) } - } else { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(filteredTickets) { ticket in - ticketRow(ticket) - } + } + } else { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredTickets) { ticket in + ticketRow(ticket) } } } - .frame(maxHeight: .infinity, alignment: .topLeading) } + .frame(maxHeight: .infinity, alignment: .topLeading) } .focusable() .focused($ticketsListFocused) @@ -663,16 +673,16 @@ struct TicketsView: View { private var standardTicketsListHeader: some View { HStack(spacing: 8) { Text("\(filteredTickets.count) issue\(filteredTickets.count == 1 ? "" : "s")") - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) Spacer() if !filteredTickets.isEmpty { Text("Use ↑ ↓ to navigate") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) } } - .padding(.horizontal, 14) - .padding(.vertical, 12) + .padding(.horizontal, 8) + .padding(.vertical, 8) } private var reviewQueueListHeader: some View { @@ -681,10 +691,10 @@ struct TicketsView: View { Text( "\(filteredReviewQueueEntries.count) review item\(filteredReviewQueueEntries.count == 1 ? "" : "s")" ) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) Spacer() Text("Use ↑ ↓ to triage") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) } ScrollView(.horizontal, showsIndicators: false) { @@ -720,8 +730,8 @@ struct TicketsView: View { .adaptiveToolbarButton() } } - .padding(.horizontal, 14) - .padding(.vertical, 12) + .padding(.horizontal, 8) + .padding(.vertical, 8) } private var ticketDetailCard: some View { @@ -767,9 +777,9 @@ struct TicketsView: View { private var emptyTicketsView: some View { VStack(alignment: .leading, spacing: 8) { Text("No tickets found.") - .font(.system(size: 15, weight: .semibold)) - Text("Adjust filters, pull from Linear, or create a new ticket.") - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + Text("Adjust filters, reconcile from Linear, or create a new ticket.") + .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) HStack(spacing: 8) { Button("New ticket") { @@ -777,13 +787,13 @@ struct TicketsView: View { } .adaptiveToolbarButton() - Button("Pull Linear") { + Button("Reconcile now") { requestBulkSyncConfirmation(.pullLinear) } .adaptiveToolbarButtonProminent() .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) - Button("Routing") { + Button("Ticket sync") { openProjectRouting() } .adaptiveToolbarButton() @@ -803,13 +813,13 @@ struct TicketsView: View { private var reviewQueueEmptyView: some View { VStack(alignment: .leading, spacing: 8) { Text(reviewQueueCount == 0 ? "No tickets need sync review." : "No review items match the current filters.") - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) Text( reviewQueueCount == 0 ? "When conflicts, mergeable-field ambiguity, or follow-up notes land, they will appear here for triage." : "Clear the search text, widen the origin filter, or switch back to the full ticket board." ) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) HStack(spacing: 8) { Button("All tickets") { @@ -847,22 +857,21 @@ struct TicketsView: View { Text(ticket.ticketId) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) - ticketStatusBadge(ticket.status) Spacer() Text(humanReadableListDate(ticket.updatedAt)) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) } Text(ticket.title) - .font(.system(size: 15, weight: .semibold)) - .lineLimit(2) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .lineLimit(1) .multilineTextAlignment(.leading) ticketRowMetadata(ticket) } Spacer(minLength: 0) } - .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.horizontal, 8) + .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(selectionBackground(for: ticket.ticketId)) .overlay(alignment: .bottom) { @@ -880,7 +889,6 @@ struct TicketsView: View { } private func reviewQueueRow(_ entry: TicketReviewQueueEntry) -> some View { - let ticket = tickets.first(where: { $0.ticketId == entry.ticketId }) return Button { if selectedTicketId == entry.ticketId { selectedTicketId = nil @@ -906,40 +914,26 @@ struct TicketsView: View { entry.badgeLabel, tone: entry.openConflictCount > 0 ? .orange : .secondary ) - if let ticket { - ticketMetaPill( - ticketAuthorityBadgeLabel(for: ticket), - tone: ticketAuthorityBadgeColor(for: ticket) - ) - ticketMetaPill( - ticket.owner, - tone: ticket.owner == "linear" ? .orange : .secondary - ) - } Spacer(minLength: 8) Text(humanReadableListDate(entry.updatedAt)) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) } Text(entry.title) - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) .multilineTextAlignment(.leading) - .lineLimit(2) + .lineLimit(1) Text(entry.summary) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) .multilineTextAlignment(.leading) - .lineLimit(3) + .lineLimit(2) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ticketMetaPill( "\(entry.openConflictCount) open", tone: entry.openConflictCount > 0 ? .orange : .secondary ) - ticketMetaPill( - "\(entry.commentCount) comment\(entry.commentCount == 1 ? "" : "s")", - tone: .secondary - ) if entry.reviewNoteCount > 0 { ticketMetaPill( "\(entry.reviewNoteCount) review note\(entry.reviewNoteCount == 1 ? "" : "s")", @@ -954,8 +948,8 @@ struct TicketsView: View { } } } - .padding(.horizontal, 12) - .padding(.vertical, 12) + .padding(.horizontal, 8) + .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(selectionBackground(for: entry.ticketId)) .overlay(alignment: .bottom) { @@ -973,23 +967,25 @@ struct TicketsView: View { } private func ticketRowMetadata(_ ticket: TicketSummary) -> some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ticketMetaPill(ticket.owner, tone: ticket.owner == "linear" ? .orange : .secondary) - ticketMetaPill(ticket.source, tone: ticket.source == "linear" ? .orange : .secondary) - if let assignee = ticket.assignee?.trimmingCharacters(in: .whitespacesAndNewlines), !assignee.isEmpty { - ticketMetaPill("Assignee \(assignee)", tone: .secondary) - } - ticketMetaPill(ticketAuthorityBadgeLabel(for: ticket), tone: ticketAuthorityBadgeColor(for: ticket)) - if ticket.linearSyncUXState.reviewHint != nil { - ticketMetaPill("Review sync", tone: .orange) - } - if let externalKey = ticket.externalKey, !externalKey.isEmpty { - ticketMetaPill(externalKey, tone: .accentColor) - } + HStack(spacing: 10) { + if let externalKey = ticket.externalKey, !externalKey.isEmpty { + Text(externalKey) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + if let assignee = ticket.assignee?.trimmingCharacters(in: .whitespacesAndNewlines), !assignee.isEmpty { + Text(assignee) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if ticket.linearSyncUXState.reviewHint != nil { + Text("review") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.orange) } - .padding(.top, 2) } + .padding(.top, 2) } private func ticketDetailView(_ detail: TicketDetailResponse) -> some View { @@ -997,13 +993,13 @@ struct TicketsView: View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { Text(detail.ticket.title) - .font(.system(size: 29, weight: .bold, design: .rounded)) + .font(.system(size: 24, weight: .semibold, design: .monospaced)) HStack(spacing: 10) { Text(detail.ticket.ticketId) .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) Text("Updated \(humanReadableDetailDate(detail.ticket.updatedAt))") - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) } ticketDetailMetadata(detail.ticket) @@ -1014,9 +1010,6 @@ struct TicketsView: View { if let body = detail.ticket.body, !body.isEmpty { VStack(alignment: .leading, spacing: 6) { - Text("Body") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) markdownBody(body) } } @@ -1033,14 +1026,9 @@ struct TicketsView: View { private func ticketDetailMetadata(_ ticket: TicketSummary) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ticketMetaPill(ticket.owner, tone: ticket.owner == "linear" ? .orange : .secondary) - ticketMetaPill(ticket.source, tone: ticket.source == "linear" ? .orange : .secondary) + ticketMetaPill(ticket.status.label, tone: ticket.status.color) if let assignee = ticket.assignee?.trimmingCharacters(in: .whitespacesAndNewlines), !assignee.isEmpty { - ticketMetaPill("Assignee \(assignee)", tone: .secondary) - } - ticketMetaPill(ticketAuthorityBadgeLabel(for: ticket), tone: ticketAuthorityBadgeColor(for: ticket)) - if ticket.linearSyncUXState.reviewHint != nil { - ticketMetaPill("Review sync", tone: .orange) + ticketMetaPill(assignee, tone: .secondary) } if let externalSystem = ticket.externalSystem, !externalSystem.isEmpty { ticketMetaPill(externalSystem, tone: .accentColor) @@ -1051,10 +1039,8 @@ struct TicketsView: View { if let projectName = ticket.externalProjectName, !projectName.isEmpty { ticketMetaPill(projectName, tone: .secondary) } - if !ticket.tags.isEmpty { - ForEach(ticket.tags, id: \.self) { tag in - ticketMetaPill(tag, tone: .secondary) - } + if ticket.linearSyncUXState.reviewHint != nil { + ticketMetaPill("Needs review", tone: .orange) } } } @@ -2016,12 +2002,28 @@ struct TicketsView: View { async let defaultProfile = model.getGlobalConfig( key: "controlPlane.extensions[\"dance.hack.linear\"].config.defaultProfile" ) + async let syncFromLinearConfig = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.syncFromLinear" + ) + async let createHackTicketsConfig = model.getProjectConfig( + for: project, + key: "controlPlane.routing.overrides.linear.createHackTicketsInLinear" + ) let resolvedBinding = await binding let resolvedProjectProfile = (resolvedBinding?.profileId ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) let resolvedDefaultProfile = (await defaultProfile ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) + linearSyncFromLinearEnabled = parseProjectBooleanConfig( + await syncFromLinearConfig, + default: true + ) + linearCreateHackTicketsEnabled = parseProjectBooleanConfig( + await createHackTicketsConfig, + default: false + ) linearRouteProfile = resolvedProjectProfile.isEmpty ? resolvedDefaultProfile : resolvedProjectProfile linearRouteProjectId = (resolvedBinding?.projectId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) linearRouteProjectName = (resolvedBinding?.projectName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -2033,6 +2035,20 @@ struct TicketsView: View { linearAutosyncRouteKeys = await loadLinearAutosyncRouteKeys(targets: allLinearRouteTargets) } + private func parseProjectBooleanConfig(_ value: String?, default defaultValue: Bool) -> Bool { + guard let value else { + return defaultValue + } + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + default: + return defaultValue + } + } + private func loadLinearAutosyncRouteKeys( targets: [LinearProjectBindingTarget] ) async -> Set { @@ -2084,6 +2100,40 @@ struct TicketsView: View { } } + private func runBackgroundSyncCycle() async { + guard !isAnySyncInFlight else { + return + } + + if linearSyncFromLinearEnabled, canSyncProjectFromLinear { + let autosyncResult = await model.runLinearAutosync(for: project) + if autosyncResult?.ok == true { + await refreshLinearRouting() + await refreshTickets() + } + } + + guard !isAnySyncInFlight else { + return + } + + if linearCreateHackTicketsEnabled, canSyncProjectToLinear { + let outboundResult = await model.syncLinearProject( + for: project, + from: "hack", + ownerMode: "hack", + projectId: nil, + teamId: nil, + limit: nil, + syncLabels: nil + ) + if outboundResult?.ok == true { + await refreshLinearRouting() + await refreshTickets() + } + } + } + private func runProjectLinearAutosync() async { await runSyncAction(.runProjectAutosync) { loadNotice = nil @@ -2414,21 +2464,17 @@ struct TicketsView: View { .fill(status.color) .frame(width: 8, height: 8) Text(status.label) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) Text("\(items.count)") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) .foregroundStyle(.secondary) } - .padding(.vertical, 8) - .padding(.horizontal, 12) + .padding(.vertical, 6) + .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) .background( - ZStack { - Rectangle() - .fill(.ultraThinMaterial) - Rectangle() - .fill(status.color.opacity(colorScheme == .dark ? 0.18 : 0.08)) - } + Rectangle() + .fill(status.color.opacity(colorScheme == .dark ? 0.14 : 0.07)) ) .overlay(alignment: .bottom) { Rectangle() @@ -2444,7 +2490,7 @@ struct TicketsView: View { private func inspectorGroup(title: String, rows: [DetailRowItem]) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) .foregroundStyle(.secondary) DetailRows(rows: rows, labelWidth: 88) } From 0a99bf80885f3aeb7858d4b9301cf2e42dacf0fc Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 9 Mar 2026 14:13:03 -0400 Subject: [PATCH 49/54] Polish desktop tickets autosync UX --- .../DashboardFeature/TicketsView.swift | 360 ++++++------------ 1 file changed, 108 insertions(+), 252 deletions(-) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index 5eab385d5..d65c24542 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -36,7 +36,6 @@ struct TicketsView: View { @State private var linearSyncFromLinearEnabled = true @State private var linearCreateHackTicketsEnabled = false @State private var linearRouteStatus: LinearStatusResponse? = nil - @State private var pendingBulkSyncAction: TicketBulkSyncAction? = nil @State private var activeSyncAction: TicketSyncAction? = nil @State private var resolvingConflictIds: Set = [] @State private var postingCommentTicketIds: Set = [] @@ -110,24 +109,6 @@ struct TicketsView: View { } ) } - .confirmationDialog( - pendingBulkSyncAction?.dialogTitle ?? "Confirm Linear sync", - isPresented: bulkSyncConfirmationPresented, - titleVisibility: .visible - ) { - if let pendingBulkSyncAction { - Button(pendingBulkSyncAction.confirmLabel) { - Task { await performBulkSync(pendingBulkSyncAction) } - } - } - Button("Cancel", role: .cancel) { - pendingBulkSyncAction = nil - } - } message: { - if let pendingBulkSyncAction { - Text(pendingBulkSyncAction.message) - } - } } private var header: some View { @@ -160,26 +141,18 @@ struct TicketsView: View { Button("Refresh tickets") { Task { await refreshTickets() } } - Button("Sync ticket ref store") { - Task { await syncTickets() } - } if canSyncProjectFromLinear { Divider() Button("Reconcile from Linear") { - requestBulkSyncConfirmation(.pullLinear) - } - } - if linearRouteStatus?.tokenResolved == true && hasAnyLinearAutosyncRoutes { - Button("Run autosync now") { - Task { await runProjectLinearAutosync() } + Task { await syncProjectFromLinear() } } } if let selectedTicket { Divider() - Button("Push selected ticket to Linear") { + Button(canPushTicketToLinear(selectedTicket) && selectedTicket.externalKey?.isEmpty != false ? "Create selected ticket in Linear" : "Update selected ticket in Linear") { Task { await syncSelectedTicketToLinear(selectedTicket) } } - .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) + .disabled(!canPushTicketToLinear(selectedTicket) || isAnySyncInFlight) Button("Refresh selected ticket from Linear") { Task { await syncSelectedTicketFromLinear(selectedTicket) } } @@ -265,23 +238,23 @@ struct TicketsView: View { linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting && linearSyncFromLinearEnabled } - private var canSyncProjectToLinear: Bool { - linearRouteStatus?.tokenResolved == true && linearCreateHackTicketsEnabled + private var canSyncLinkedTicketsToLinear: Bool { + linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting } - private var isAnySyncInFlight: Bool { - activeSyncAction != nil + private var canSyncHackOwnedTicketsToLinear: Bool { + canSyncLinkedTicketsToLinear && linearCreateHackTicketsEnabled } - private var bulkSyncConfirmationPresented: Binding { - Binding( - get: { pendingBulkSyncAction != nil }, - set: { isPresented in - if !isPresented { - pendingBulkSyncAction = nil - } - } - ) + private func canPushTicketToLinear(_ ticket: TicketSummary) -> Bool { + if ticket.externalKey?.isEmpty == false { + return linearRouteStatus?.tokenResolved == true + } + return linearRouteStatus?.tokenResolved == true && linearCreateHackTicketsEnabled + } + + private var isAnySyncInFlight: Bool { + activeSyncAction != nil } @ViewBuilder @@ -303,10 +276,6 @@ struct TicketsView: View { return !linearAdditionalProjects.isEmpty } - private var hasAnyLinearAutosyncRoutes: Bool { - !linearAutosyncRouteKeys.isEmpty - } - private var backgroundSyncTaskKey: String { [ project.id, @@ -335,12 +304,6 @@ struct TicketsView: View { Spacer(minLength: 0) - if hasAnyLinearAutosyncRoutes { - Text("autosync on") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - if reviewQueueCount > 0 { Text(reviewQueueCount == 1 ? "1 review" : "\(reviewQueueCount) reviews") .font(.system(size: 11, weight: .medium, design: .monospaced)) @@ -374,22 +337,15 @@ struct TicketsView: View { } let defaultLabel = linearRouteProjectName.isEmpty ? linearRouteProjectId : linearRouteProjectName if linearSyncFromLinearEnabled && linearCreateHackTicketsEnabled { - return "\(defaultLabel) · inbound on · create in Linear on" + return "\(defaultLabel) · syncing with Linear · create in Linear on" } if linearSyncFromLinearEnabled { - return "\(defaultLabel) · syncing from Linear" + return "\(defaultLabel) · syncing with Linear" } if linearCreateHackTicketsEnabled { return "\(defaultLabel) · create in Linear on" } - return "\(defaultLabel) · sync off" - } - - private var linearProfileDisplayName: String { - if !linearRouteProfile.isEmpty { - return linearRouteProfile - } - return "Inherited profile" + return "\(defaultLabel) · local-only outbound" } private var allLinearRouteTargets: [LinearProjectBindingTarget] { @@ -450,19 +406,13 @@ struct TicketsView: View { return "Linear sync needs attention" } if normalized.contains("not linked") { - return "Linear link required" + return "Local-only ticket" } - if normalized.hasPrefix("pulled") { - return "Linear pull complete" + if normalized.hasPrefix("reconciled") { + return "Reconciliation complete" } - if normalized.hasPrefix("pushed") { - return "Hack push complete" - } - if normalized.hasPrefix("synced") { - return "Ticket pushed to Linear" - } - if normalized.hasPrefix("refreshed") { - return "Ticket refreshed from Linear" + if normalized.hasPrefix("created") || normalized.hasPrefix("updated") { + return "Linear issue updated" } return "Ticket status" } @@ -487,7 +437,7 @@ struct TicketsView: View { } if !hasAnyLinearProjectRouting { return [ - InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + InlineCalloutAction(label: "Ticket sync settings", systemImage: "slider.horizontal.3") { openProjectRouting() } ] @@ -495,7 +445,14 @@ struct TicketsView: View { } if normalized.contains("not linked") && linearRouteStatus?.tokenResolved == true { return [ - InlineCalloutAction(label: "Push selected ticket", systemImage: "arrow.up.right") { + InlineCalloutAction( + label: linearCreateHackTicketsEnabled ? "Create in Linear" : "Ticket sync settings", + systemImage: linearCreateHackTicketsEnabled ? "arrow.up.right" : "slider.horizontal.3" + ) { + if !linearCreateHackTicketsEnabled { + openProjectRouting() + return + } guard let selectedTicket else { return } Task { await syncSelectedTicketToLinear(selectedTicket) } } @@ -788,7 +745,7 @@ struct TicketsView: View { .adaptiveToolbarButton() Button("Reconcile now") { - requestBulkSyncConfirmation(.pullLinear) + Task { await syncProjectFromLinear() } } .adaptiveToolbarButtonProminent() .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) @@ -826,7 +783,7 @@ struct TicketsView: View { selectedFilter = .all } .adaptiveToolbarButton() - Button("Refresh") { + Button("Refresh list") { Task { await refreshTickets() } } .adaptiveToolbarButton() @@ -1046,14 +1003,20 @@ struct TicketsView: View { } } + @ViewBuilder private func ticketSyncGuidanceCallout(_ detail: TicketDetailResponse) -> some View { - let guidance = ticketSyncGuidance(for: detail) - return InlineCallout( - tone: guidance.tone, - title: guidance.title, - message: guidance.message, - actions: ticketSyncGuidanceActions(for: detail) - ) + let shouldShowGuidance = + detail.linearSyncReviewState.needsReview || + !detail.ticket.linearSyncUXState.isLinkedToLinear + if shouldShowGuidance { + let guidance = ticketSyncGuidance(for: detail) + InlineCallout( + tone: guidance.tone, + title: guidance.title, + message: guidance.message, + actions: ticketSyncGuidanceActions(for: detail) + ) + } } @ViewBuilder @@ -1090,10 +1053,9 @@ struct TicketsView: View { if !detail.comments.isEmpty { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { - Text("Comments") + Text("Activity") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) - ticketMetaPill("Append-only FIFO", tone: .secondary) } VStack(alignment: .leading, spacing: 10) { ForEach(detail.comments) { comment in @@ -1478,16 +1440,16 @@ struct TicketsView: View { let syncState = ticket.linearSyncUXState if syncState.reviewHint != nil { return [ - InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + InlineCalloutAction(label: "Ticket sync settings", systemImage: "slider.horizontal.3") { openProjectRouting() } ] } if !syncState.isLinkedToLinear { - if linearRouteStatus?.tokenResolved == true { + if canPushTicketToLinear(ticket) { return [ - InlineCalloutAction(label: "Push Hack changes", systemImage: "arrow.up.right") { + InlineCalloutAction(label: "Create in Linear", systemImage: "arrow.up.right") { Task { await syncSelectedTicketToLinear(ticket) } } ] @@ -1496,7 +1458,7 @@ struct TicketsView: View { InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { openLinearSettings() }, - InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + InlineCalloutAction(label: "Ticket sync settings", systemImage: "slider.horizontal.3") { openProjectRouting() }, ] @@ -1504,18 +1466,11 @@ struct TicketsView: View { switch syncState.authority { case .linear: - if let externalKey = ticket.externalKey, !externalKey.isEmpty { - return [ - InlineCalloutAction(label: "Pull Linear changes", systemImage: "arrow.down.left") { - Task { await syncSelectedTicketFromLinear(ticket) } - } - ] - } return [] case .hack: - if linearRouteStatus?.tokenResolved == true { + if !syncState.isLinkedToLinear, canPushTicketToLinear(ticket) { return [ - InlineCalloutAction(label: "Push Hack changes", systemImage: "arrow.up.right") { + InlineCalloutAction(label: "Create in Linear", systemImage: "arrow.up.right") { Task { await syncSelectedTicketToLinear(ticket) } } ] @@ -1531,7 +1486,7 @@ struct TicketsView: View { private func ticketSyncGuidanceActions(for detail: TicketDetailResponse) -> [InlineCalloutAction] { if detail.linearSyncReviewState.needsReview { var actions = [ - InlineCalloutAction(label: "Project routing", systemImage: "slider.horizontal.3") { + InlineCalloutAction(label: "Ticket sync settings", systemImage: "slider.horizontal.3") { openProjectRouting() } ] @@ -1548,41 +1503,24 @@ struct TicketsView: View { return ticketSyncGuidanceActions(for: detail.ticket) } - private func ticketAuthorityBadgeLabel(for ticket: TicketSummary) -> String { - if ticket.linearSyncUXState.reviewHint != nil { - return "Review authority" - } - switch ticket.linearSyncUXState.authority { - case .hack: - return "Hack authority" - case .linear: - return "Linear authority" - } - } - - private func ticketAuthorityBadgeColor(for ticket: TicketSummary) -> Color { - if ticket.linearSyncUXState.reviewHint != nil { - return .orange - } - switch ticket.linearSyncUXState.authority { - case .hack: - return .secondary - case .linear: - return .orange - } - } - private func ticketSyncGuidance(for ticket: TicketSummary) -> TicketSyncGuidance { let syncState = ticket.linearSyncUXState if let reviewHint = syncState.reviewHint { return TicketSyncGuidance( - title: "Review authority before syncing", + title: "Needs sync review", message: "\(syncState.shortGuidance) \(reviewHint)", tone: .warn ) } if !syncState.isLinkedToLinear { + if linearRouteStatus?.tokenResolved == true && !linearCreateHackTicketsEnabled { + return TicketSyncGuidance( + title: "Local ticket", + message: "This ticket stays in Hack until Create Hack tickets in Linear is enabled for this repo.", + tone: .neutral + ) + } return TicketSyncGuidance( title: "Local ticket", message: syncState.shortGuidance, @@ -1593,14 +1531,14 @@ struct TicketsView: View { switch syncState.authority { case .hack: return TicketSyncGuidance( - title: "Hack owns authoritative edits", - message: "\(syncState.shortGuidance) Push when you want to publish local changes outward.", + title: "Linked ticket", + message: "\(syncState.shortGuidance) Linked changes continue syncing in the background.", tone: .neutral ) case .linear: return TicketSyncGuidance( - title: "Linear owns authoritative edits", - message: "\(syncState.shortGuidance) Pull when you want the local ticket to match the linked Linear issue.", + title: "Linked ticket", + message: "\(syncState.shortGuidance) Remote updates keep flowing in automatically.", tone: .neutral ) } @@ -1616,10 +1554,8 @@ struct TicketsView: View { } private func ticketDetailFooter(_ detail: TicketDetailResponse) -> some View { - let prefersPullPrimary = - detail.ticket.linearSyncUXState.authority == .linear && - detail.linearSyncReviewState.needsReview == false && - detail.ticket.externalKey?.isEmpty == false + let canPushThisTicket = canPushTicketToLinear(detail.ticket) + let isLinkedToLinear = detail.ticket.externalKey?.isEmpty == false return VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { @@ -1633,38 +1569,21 @@ struct TicketsView: View { .adaptiveToolbarButton() } - if prefersPullPrimary { - Button { - Task { await syncSelectedTicketToLinear(detail.ticket) } - } label: { - Label("Push Hack changes", systemImage: "arrow.up.right") - } - .adaptiveToolbarButton() - .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) - - Button { - Task { await syncSelectedTicketFromLinear(detail.ticket) } - } label: { - Label("Pull Linear changes", systemImage: "arrow.down.left") - } - .adaptiveToolbarButtonProminent() - .disabled(detail.ticket.externalKey?.isEmpty != false || isAnySyncInFlight) - } else { + if canPushThisTicket && !isLinkedToLinear { Button { Task { await syncSelectedTicketToLinear(detail.ticket) } } label: { - Label("Push Hack changes", systemImage: "arrow.up.right") + Label("Create in Linear", systemImage: "arrow.up.right") } .adaptiveToolbarButtonProminent() - .disabled(linearRouteStatus?.tokenResolved != true || isAnySyncInFlight) - + .disabled(!canPushThisTicket || isAnySyncInFlight) + } else if !isLinkedToLinear { Button { - Task { await syncSelectedTicketFromLinear(detail.ticket) } + openProjectRouting() } label: { - Label("Pull Linear changes", systemImage: "arrow.down.left") + Label("Ticket sync settings", systemImage: "slider.horizontal.3") } .adaptiveToolbarButton() - .disabled(detail.ticket.externalKey?.isEmpty != false || isAnySyncInFlight) } if let externalURL = detail.ticket.externalUrl, @@ -1711,7 +1630,6 @@ struct TicketsView: View { bottomPinnedDisclosure(title: "Properties", isExpanded: $isPropertiesExpanded) { VStack(alignment: .leading, spacing: 14) { - inspectorGroup(title: "Dates", rows: inspectorDateRows(for: detail.ticket)) inspectorGroup(title: "Links", rows: inspectorLinkRows(for: detail.ticket)) inspectorGroup(title: "Dependencies", rows: inspectorDependencyRows(for: detail.ticket)) } @@ -1969,25 +1887,6 @@ struct TicketsView: View { await refreshTickets() } - private func syncTickets() async { - _ = await model.syncTickets(for: project) - await refreshTickets() - } - - private func requestBulkSyncConfirmation(_ action: TicketBulkSyncAction) { - pendingBulkSyncAction = action - } - - private func performBulkSync(_ action: TicketBulkSyncAction) async { - pendingBulkSyncAction = nil - switch action { - case .pullLinear: - await syncProjectFromLinear() - case .pushHack: - await syncProjectToLinear() - } - } - private func runSyncAction( _ action: TicketSyncAction, operation: () async -> Void @@ -2094,7 +1993,7 @@ struct TicketsView: View { return } let routedProjects = result.projectIds?.count ?? (linearAdditionalProjects.isEmpty ? 1 : linearAdditionalProjects.count + 1) - loadNotice = "Pulled \(result.processed) item\(result.processed == 1 ? "" : "s") from \(routedProjects) routed Linear project\(routedProjects == 1 ? "" : "s")." + loadNotice = "Reconciled \(result.processed) ticket\(result.processed == 1 ? "" : "s") against \(routedProjects) Linear project\(routedProjects == 1 ? "" : "s")." await refreshLinearRouting() await refreshTickets() } @@ -2107,7 +2006,12 @@ struct TicketsView: View { if linearSyncFromLinearEnabled, canSyncProjectFromLinear { let autosyncResult = await model.runLinearAutosync(for: project) - if autosyncResult?.ok == true { + if let autosyncResult, + autosyncResult.ok, + autosyncResult.processedDeliveries > 0 || + autosyncResult.created > 0 || + autosyncResult.updated > 0 + { await refreshLinearRouting() await refreshTickets() } @@ -2117,42 +2021,31 @@ struct TicketsView: View { return } - if linearCreateHackTicketsEnabled, canSyncProjectToLinear { - let outboundResult = await model.syncLinearProject( + if canSyncLinkedTicketsToLinear { + let linkedUpdateResult = await model.syncLinearProject( for: project, from: "hack", - ownerMode: "hack", + ownerMode: "linear", projectId: nil, teamId: nil, limit: nil, syncLabels: nil ) - if outboundResult?.ok == true { + if let linkedUpdateResult, + linkedUpdateResult.ok, + linkedUpdateResult.processed > 0 || linkedUpdateResult.updated > 0 + { await refreshLinearRouting() await refreshTickets() } } - } - private func runProjectLinearAutosync() async { - await runSyncAction(.runProjectAutosync) { - loadNotice = nil - let result = await model.runLinearAutosync(for: project) - guard let result, result.ok else { - loadNotice = model.errorMessage ?? "Linear autosync failed." - return - } - loadNotice = - "Processed \(result.processedDeliveries) pending delivery\(result.processedDeliveries == 1 ? "" : "ies") across \(result.subscribedRoutes) subscribed route\(result.subscribedRoutes == 1 ? "" : "s") • applied \(result.appliedDeliveries) • failed \(result.failedDeliveries)." - await refreshLinearRouting() - await refreshTickets() + guard !isAnySyncInFlight else { + return } - } - private func syncProjectToLinear() async { - await runSyncAction(.pushProjectToLinear) { - loadNotice = nil - let result = await model.syncLinearProject( + if linearCreateHackTicketsEnabled, canSyncHackOwnedTicketsToLinear { + let outboundCreateResult = await model.syncLinearProject( for: project, from: "hack", ownerMode: "hack", @@ -2161,13 +2054,13 @@ struct TicketsView: View { limit: nil, syncLabels: nil ) - guard let result, result.ok else { - loadNotice = model.errorMessage ?? "Linear sync failed." - return + if let outboundCreateResult, + outboundCreateResult.ok, + outboundCreateResult.processed > 0 || outboundCreateResult.created > 0 + { + await refreshLinearRouting() + await refreshTickets() } - loadNotice = "Pushed \(result.processed) Hack ticket\(result.processed == 1 ? "" : "s") to Linear." - await refreshLinearRouting() - await refreshTickets() } } @@ -2187,7 +2080,10 @@ struct TicketsView: View { loadNotice = model.errorMessage ?? "Ticket sync failed." return } - loadNotice = "Synced \(result.ticketId) to \(result.issueIdentifier)." + loadNotice = + result.operation == "created" + ? "Created \(result.issueIdentifier) for \(result.ticketId)." + : "Updated \(result.issueIdentifier) from \(result.ticketId)." await refreshLinearRouting() await refreshTickets() } @@ -2213,7 +2109,7 @@ struct TicketsView: View { loadNotice = model.errorMessage ?? "Ticket refresh failed." return } - loadNotice = "Refreshed \(result.ticketId) from \(result.issueIdentifier)." + loadNotice = "Reconciled \(result.ticketId) with \(result.issueIdentifier)." await refreshLinearRouting() await refreshTickets() } @@ -2971,59 +2867,19 @@ private enum TicketOriginFilter: String, CaseIterable { } } -private enum TicketBulkSyncAction: String, Identifiable { - case pullLinear - case pushHack - - var id: String { rawValue } - - var dialogTitle: String { - switch self { - case .pullLinear: - return "Pull Linear issues into tickets?" - case .pushHack: - return "Push Hack tickets to Linear?" - } - } - - var confirmLabel: String { - switch self { - case .pullLinear: - return "Pull from Linear" - case .pushHack: - return "Push to Linear" - } - } - - var message: String { - switch self { - case .pullLinear: - return "Use this when Linear is the source of truth for the project. Linear-owned tickets will refresh local authoritative fields, while comments remain append-only." - case .pushHack: - return "Use this when Hack owns the local ticket set for this project. Hack-owned tickets will publish outward, while assignees, labels, and dependencies merge best effort." - } - } -} - private enum TicketSyncAction: Equatable { case pullProjectFromLinear - case runProjectAutosync - case pushProjectToLinear case pushTicketToLinear(String) case pullTicketFromLinear(String) var progressLabel: String { switch self { case .pullProjectFromLinear: - return "Pulling Linear project…" - case .runProjectAutosync: - return "Running Linear autosync…" - case .pushProjectToLinear: - return "Pushing Hack tickets…" + return "Reconciling Linear…" case let .pushTicketToLinear(ticketId): - return "Pushing \(ticketId)…" + return "Syncing \(ticketId)…" case let .pullTicketFromLinear(ticketId): - return "Refreshing \(ticketId)…" + return "Reconciling \(ticketId)…" } } } From e8f6c08644c9438e9a60e799937b46cb6351dc41 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 9 Mar 2026 16:11:41 -0400 Subject: [PATCH 50/54] Review PR fixes for install issues --- examples/basic/.hack/hack.config.json | 5 +++++ examples/next-app/.hack/hack.config.json | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/examples/basic/.hack/hack.config.json b/examples/basic/.hack/hack.config.json index a46c26d1c..399e87a22 100644 --- a/examples/basic/.hack/hack.config.json +++ b/examples/basic/.hack/hack.config.json @@ -18,6 +18,11 @@ "controlPlane": { "gateway": { "enabled": true + }, + "extensions": { + "dance.hack.linear": { + "enabled": true + } } } } diff --git a/examples/next-app/.hack/hack.config.json b/examples/next-app/.hack/hack.config.json index e5bebfdc6..f55fd5c07 100644 --- a/examples/next-app/.hack/hack.config.json +++ b/examples/next-app/.hack/hack.config.json @@ -14,5 +14,12 @@ "oauth": { "enabled": false, "tld": "gy" + }, + "controlPlane": { + "extensions": { + "dance.hack.linear": { + "enabled": true + } + } } } From 38b47d41fe85d49c67a1f4c599a5203eb08b0340 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 9 Mar 2026 16:19:20 -0400 Subject: [PATCH 51/54] Review doctor host DNS fix --- src/commands/doctor-utils.ts | 23 ++++++- src/commands/doctor.ts | 81 ++++++++++++++++------ src/commands/global.ts | 126 +++++++++++++++++++++++++++-------- src/constants.ts | 1 + tests/doctor-utils.test.ts | 19 ++++++ 5 files changed, 198 insertions(+), 52 deletions(-) diff --git a/src/commands/doctor-utils.ts b/src/commands/doctor-utils.ts index 9a0c53eb0..20b2c45d4 100644 --- a/src/commands/doctor-utils.ts +++ b/src/commands/doctor-utils.ts @@ -1,6 +1,10 @@ import { YAML } from "bun"; -import { DEFAULT_CADDY_IP, DEFAULT_INGRESS_NETWORK } from "../constants.ts"; +import { + DEFAULT_CADDY_IP, + DEFAULT_HOST_DNS_IP, + DEFAULT_INGRESS_NETWORK, +} from "../constants.ts"; import { isRecord, isStringArray } from "../lib/guards.ts"; export type ComposeNetworkHygieneError = @@ -26,10 +30,10 @@ export function dnsmasqConfigHasDomain(opts: { readonly text: string; readonly domain: string; }): boolean { - // Accept container IP (preferred), IPv6, or legacy IPv4 config + // Accept either supported host target plus the legacy IPv6 form. const containerIpLine = `address=/.${opts.domain}/${DEFAULT_CADDY_IP}`; const ipv6Line = `address=/.${opts.domain}/::1`; - const ipv4Line = `address=/.${opts.domain}/127.0.0.1`; + const ipv4Line = `address=/.${opts.domain}/${DEFAULT_HOST_DNS_IP}`; return ( opts.text.includes(containerIpLine) || opts.text.includes(ipv6Line) || @@ -37,6 +41,19 @@ export function dnsmasqConfigHasDomain(opts: { ); } +export function resolvePreferredHostDnsTarget(opts: { + readonly containerIpReachable: boolean; + readonly localhostReachable: boolean; +}): string { + if (opts.containerIpReachable) { + return DEFAULT_CADDY_IP; + } + if (opts.localhostReachable) { + return DEFAULT_HOST_DNS_IP; + } + return DEFAULT_CADDY_IP; +} + export function analyzeComposeNetworkHygiene(opts: { readonly yamlText: string; readonly ingressNetwork?: string; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 37c3bcd45..a50897dc8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { optPath } from "../cli/options.ts"; import { DEFAULT_CADDY_IP, DEFAULT_GRAFANA_HOST, + DEFAULT_HOST_DNS_IP, DEFAULT_INGRESS_GATEWAY, DEFAULT_INGRESS_NETWORK, DEFAULT_INGRESS_SUBNET, @@ -65,6 +66,7 @@ import { isColorEnabled } from "../ui/terminal.ts"; import { analyzeComposeNetworkHygiene, dnsmasqConfigHasDomain, + resolvePreferredHostDnsTarget, resolverHasNameserver, } from "./doctor-utils.ts"; @@ -815,8 +817,6 @@ async function checkMacResolverForDomain(domain: string): Promise { async function checkMacDnsmasqConfigForDomain( domain: string ): Promise { - const desiredLine = `address=/.${domain}/${DEFAULT_CADDY_IP}`; - const brew = await findExecutableInPath("brew"); if (!brew) { return { @@ -846,7 +846,7 @@ async function checkMacDnsmasqConfigForDomain( status: ok ? "ok" : "warn", message: ok ? dnsmasqConf - : `Missing "${desiredLine}" in ${dnsmasqConf} (run: hack global install)`, + : `Missing .${domain} dnsmasq address line in ${dnsmasqConf} (run: hack global install)`, }; } @@ -1898,15 +1898,11 @@ async function maybeMigrateDnsmasq(): Promise { return; } - note( - "dnsmasq migrated to container IP - port forwarding issues resolved", - "doctor" - ); + note("dnsmasq updated to the reachable local ingress target", "doctor"); } /** - * Check if dnsmasq has legacy localhost config and offer to migrate to container IP. - * Using the container IP directly bypasses OrbStack port forwarding issues. + * Reconcile dnsmasq host routing to whichever ingress target is actually reachable. */ async function migrateDnsmasqToContainerIpIfNeeded(): Promise< "migrated" | "skipped" | "not-needed" @@ -1926,25 +1922,28 @@ async function migrateDnsmasqToContainerIpIfNeeded(): Promise< return "skipped"; } - const containerIpHackLine = `address=/.${DEFAULT_PROJECT_TLD}/${DEFAULT_CADDY_IP}`; - const containerIpOauthLine = `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/${DEFAULT_CADDY_IP}`; + const targetIp = await resolvePreferredMacHostDnsTarget(); + const desiredHackLine = `address=/.${DEFAULT_PROJECT_TLD}/${targetIp}`; + const desiredOauthLine = `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/${targetIp}`; + const legacyHostTarget = + targetIp === DEFAULT_CADDY_IP ? DEFAULT_HOST_DNS_IP : DEFAULT_CADDY_IP; const legacyLines = [ - `address=/.${DEFAULT_PROJECT_TLD}/127.0.0.1`, - `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/127.0.0.1`, + `address=/.${DEFAULT_PROJECT_TLD}/${legacyHostTarget}`, + `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/${legacyHostTarget}`, `address=/.${DEFAULT_PROJECT_TLD}/::1`, `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/::1`, ]; - const hasContainerIp = - text.includes(containerIpHackLine) && text.includes(containerIpOauthLine); + const hasDesired = + text.includes(desiredHackLine) && text.includes(desiredOauthLine); const hasLegacy = legacyLines.some((line) => text.includes(line)); - if (hasContainerIp || !hasLegacy) { + if (hasDesired || !hasLegacy) { return "not-needed"; } const okMigrate = await confirm({ - message: "Migrate dnsmasq to container IP? (fixes port forwarding issues)", + message: `Update dnsmasq to use ${targetIp} for host routing?`, initialValue: true, }); if (isCancel(okMigrate)) { @@ -1954,13 +1953,13 @@ async function migrateDnsmasqToContainerIpIfNeeded(): Promise< return "skipped"; } - // Remove legacy lines and add container IP + // Remove the old host target lines and add the newly selected one. let updated = text; for (const legacyLine of legacyLines) { updated = updated.replace(legacyLine, ""); } updated = updated.replace(/\n{3,}/g, "\n\n").trim(); - updated = `${updated}\n${containerIpHackLine}\n${containerIpOauthLine}\n`; + updated = `${updated}\n${desiredHackLine}\n${desiredOauthLine}\n`; await writeTextFileIfChanged(dnsmasqConf, updated); @@ -2211,14 +2210,54 @@ function renderMacNote(): void { note( [ "macOS tip:", - `- wildcard DNS: /etc/resolver/${DEFAULT_PROJECT_TLD} + dnsmasq address=/.${DEFAULT_PROJECT_TLD}/${DEFAULT_CADDY_IP}`, - `- OAuth alias DNS: /etc/resolver/${DEFAULT_OAUTH_ALIAS_ROOT} + dnsmasq address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/${DEFAULT_CADDY_IP}`, + `- wildcard DNS: /etc/resolver/${DEFAULT_PROJECT_TLD} + dnsmasq address=/.${DEFAULT_PROJECT_TLD}/`, + `- OAuth alias DNS: /etc/resolver/${DEFAULT_OAUTH_ALIAS_ROOT} + dnsmasq address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/`, ].join("\n"), "doctor" ); } } +async function resolvePreferredMacHostDnsTarget(): Promise { + const [containerIpReachable, localhostReachable] = await Promise.all([ + canConnectTcp({ host: DEFAULT_CADDY_IP, port: 443, timeoutMs: 1500 }), + canConnectTcp({ host: DEFAULT_HOST_DNS_IP, port: 443, timeoutMs: 1500 }), + ]); + + return resolvePreferredHostDnsTarget({ + containerIpReachable, + localhostReachable, + }); +} + +async function canConnectTcp(opts: { + readonly host: string; + readonly port: number; + readonly timeoutMs: number; +}): Promise { + const { createConnection } = await import("node:net"); + + return await new Promise((resolve) => { + const socket = createConnection({ + host: opts.host, + port: opts.port, + timeout: opts.timeoutMs, + }); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} + // Keep macOS guidance at the end so it doesn't push other output off-screen. // (Called from the command handler.) async function runCheck( diff --git a/src/commands/global.ts b/src/commands/global.ts index fd4361323..08ed8fc9e 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -17,6 +17,7 @@ import { import { DEFAULT_CADDY_IP, DEFAULT_COREDNS_IP, + DEFAULT_HOST_DNS_IP, DEFAULT_INGRESS_GATEWAY, DEFAULT_INGRESS_NETWORK, DEFAULT_INGRESS_SUBNET, @@ -86,6 +87,7 @@ import { display } from "../ui/display.ts"; import { dockerComposeLogsPretty } from "../ui/docker-logs.ts"; import { ensureBundledGumInstalled } from "../ui/gum.ts"; import { logger } from "../ui/logger.ts"; +import { resolvePreferredHostDnsTarget } from "./doctor-utils.ts"; const globalLogsOptions = [optFollow, optNoFollow, optTail, optPretty] as const; const globalLogsPositionals = [{ name: "service", required: false }] as const; @@ -552,21 +554,6 @@ async function globalInstall(): Promise { }); } - if (isMac()) { - await ensureMacHackDns(); - } else { - logger.warn({ - message: "Skipping DNS bootstrap (only implemented for macOS for now).", - }); - note( - [ - "You need wildcard DNS for *.hack pointing to 127.0.0.1.", - "Recommended: dnsmasq + OS resolver config for the 'hack' TLD.", - ].join("\n"), - "DNS setup" - ); - } - const paths = getGlobalPaths(); await ensureDir(paths.caddyDir); await ensureDir(paths.loggingDir); @@ -629,7 +616,20 @@ async function globalInstall(): Promise { await globalUp(); if (isMac()) { + const hostDnsTarget = await resolvePreferredMacHostDnsTarget(); + await ensureMacHackDns({ targetIp: hostDnsTarget }); await ensureMacTrustCaddyLocalCa(); + } else { + logger.warn({ + message: "Skipping DNS bootstrap (only implemented for macOS for now).", + }); + note( + [ + `You need wildcard DNS for *.hack pointing to ${DEFAULT_HOST_DNS_IP}.`, + "Recommended: dnsmasq + OS resolver config for the 'hack' TLD.", + ].join("\n"), + "DNS setup" + ); } note( @@ -2196,7 +2196,9 @@ async function hasMkcertLocalCa({ return (await pathExists(certPath)) && (await pathExists(keyPath)); } -async function ensureMacHackDns(): Promise { +async function ensureMacHackDns(opts: { + readonly targetIp: string; +}): Promise { const brewOk = await ensureBrewForDnsmasq(); if (!brewOk) { return; @@ -2209,11 +2211,11 @@ async function ensureMacHackDns(): Promise { const brewPrefix = await resolveBrewPrefix(); const dnsmasqConf = resolve(brewPrefix, "etc", "dnsmasq.conf"); - await ensureDnsmasqHackAliases({ dnsmasqConf }); + await ensureDnsmasqHackAliases({ dnsmasqConf, targetIp: opts.targetIp }); await ensureMacResolverFiles(); await restartDnsmasq(); await flushMacDnsCache(); - noteDnsConfigured({ dnsmasqConf }); + noteDnsConfigured({ dnsmasqConf, targetIp: opts.targetIp }); } async function ensureBrewForDnsmasq(): Promise { @@ -2273,14 +2275,17 @@ async function resolveBrewPrefix(): Promise { async function ensureDnsmasqHackAliases(opts: { readonly dnsmasqConf: string; + readonly targetIp: string; }): Promise { - const containerIpLines = [ - `address=/.${DEFAULT_PROJECT_TLD}/${DEFAULT_CADDY_IP}`, - `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/${DEFAULT_CADDY_IP}`, + const desiredLines = [ + `address=/.${DEFAULT_PROJECT_TLD}/${opts.targetIp}`, + `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/${opts.targetIp}`, ] as const; + const legacyHostTarget = + opts.targetIp === DEFAULT_CADDY_IP ? DEFAULT_HOST_DNS_IP : DEFAULT_CADDY_IP; const legacyLines = [ - `address=/.${DEFAULT_PROJECT_TLD}/127.0.0.1`, - `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/127.0.0.1`, + `address=/.${DEFAULT_PROJECT_TLD}/${legacyHostTarget}`, + `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/${legacyHostTarget}`, `address=/.${DEFAULT_PROJECT_TLD}/::1`, `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/::1`, ] as const; @@ -2290,7 +2295,7 @@ async function ensureDnsmasqHackAliases(opts: { content: existing, legacyLines, }); - const missing = containerIpLines.filter( + const missing = desiredLines.filter( (line) => !migrated.content.includes(line) ); const shouldWrite = migrated.changed || missing.length > 0; @@ -2331,7 +2336,9 @@ function removeLegacyDnsmasqLines(opts: { // Clean up any double newlines left from removal. const cleaned = updated.replace(/\n{3,}/g, "\n\n").trim(); - logger.info({ message: "Migrating dnsmasq to use container IP..." }); + logger.info({ + message: "Migrating dnsmasq to the reachable local ingress target...", + }); return { content: cleaned, changed: true }; } @@ -2400,11 +2407,16 @@ async function flushMacDnsCache(): Promise { await run(["sudo", "killall", "-HUP", "mDNSResponder"], { stdin: "inherit" }); } -function noteDnsConfigured(opts: { readonly dnsmasqConf: string }): void { +function noteDnsConfigured(opts: { + readonly dnsmasqConf: string; + readonly targetIp: string; +}): void { + const targetLabel = + opts.targetIp === DEFAULT_CADDY_IP ? "container ingress" : "localhost"; note( [ - `DNS configured: *.${DEFAULT_PROJECT_TLD} → ${DEFAULT_CADDY_IP} (container)`, - `DNS configured: *.${DEFAULT_OAUTH_ALIAS_ROOT} → ${DEFAULT_CADDY_IP} (container)`, + `DNS configured: *.${DEFAULT_PROJECT_TLD} → ${opts.targetIp} (${targetLabel})`, + `DNS configured: *.${DEFAULT_OAUTH_ALIAS_ROOT} → ${opts.targetIp} (${targetLabel})`, `- dnsmasq: ${opts.dnsmasqConf}`, `- resolver: /etc/resolver/${DEFAULT_PROJECT_TLD}`, `- resolver: /etc/resolver/${DEFAULT_OAUTH_ALIAS_ROOT}`, @@ -2413,6 +2425,64 @@ function noteDnsConfigured(opts: { readonly dnsmasqConf: string }): void { ); } +async function resolvePreferredMacHostDnsTarget(): Promise { + const [containerIpReachable, localhostReachable] = await Promise.all([ + canConnectTcp({ host: DEFAULT_CADDY_IP, port: 443, timeoutMs: 1500 }), + canConnectTcp({ host: DEFAULT_HOST_DNS_IP, port: 443, timeoutMs: 1500 }), + ]); + + const targetIp = resolvePreferredHostDnsTarget({ + containerIpReachable, + localhostReachable, + }); + + if (!containerIpReachable && localhostReachable) { + logger.warn({ + message: [ + `Host cannot reach ${DEFAULT_CADDY_IP}:443 directly.`, + `Falling back to ${DEFAULT_HOST_DNS_IP} for macOS host DNS.`, + ].join("\n"), + }); + } else if (!(containerIpReachable || localhostReachable)) { + logger.warn({ + message: [ + `Unable to reach either ${DEFAULT_CADDY_IP}:443 or ${DEFAULT_HOST_DNS_IP}:443 after startup.`, + `Keeping ${DEFAULT_CADDY_IP} as the host DNS target.`, + ].join("\n"), + }); + } + + return targetIp; +} + +async function canConnectTcp(opts: { + readonly host: string; + readonly port: number; + readonly timeoutMs: number; +}): Promise { + const { createConnection } = await import("node:net"); + + return await new Promise((resolve) => { + const socket = createConnection({ + host: opts.host, + port: opts.port, + timeout: opts.timeoutMs, + }); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} + async function ensureMacDnsmasqRunning(): Promise { const brew = await findExecutableInPath("brew"); if (!brew) { diff --git a/src/constants.ts b/src/constants.ts index 6b0ec637d..1bffc7df3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,6 +7,7 @@ export const DEFAULT_INGRESS_SUBNET = "172.30.0.0/16" as const; export const DEFAULT_INGRESS_GATEWAY = "172.30.0.1" as const; export const DEFAULT_CADDY_IP = "172.30.0.2" as const; export const DEFAULT_COREDNS_IP = "172.30.0.53" as const; +export const DEFAULT_HOST_DNS_IP = "127.0.0.1" as const; export const DEFAULT_PROJECT_TLD = "hack" as const; export const DEFAULT_GRAFANA_HOST = `logs.${DEFAULT_PROJECT_TLD}` as const; diff --git a/tests/doctor-utils.test.ts b/tests/doctor-utils.test.ts index 85ee3a5f5..2aee82853 100644 --- a/tests/doctor-utils.test.ts +++ b/tests/doctor-utils.test.ts @@ -3,6 +3,7 @@ import { expect, test } from "bun:test"; import { analyzeComposeNetworkHygiene, dnsmasqConfigHasDomain, + resolvePreferredHostDnsTarget, resolverHasNameserver, } from "../src/commands/doctor-utils.ts"; @@ -70,3 +71,21 @@ test("dnsmasqConfigHasDomain detects expected address line", () => { expect(dnsmasqConfigHasDomain({ text, domain: "hack" })).toBe(true); expect(dnsmasqConfigHasDomain({ text, domain: "example" })).toBe(false); }); + +test("resolvePreferredHostDnsTarget keeps container ip when it is reachable", () => { + expect( + resolvePreferredHostDnsTarget({ + containerIpReachable: true, + localhostReachable: true, + }) + ).toBe("172.30.0.2"); +}); + +test("resolvePreferredHostDnsTarget falls back to localhost when container ip is unreachable", () => { + expect( + resolvePreferredHostDnsTarget({ + containerIpReachable: false, + localhostReachable: true, + }) + ).toBe("127.0.0.1"); +}); From d480646d215d1912f4e388dc5cbc7c7aa644f1e0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 9 Mar 2026 17:39:59 -0400 Subject: [PATCH 52/54] Review PR fixes and ticket --- tests/global-command.macos.test.ts | 180 +++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/global-command.macos.test.ts diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts new file mode 100644 index 000000000..ab20b930e --- /dev/null +++ b/tests/global-command.macos.test.ts @@ -0,0 +1,180 @@ +import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { + DEFAULT_CADDY_IP, + DEFAULT_HOST_DNS_IP, + GLOBAL_HACK_DIR_NAME, +} from "../src/constants.ts"; + +const runCalls: string[][] = []; + +let tempDir: string | null = null; +let originalHome: string | undefined; +let originalLogger: string | undefined; +let reachabilityByHost: Record = {}; + +mock.module("@clack/prompts", () => ({ + access: async () => true, + autocompleteMultiselect: async () => [], + cancel: () => {}, + confirm: async () => true, + multiselect: async () => [], + isCancel: () => false, + log: { + error: () => {}, + info: () => {}, + message: () => {}, + success: () => {}, + step: () => {}, + warn: () => {}, + }, + note: () => {}, + password: async () => "", + select: async () => "", + spinner: () => ({ + start: () => {}, + stop: () => {}, + }), + text: async () => "", +})); + +mock.module("node:net", () => ({ + createConnection: (opts: { host: string }) => { + const handlers = new Map void>(); + const reachable = reachabilityByHost[opts.host] ?? false; + + queueMicrotask(() => { + const event = reachable ? "connect" : "error"; + handlers.get(event)?.(); + }); + + return { + destroy: () => {}, + on: (event: string, handler: () => void) => { + handlers.set(event, handler); + }, + }; + }, +})); + +mock.module("../src/lib/shell.ts", () => ({ + exec: async (cmd: readonly string[]) => { + if (cmd[0] === "docker" && cmd[1] === "info") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (cmd[0] === "brew" && cmd[1] === "--prefix") { + return { + exitCode: 0, + stdout: resolve(tempDir ?? "/tmp", "brew-prefix"), + stderr: "", + }; + } + if (cmd[0] === "brew" && cmd[1] === "list" && cmd[2] === "dnsmasq") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (cmd[0] === "docker" && cmd[1] === "network" && cmd[2] === "inspect") { + return { exitCode: 0, stdout: "[]", stderr: "" }; + } + + return { exitCode: 0, stdout: "", stderr: "" }; + }, + execOrThrow: async () => ({ exitCode: 0, stdout: "", stderr: "" }), + run: async (cmd: readonly string[]) => { + runCalls.push([...cmd]); + return 0; + }, + findExecutableInPath: (name?: string) => { + if (name === "hack") { + return "/usr/local/bin/hack"; + } + if (name === "brew") { + return "/opt/homebrew/bin/brew"; + } + return "/usr/bin/mock-bin"; + }, + CommandError: class CommandError extends Error {}, +})); + +mock.module("../src/lib/os.ts", () => ({ + isMac: () => true, + openUrl: async () => 0, +})); + +beforeEach(async () => { + originalHome = process.env.HOME; + originalLogger = process.env.HACK_LOGGER; + tempDir = await mkdtemp(join(tmpdir(), "hack-global-macos-")); + process.env.HOME = tempDir; + process.env.HACK_LOGGER = "console"; + runCalls.length = 0; + reachabilityByHost = {}; +}); + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + process.env.HOME = originalHome; + process.env.HACK_LOGGER = originalLogger; +}); + +afterAll(() => { + mock.restore(); +}); + +async function writeExecutable(path: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, "#!/usr/bin/env sh\nexit 0\n"); + await chmod(path, 0o755); +} + +async function prepareManagedTools(root: string): Promise { + await writeExecutable(join(root, GLOBAL_HACK_DIR_NAME, "bin", "gum")); + await writeExecutable(join(root, GLOBAL_HACK_DIR_NAME, "bin", "mutagen")); + await mkdir(join(root, GLOBAL_HACK_DIR_NAME, "libexec"), { recursive: true }); + await writeFile( + join(root, GLOBAL_HACK_DIR_NAME, "libexec", "mutagen-agents.tar.gz"), + "stub" + ); +} + +async function readDnsmasqConf(root: string): Promise { + const dnsmasqConf = join(root, "brew-prefix", "etc", "dnsmasq.conf"); + return await Bun.file(dnsmasqConf).text(); +} + +test("global install keeps container ip host dns when bridge ip is reachable", async () => { + await prepareManagedTools(tempDir!); + reachabilityByHost = { + [DEFAULT_CADDY_IP]: true, + [DEFAULT_HOST_DNS_IP]: true, + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "install"]); + + expect(code).toBe(0); + expect(await readDnsmasqConf(tempDir!)).toContain( + `address=/.hack/${DEFAULT_CADDY_IP}` + ); +}); + +test("global install falls back to localhost host dns when bridge ip is unreachable", async () => { + await prepareManagedTools(tempDir!); + reachabilityByHost = { + [DEFAULT_CADDY_IP]: false, + [DEFAULT_HOST_DNS_IP]: true, + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "install"]); + + expect(code).toBe(0); + expect(await readDnsmasqConf(tempDir!)).toContain( + `address=/.hack/${DEFAULT_HOST_DNS_IP}` + ); +}); From 065b0c798380a5a94ad8a16ac7b5d0a6c7822a66 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 9 Mar 2026 17:51:34 -0400 Subject: [PATCH 53/54] Review PR fixes and CI issues --- scripts/run-tests-deterministic.ts | 40 ++++++++++++++++++++++-------- tests/linear-auth.test.ts | 2 ++ tests/linear-command-alias.test.ts | 5 ++-- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/scripts/run-tests-deterministic.ts b/scripts/run-tests-deterministic.ts index 58d5e094f..c5a99dacb 100644 --- a/scripts/run-tests-deterministic.ts +++ b/scripts/run-tests-deterministic.ts @@ -1,22 +1,30 @@ import { Glob } from "bun"; -const bootstrapTestPath = "tests/node-workspace-bootstrap.test.ts"; +const isolatedTestPaths = [ + "tests/node-workspace-bootstrap.test.ts", + "tests/nodes-registry.test.ts", +] as const; const maxConcurrencyArg = "--max-concurrency=1"; /** - * Runs the test suite in deterministic batches to avoid Bun mock.module cross-file bleed. - * The bootstrap test must run in a clean process before suites that mock shared modules. + * Runs the test suite in deterministic batches to avoid Bun mock.module/env cross-file bleed. + * Files in isolatedTestPaths must run in their own process before the remaining suite. */ async function runTestsDeterministically(): Promise { const allTests = await collectTestFiles(); const remainingTests = allTests.filter( - (testPath) => testPath !== bootstrapTestPath + (testPath) => + !isolatedTestPaths.includes( + testPath as (typeof isolatedTestPaths)[number] + ) ); - await runBunTest({ - label: "bootstrap workspace", - testPaths: [bootstrapTestPath], - }); + for (const isolatedTestPath of isolatedTestPaths) { + await runBunTest({ + label: testLabel({ testPath: isolatedTestPath }), + testPaths: [isolatedTestPath], + }); + } await runBunTest({ label: "remaining suite", testPaths: remainingTests, @@ -34,13 +42,25 @@ async function collectTestFiles(): Promise { } files.sort((left, right) => left.localeCompare(right)); - if (!files.includes(bootstrapTestPath)) { - throw new Error(`Missing required bootstrap test: ${bootstrapTestPath}`); + for (const isolatedTestPath of isolatedTestPaths) { + if (!files.includes(isolatedTestPath)) { + throw new Error(`Missing required isolated test: ${isolatedTestPath}`); + } } return files; } +function testLabel(opts: { readonly testPath: string }): string { + if (opts.testPath === "tests/node-workspace-bootstrap.test.ts") { + return "bootstrap workspace"; + } + if (opts.testPath === "tests/nodes-registry.test.ts") { + return "nodes registry auth"; + } + return opts.testPath; +} + /** * Executes `bun test` for a deterministic batch and exits on first failure. */ diff --git a/tests/linear-auth.test.ts b/tests/linear-auth.test.ts index 4ccdb0d0e..6b22f4a38 100644 --- a/tests/linear-auth.test.ts +++ b/tests/linear-auth.test.ts @@ -87,6 +87,7 @@ test("saveLinearToken persists refresh metadata in the stored envelope", async ( }); test("resolveLinearToken refreshes expired stored credentials via auth broker", async () => { + const fixedNowMs = Date.parse("2026-03-05T12:30:00.000Z"); const config = createControlPlaneConfig({ profiles: { default: { @@ -110,6 +111,7 @@ test("resolveLinearToken refreshes expired stored credentials via auth broker", const resolved = await resolveLinearToken({ controlPlaneConfig: config, store: memory.store, + nowMs: fixedNowMs, refreshConfig: { baseUrl: "https://auth.hack.broker", fetch: (async (input, init) => { diff --git a/tests/linear-command-alias.test.ts b/tests/linear-command-alias.test.ts index 6e693b40f..7befc1cd6 100644 --- a/tests/linear-command-alias.test.ts +++ b/tests/linear-command-alias.test.ts @@ -24,10 +24,11 @@ test("hack linear alias forwards extension options like --json", async () => { proc.exited, ]); - expect(exitCode).toBe(1); - expect(stderr).not.toContain('Option(s) not valid for "linear": --json'); const payload = JSON.parse(stdout) as { readonly extensionId?: string; + readonly ok?: boolean; }; + expect(exitCode).toBe(payload.ok ? 0 : 1); + expect(stderr).not.toContain('Option(s) not valid for "linear": --json'); expect(payload.extensionId).toBe("dance.hack.linear"); }); From b9d698f73e8b25dde21e4ffc4a6d2e6af88b66f2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 9 Mar 2026 19:12:30 -0400 Subject: [PATCH 54/54] Fix CI failures and update PR --- bun.lock | 240 +++++++++++++++++- services/auth-broker/package.json | 4 +- services/auth-broker/src/better-auth-link.ts | 4 + services/auth-broker/src/better-auth.ts | 2 +- .../src/modules/better-auth/session.ts | 7 +- .../src/modules/linear-connections/plugin.ts | 18 +- .../src/modules/linear-sync-store/plugin.ts | 8 +- .../tests/better-auth-link.test.ts | 19 ++ services/auth-broker/tests/index.test.ts | 22 ++ .../auth-broker/tests/session-auth.test.ts | 38 +++ tests/linear-client.test.ts | 4 +- 11 files changed, 345 insertions(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index 000487f9a..76263be72 100644 --- a/bun.lock +++ b/bun.lock @@ -60,9 +60,9 @@ "name": "@hack/auth-broker", "version": "1.17.0", "dependencies": { - "@better-auth/drizzle-adapter": "^1.3.12", + "@better-auth/drizzle-adapter": "^1.5.4", "@neondatabase/serverless": "^1.0.2", - "better-auth": "^1.3.12", + "better-auth": "^1.5.4", "drizzle-orm": "^0.44.5", "elysia": "^1.4.9", }, @@ -87,9 +87,17 @@ "@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.3", "", { "peerDependencies": { "@better-auth/core": "1.5.3", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" } }, "sha512-dib9V1vpwDu+TKLC+L+8Q5bLNS0uE3JCT4pGotw52pnpiQF8msoMK4eEfri19f8DtNltpb2F2yzyIsTugBBYNQ=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" } }, "sha512-4M4nMAWrDd3TmpV6dONkJjybBVKRZghe5Oj0NNyDEoXubxastQdO7Sb5B54I1rTx5yoMgsqaB+kbJnu/9UgjQg=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-DPww7rIfz6Ed7dZlJSW9xMQ42VKaJLB5Cs+pPqd+UHKRyighKjf3VgvMIcAdFPc4olQ0qRHo3+ZJhFlBCxRhxA=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0" } }, "sha512-iiWYut9rbQqiAsgRBtj6+nxanwjapxRgpIJbiS2o81h7b9iclE0AiDA0Foes590gdFQvskNauZcCpuF8ytxthg=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-ArzJN5Obk6i6+vLK1HpPzLIcsjxZYXPPUvxVU8eyU5HyoUT2MlswWfPQ8UJAKPn0iq/T4PVp/wZcQMhWk1tuNA=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-ZQTbcBopw/ezjjbNFsfR3CRp0QciC4tJCarAnB5G9fZtUYbDjfY0vZOxIRmU4kI3x755CXQpGqTrkwmXaMRa3w=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.5.4", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.4" } }, "sha512-mGXTY7Ecxo7uvlMr6TFCBUvlH0NUMOeE9LKgPhG4HyhBN6VfCEg/DD9PG0Z2IatmMWQbckkt7ox5A0eBpG9m5w=="], "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], @@ -115,6 +123,14 @@ "@charmland/lipgloss": ["@charmland/lipgloss@2.0.0-beta.3-0e280f3", "", {}, "sha512-F2J7CCl6srSbtlIzJEELtWCq3yVgTQ9pi9JYqR8pcss45as9xGQQdMzJ2QbcKH+Lv8IbAbmtT5ryE4thjGUbDA=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], + + "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], + + "@chevrotain/types": ["@chevrotain/types@10.5.0", "", {}, "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A=="], + + "@chevrotain/utils": ["@chevrotain/utils@10.5.0", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="], + "@clack/core": ["@clack/core@1.0.0-alpha.7", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-3vdh6Ar09D14rVxJZIm3VQJkU+ZOKKT5I5cC0cOVazy70CNyYYjiwRj9unwalhESndgxx6bGc/m6Hhs4EKF5XQ=="], "@clack/prompts": ["@clack/prompts@1.0.0-alpha.9", "", { "dependencies": { "@clack/core": "1.0.0-alpha.7", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-sKs0UjiHFWvry4SiRfBi5Qnj0C/6AYx8aKkFPZQSuUZXgAram25ZDmhQmP7vj1aFyLpfHWtLQjWvOvcat0TOLg=="], @@ -159,6 +175,12 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], + + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], @@ -287,6 +309,10 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], + "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="], + + "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], + "@neondatabase/serverless": ["@neondatabase/serverless@1.0.2", "", { "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" } }, "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw=="], "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], @@ -335,6 +361,28 @@ "@pnpm/npm-conf": ["@pnpm/npm-conf@2.3.1", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw=="], + "@prisma/client": ["@prisma/client@7.4.2", "", { "dependencies": { "@prisma/client-runtime-utils": "7.4.2" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA=="], + + "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.4.2", "", {}, "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q=="], + + "@prisma/config": ["@prisma/config@7.4.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg=="], + + "@prisma/debug": ["@prisma/debug@7.4.2", "", {}, "sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w=="], + + "@prisma/dev": ["@prisma/dev@0.20.0", "", { "dependencies": { "@electric-sql/pglite": "0.3.15", "@electric-sql/pglite-socket": "0.0.20", "@electric-sql/pglite-tools": "0.2.20", "@hono/node-server": "1.19.9", "@mrleebo/prisma-ast": "0.13.1", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "hono": "4.11.4", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.2.0", "zeptomatch": "2.1.0" } }, "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ=="], + + "@prisma/engines": ["@prisma/engines@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2", "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/fetch-engine": "7.4.2", "@prisma/get-platform": "7.4.2" } }, "sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA=="], + + "@prisma/engines-version": ["@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "", {}, "sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2", "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/get-platform": "7.4.2" } }, "sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA=="], + + "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], + + "@prisma/studio-core": ["@prisma/studio-core@0.13.1", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@semantic-release/changelog": ["@semantic-release/changelog@6.0.3", "", { "dependencies": { "@semantic-release/error": "^3.0.0", "aggregate-error": "^3.0.0", "fs-extra": "^11.0.0", "lodash": "^4.17.4" }, "peerDependencies": { "semantic-release": ">=18.0.0" } }, "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag=="], @@ -377,6 +425,12 @@ "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], + + "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], + "@webgpu/types": ["@webgpu/types@0.1.68", "", {}, "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA=="], "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], @@ -413,15 +467,17 @@ "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="], + "better-auth": ["better-auth@1.5.4", "", { "dependencies": { "@better-auth/core": "1.5.4", "@better-auth/drizzle-adapter": "1.5.4", "@better-auth/kysely-adapter": "1.5.4", "@better-auth/memory-adapter": "1.5.4", "@better-auth/mongo-adapter": "1.5.4", "@better-auth/prisma-adapter": "1.5.4", "@better-auth/telemetry": "1.5.4", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg=="], - "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -435,6 +491,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -455,6 +513,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "cachedir": ["cachedir@2.3.0", "", {}, "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -471,6 +531,10 @@ "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], + "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], @@ -501,8 +565,12 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -537,6 +605,8 @@ "crypto-random-string": ["crypto-random-string@4.0.0", "", { "dependencies": { "type-fest": "^1.0.1" } }, "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cz-conventional-changelog": ["cz-conventional-changelog@3.3.0", "", { "dependencies": { "chalk": "^2.4.1", "commitizen": "^4.0.3", "conventional-commit-types": "^3.0.0", "lodash.map": "^4.5.1", "longest": "^2.0.1", "word-wrap": "^1.0.3" }, "optionalDependencies": { "@commitlint/load": ">6.1.1" } }, "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw=="], "dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], @@ -551,12 +621,18 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + "detect-file": ["detect-file@1.0.0", "", {}, "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], @@ -569,6 +645,8 @@ "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], @@ -579,12 +657,16 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], + "elysia": ["elysia@1.4.25", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-liKjavH99Gpzrv9cDil6uYWmPuqESfPFV1FIaFSd3iNqo3y7e29sN43VxFIK8tWWnyi6eDAmi2SZk8hNAMQMyg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "env-ci": ["env-ci@11.2.0", "", { "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" } }, "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA=="], @@ -633,8 +715,12 @@ "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -665,6 +751,8 @@ "findup-sync": ["findup-sync@4.0.0", "", { "dependencies": { "detect-file": "^1.0.0", "is-glob": "^4.0.0", "micromatch": "^4.0.2", "resolve-dir": "^1.0.1" } }, "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -679,12 +767,16 @@ "function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], @@ -693,6 +785,8 @@ "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], "git-raw-commits": ["git-raw-commits@4.0.0", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="], @@ -709,6 +803,10 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], + + "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -731,6 +829,8 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -785,6 +885,8 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-text-path": ["is-text-path@2.0.0", "", { "dependencies": { "text-extensions": "^2.0.0" } }, "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw=="], @@ -831,6 +933,8 @@ "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], @@ -871,10 +975,14 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest": ["longest@2.0.1", "", {}, "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q=="], "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "make-asynchronous": ["make-asynchronous@1.0.1", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "1.2.0" } }, "sha512-T9BPOmEOhp6SmV25SwLVcHK4E6JyG/coH3C6F1NjNXSziv/fd4GmsqMk8YR6qpPOswfaOCApSNkZv6fxoaYFcQ=="], "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -887,6 +995,8 @@ "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], + "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "merge": ["merge@2.1.1", "", {}, "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w=="], @@ -911,13 +1021,21 @@ "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="], + + "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + + "nanostores": ["nanostores@1.1.1", "", {}, "sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -927,6 +1045,8 @@ "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], "normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="], @@ -941,6 +1061,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1013,6 +1135,8 @@ "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="], @@ -1031,10 +1155,14 @@ "pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="], + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], @@ -1049,14 +1177,22 @@ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "prisma": ["prisma@7.4.2", "", { "dependencies": { "@prisma/config": "7.4.2", "@prisma/dev": "0.20.0", "@prisma/engines": "7.4.2", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], @@ -1069,6 +1205,12 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], "read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], @@ -1077,8 +1219,14 @@ "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="], + "registry-auth-token": ["registry-auth-token@5.1.0", "", { "dependencies": { "@pnpm/npm-conf": "^2.1.0" } }, "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw=="], + "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1093,6 +1241,8 @@ "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -1107,6 +1257,8 @@ "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semantic-release": ["semantic-release@25.0.2", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.1.1", "@semantic-release/release-notes-generator": "^14.1.0", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^9.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^12.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^18.0.0" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -1117,11 +1269,13 @@ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], - "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -1151,6 +1305,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="], + "spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="], "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], @@ -1163,10 +1319,14 @@ "split2": ["split2@1.0.0", "", { "dependencies": { "through2": "~2.0.0" } }, "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg=="], + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + "stage-js": ["stage-js@1.0.0-alpha.17", "", {}, "sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "stream-combiner2": ["stream-combiner2@1.1.1", "", { "dependencies": { "duplexer2": "~0.1.0", "readable-stream": "^2.0.2" } }, "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1223,6 +1383,8 @@ "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + "traverse": ["traverse@0.6.8", "", {}, "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA=="], "trpc-cli": ["trpc-cli@0.12.2", "", { "dependencies": { "commander": "^14.0.0" }, "peerDependencies": { "@orpc/server": "^1.0.0", "@trpc/server": "^10.45.2 || ^11.0.1", "@valibot/to-json-schema": "^1.1.0", "effect": "^3.14.2 || ^4.0.0", "valibot": "^1.1.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["@orpc/server", "@trpc/server", "@valibot/to-json-schema", "effect", "valibot", "zod"], "bin": { "trpc-cli": "dist/bin.js" } }, "sha512-kGNCiyOimGlfcZFImbWzFF2Nn3TMnenwUdyuckiN5SEaceJbIac7+Iau3WsVHjQpoNgugFruZMDOKf8GNQNtJw=="], @@ -1277,6 +1439,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1287,6 +1451,10 @@ "web-worker": ["web-worker@1.2.0", "", {}, "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], @@ -1319,12 +1487,28 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@better-auth/core/better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + + "@better-auth/kysely-adapter/@better-auth/core": ["@better-auth/core@1.5.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ=="], + + "@better-auth/memory-adapter/@better-auth/core": ["@better-auth/core@1.5.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ=="], + + "@better-auth/mongo-adapter/@better-auth/core": ["@better-auth/core@1.5.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ=="], + + "@better-auth/prisma-adapter/@better-auth/core": ["@better-auth/core@1.5.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ=="], + + "@better-auth/telemetry/@better-auth/core": ["@better-auth/core@1.5.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ=="], + + "@better-auth/telemetry/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + "@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@commitlint/load/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -1385,6 +1569,14 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], + + "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], + + "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + "@semantic-release/exec/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="], "@semantic-release/git/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1401,6 +1593,14 @@ "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + "better-auth/@better-auth/core": ["@better-auth/core@1.5.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ=="], + + "better-auth/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + + "better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "better-call/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -1425,6 +1625,8 @@ "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "giget/citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "git-raw-commits/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "git-raw-commits/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -1779,6 +1981,8 @@ "pkg-conf/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "rc/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1823,6 +2027,26 @@ "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "@better-auth/kysely-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + + "@better-auth/kysely-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@better-auth/memory-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + + "@better-auth/memory-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@better-auth/mongo-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + + "@better-auth/mongo-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@better-auth/prisma-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + + "@better-auth/prisma-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@better-auth/telemetry/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@commitlint/parse/conventional-commits-parser/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "@commitlint/parse/conventional-commits-parser/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], diff --git a/services/auth-broker/package.json b/services/auth-broker/package.json index b7ba9e52d..22ea716e9 100644 --- a/services/auth-broker/package.json +++ b/services/auth-broker/package.json @@ -13,9 +13,9 @@ "auth:migrate": "bunx @better-auth/cli@latest migrate --config src/better-auth.ts" }, "dependencies": { - "@better-auth/drizzle-adapter": "^1.3.12", + "@better-auth/drizzle-adapter": "^1.5.4", "@neondatabase/serverless": "^1.0.2", - "better-auth": "^1.3.12", + "better-auth": "^1.5.4", "drizzle-orm": "^0.44.5", "elysia": "^1.4.9" }, diff --git a/services/auth-broker/src/better-auth-link.ts b/services/auth-broker/src/better-auth-link.ts index 9d3463187..7d2f09bee 100644 --- a/services/auth-broker/src/better-auth-link.ts +++ b/services/auth-broker/src/better-auth-link.ts @@ -7,6 +7,7 @@ import type { OAuthFlowAccount } from "./types.ts"; export type BetterAuthUserLinkState = | "disabled" + | "email_not_verified" | "missing_email" | "linked_existing" | "created_new" @@ -69,6 +70,9 @@ async function resolveBetterAuthUserFromOAuthAccount(input: { if (!accountEmail) { return { state: "missing_email" }; } + if (input.account.accountEmailVerified === false) { + return { state: "email_not_verified" }; + } try { if (accountId) { diff --git a/services/auth-broker/src/better-auth.ts b/services/auth-broker/src/better-auth.ts index 90f5c9b86..1991b4682 100644 --- a/services/auth-broker/src/better-auth.ts +++ b/services/auth-broker/src/better-auth.ts @@ -112,7 +112,7 @@ export function createBetterAuthRuntimeFromEnv(): BetterAuthRuntime { return { enabled: true, - auth, + auth: auth as unknown as BetterAuthInstance, db, ready, socialProviders: socialProviderConfig.socialProviders, diff --git a/services/auth-broker/src/modules/better-auth/session.ts b/services/auth-broker/src/modules/better-auth/session.ts index 5a68cd9ee..fe0b1cea7 100644 --- a/services/auth-broker/src/modules/better-auth/session.ts +++ b/services/auth-broker/src/modules/better-auth/session.ts @@ -185,15 +185,18 @@ export function hasBetterAuthProfileAccess(input: { readonly session: BrokerBetterAuthSession | null; readonly profileId?: string | null; }): boolean { + if (!input.session) { + return false; + } const managementTokenProfileId = normalizeOptionalString( - input.session?.managementTokenProfileId + input.session.managementTokenProfileId ); if (!managementTokenProfileId) { return true; } const requestedProfileId = normalizeOptionalString(input.profileId); if (!requestedProfileId) { - return true; + return false; } return requestedProfileId === managementTokenProfileId; } diff --git a/services/auth-broker/src/modules/linear-connections/plugin.ts b/services/auth-broker/src/modules/linear-connections/plugin.ts index 7e5e80751..82c65089a 100644 --- a/services/auth-broker/src/modules/linear-connections/plugin.ts +++ b/services/auth-broker/src/modules/linear-connections/plugin.ts @@ -50,10 +50,14 @@ export function createLinearConnectionsPlugin({ error: "better_auth_session_required", } as const; } + const profileId = + normalizeOptionalQueryValue(query.profileId) ?? + session.session?.managementTokenProfileId ?? + null; if ( !hasBetterAuthProfileAccess({ session: session.session, - profileId: normalizeOptionalQueryValue(query.profileId), + profileId, }) ) { set.status = 403; @@ -64,7 +68,7 @@ export function createLinearConnectionsPlugin({ } const activeSession = session.session; const allConnections = await connectionStore.listConnections({ - profileId: normalizeOptionalQueryValue(query.profileId), + profileId, organizationId: normalizeOptionalQueryValue(query.organizationId), }); const connections = @@ -95,7 +99,10 @@ export function createLinearConnectionsPlugin({ set.status = 401; return { ok: false, error: "better_auth_session_required" } as const; } - const profileId = normalizeOptionalQueryValue(body.profileId); + const profileId = + normalizeOptionalQueryValue(body.profileId) ?? + session.session?.managementTokenProfileId ?? + null; if ( !hasBetterAuthProfileAccess({ session: session.session, @@ -162,7 +169,10 @@ export function createLinearConnectionsPlugin({ set.status = 401; return { ok: false, error: "better_auth_session_required" } as const; } - const profileId = normalizeOptionalQueryValue(body.profileId); + const profileId = + normalizeOptionalQueryValue(body.profileId) ?? + session.session?.managementTokenProfileId ?? + null; if ( !hasBetterAuthProfileAccess({ session: session.session, diff --git a/services/auth-broker/src/modules/linear-sync-store/plugin.ts b/services/auth-broker/src/modules/linear-sync-store/plugin.ts index 16635403f..4b766befc 100644 --- a/services/auth-broker/src/modules/linear-sync-store/plugin.ts +++ b/services/auth-broker/src/modules/linear-sync-store/plugin.ts @@ -67,10 +67,14 @@ export function createLinearSyncStorePlugin({ error: "better_auth_session_required", } as const; } + const profileId = + normalizeOptionalQueryValue({ value: query.profileId }) ?? + session.session?.managementTokenProfileId ?? + null; if ( !hasBetterAuthProfileAccess({ session: session.session, - profileId: normalizeOptionalQueryValue({ value: query.profileId }), + profileId, }) ) { set.status = 403; @@ -83,7 +87,7 @@ export function createLinearSyncStorePlugin({ const allDeliveries = await syncStore.listWebhookDeliveries({ status: statusResult.status, - profileId: normalizeOptionalQueryValue({ value: query.profileId }), + profileId, projectId: normalizeOptionalQueryValue({ value: query.projectId }), teamId: normalizeOptionalQueryValue({ value: query.teamId }), }); diff --git a/services/auth-broker/tests/better-auth-link.test.ts b/services/auth-broker/tests/better-auth-link.test.ts index 6bb16af5c..6afe8bea8 100644 --- a/services/auth-broker/tests/better-auth-link.test.ts +++ b/services/auth-broker/tests/better-auth-link.test.ts @@ -43,6 +43,7 @@ describe("better-auth github account linking", () => { }, account: { accountEmail: "linear@example.com", + accountEmailVerified: true, accountName: "Linear User", }, autoProvision: false, @@ -51,4 +52,22 @@ describe("better-auth github account linking", () => { expect(result.state).toBe("linked_existing"); expect(result.userId).toBe("user-linear-1"); }); + + test("rejects unverified provider emails before linking or provisioning", async () => { + const result = await resolveBetterAuthUserFromLinearAccount({ + runtime: { + enabled: true, + db: createBetterAuthDb([{ id: "user-linear-1" }]), + }, + account: { + accountEmail: "linear@example.com", + accountEmailVerified: false, + accountName: "Linear User", + }, + autoProvision: true, + }); + + expect(result.state).toBe("email_not_verified"); + expect(result.userId).toBeUndefined(); + }); }); diff --git a/services/auth-broker/tests/index.test.ts b/services/auth-broker/tests/index.test.ts index 0d37f432f..08580687e 100644 --- a/services/auth-broker/tests/index.test.ts +++ b/services/auth-broker/tests/index.test.ts @@ -1038,10 +1038,32 @@ describe("auth broker github flow routes", () => { test("linear pending deliveries route lists pending deliveries with scope filters", async () => { const syncStore: LinearSyncStore = new InMemoryLinearSyncStore(); + const session = { + session: { + id: "sess-team-a", + userId: "user-a", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + token: "token-team-a", + }, + user: { + id: "user-a", + email: "user-a@example.com", + emailVerified: true, + name: "User A", + }, + } as NonNullable; const app = createAuthBrokerApp({ config: createTestConfig(), flowStore: new FlowStore(), linearSyncStore: syncStore, + betterAuthRuntime: createBetterAuthRuntimeWithSession( + withActiveTeam(session, { + organizationId: "org-a", + teamId: "team-a", + }) + ), }); const matchingDelivery = await syncStore.recordWebhookDelivery({ diff --git a/services/auth-broker/tests/session-auth.test.ts b/services/auth-broker/tests/session-auth.test.ts index 5f76a49cf..9e3a5ca23 100644 --- a/services/auth-broker/tests/session-auth.test.ts +++ b/services/auth-broker/tests/session-auth.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import type { BetterAuthRuntime } from "../src/better-auth.ts"; import { FlowStore } from "../src/flow-store.ts"; import { createAuthBrokerApp } from "../src/index.ts"; +import { hasBetterAuthProfileAccess } from "../src/modules/better-auth/session.ts"; type BetterAuthAuth = NonNullable; type BetterAuthSession = Awaited< @@ -104,6 +105,43 @@ async function withManagementTokenSecret( } describe("broker Hack session auth flow", () => { + test("profile access fails closed when session or requested profile is missing", () => { + expect( + hasBetterAuthProfileAccess({ + session: null, + profileId: "work", + }) + ).toBe(false); + + expect( + hasBetterAuthProfileAccess({ + session: { + userId: "user-123", + email: null, + name: null, + organizationId: null, + teamId: null, + managementTokenProfileId: "work", + }, + profileId: null, + }) + ).toBe(false); + + expect( + hasBetterAuthProfileAccess({ + session: { + userId: "user-123", + email: null, + name: null, + organizationId: null, + teamId: null, + managementTokenProfileId: "work", + }, + profileId: "work", + }) + ).toBe(true); + }); + test("session start exposes configured social providers", async () => { const app = createAuthBrokerApp({ config: createTestConfig(), diff --git a/tests/linear-client.test.ts b/tests/linear-client.test.ts index e776c3b51..935413c30 100644 --- a/tests/linear-client.test.ts +++ b/tests/linear-client.test.ts @@ -119,8 +119,8 @@ test("getViewer sends bearer authorization for oauth tokens", async () => { const result = await client.getViewer(); expect(result.ok).toBe(true); - expect(authorization).toBe("Bearer linear-oauth-token"); - expect(requestedUrl).toBe("https://api.linear.app/graphql"); + expect(authorization ?? "").toBe("Bearer linear-oauth-token"); + expect(requestedUrl ?? "").toBe("https://api.linear.app/graphql"); }); test("listProjects queries the top-level projects connection", async () => {