diff --git a/README.md b/README.md index d93bb49..8e78fab 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A fast, lightweight, native macOS Git client built entirely with SwiftUI and App - **Reflog and submodule support** - **FSEvents auto-refresh** -- your view updates automatically when files change on disk - **Check for Updates** -- stay current via the built-in update checker +- **AI assistant (optional)** -- describe Git tasks in plain English and let an AI carry them out, or generate conventional-commit messages from your staged diff. Supports Anthropic and OpenAI. Requires your own API key; can be hidden entirely via Settings > AI ## Requirements diff --git a/Sources/Gitify/Model/AI/AIAssistantViewModel.swift b/Sources/Gitify/Model/AI/AIAssistantViewModel.swift new file mode 100644 index 0000000..6091011 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIAssistantViewModel.swift @@ -0,0 +1,210 @@ +import Foundation +import GitKit + +/// A single message displayed in the AI assistant chat. +struct ChatMessage: Identifiable { + enum Role { case user, assistant, toolCall, toolResult } + + let id = UUID() + let role: Role + var text: String + /// For tool-call messages: the tool name. + var toolName: String? + /// For tool-result messages: the result content. + var toolResultContent: String? + let timestamp = Date() +} + +/// Manages the AI assistant conversation, including streaming responses and the tool-use +/// loop. Each instance is scoped to a single repository. +@MainActor +@Observable +final class AIAssistantViewModel { + var messages: [ChatMessage] = [] + var inputText = "" + private(set) var isStreaming = false + private(set) var error: String? + + private let viewModel: RepositoryViewModel + private let toolExecutor: AIToolExecutor + /// The full message history sent to the API (not the same as `messages`, which is for display). + private var conversationHistory: [AIMessage] = [] + private var streamTask: Task? + + /// Maximum consecutive tool-call rounds before stopping. + private static let maxToolRounds = 10 + + init(viewModel: RepositoryViewModel) { + self.viewModel = viewModel + self.toolExecutor = AIToolExecutor(viewModel: viewModel) + } + + /// Sends the current input as a user message and streams the AI response. + func send() { + let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isStreaming else { return } + inputText = "" + error = nil + + messages.append(ChatMessage(role: .user, text: text)) + conversationHistory.append(.user(text)) + + streamTask = Task { await streamResponse() } + } + + /// Cancels the in-flight stream. + func stop() { + streamTask?.cancel() + streamTask = nil + isStreaming = false + } + + /// Clears the conversation. + func clear() { + stop() + messages.removeAll() + conversationHistory.removeAll() + error = nil + } + + // MARK: - Streaming + tool-use loop + + private func streamResponse() async { + guard let client = makeClient() else { + error = "No API key configured. Open Settings \u{2192} AI to add one." + return + } + + guard let model = AIDefaults.model else { + error = "No model selected. Open Settings \u{2192} AI to choose one." + return + } + let systemPrompt = buildSystemPrompt() + let tools = AIToolDefinitions.all + + isStreaming = true + defer { isStreaming = false } + + var toolRounds = 0 + + // Loop: stream a response, execute any tool calls, re-send with results. + while toolRounds < Self.maxToolRounds { + // The assistant message is created lazily on the first text delta, + // so tool-only responses don't leave an empty bubble. + var assistantIndex: Int? + var accumulatedText = "" + var pendingToolCalls: [AIToolCall] = [] + + do { + let stream = client.stream( + messages: conversationHistory, + systemPrompt: systemPrompt, + tools: tools, + model: model.id + ) + + for try await event in stream { + if Task.isCancelled { return } + switch event { + case .textDelta(let delta): + accumulatedText += delta + if let idx = assistantIndex { + messages[idx].text = accumulatedText + } else { + assistantIndex = messages.count + messages.append(ChatMessage(role: .assistant, text: accumulatedText)) + } + case .toolCall(let call): + pendingToolCalls.append(call) + case .done: + break + } + } + } catch { + if !Task.isCancelled { + self.error = error.localizedDescription + } + return + } + + // If no tool calls, we're done. + if pendingToolCalls.isEmpty { + // Record the assistant's text in conversation history. + if !accumulatedText.isEmpty { + conversationHistory.append(.assistant(accumulatedText)) + } + return + } + + // Record the assistant's response (text + tool calls) in history. + conversationHistory.append(.assistantToolCalls(pendingToolCalls, text: accumulatedText)) + + // Execute each tool call and send results back. + for call in pendingToolCalls { + // Show the tool call in the chat. + messages.append(ChatMessage(role: .toolCall, text: call.name, toolName: call.name)) + + let result = await toolExecutor.execute(name: call.name, arguments: call.arguments) + + // Show the tool result. + messages.append(ChatMessage(role: .toolResult, text: result, + toolName: call.name, toolResultContent: result)) + + // Add to conversation history. + conversationHistory.append(.toolResult(callID: call.id, content: result)) + } + + toolRounds += 1 + } + + // If we hit the cap, let the user know. + if toolRounds >= Self.maxToolRounds { + messages.append(ChatMessage(role: .assistant, + text: "Reached the maximum number of tool calls (\(Self.maxToolRounds)). Please continue the conversation if more steps are needed.")) + } + } + + // MARK: - Helpers + + private func makeClient() -> (any AIClient)? { + let provider = AIDefaults.provider + guard let key = AIDefaults.apiKey(for: provider), !key.isEmpty else { return nil } + switch provider { + case .anthropic: return AnthropicClient(apiKey: key) + case .openAI: return OpenAIClient(apiKey: key) + } + } + + private func buildSystemPrompt() -> String { + var prompt = """ + You are a Git expert assistant integrated into Gitify, a macOS Git client. \ + You help the user with Git operations on their repository. + + You have access to tools that let you read repository state and perform Git operations. \ + Always use the tools to check the current state before making changes. \ + For destructive operations (merge, rebase, reset, force push), explain what you are \ + about to do and why before executing the tool call. + + Keep responses concise and focused on the Git task at hand. + """ + + // Add current repo context if available. + if let status = viewModel.status { + prompt += "\n\nCurrent repository context:" + prompt += "\n- Branch: \(status.branch ?? "(detached HEAD)")" + if let upstream = status.upstream { + prompt += "\n- Upstream: \(upstream)" + if status.ahead > 0 { prompt += " (ahead \(status.ahead))" } + if status.behind > 0 { prompt += " (behind \(status.behind))" } + } + let changeCount = status.files.count + if changeCount > 0 { + prompt += "\n- \(changeCount) changed file\(changeCount == 1 ? "" : "s") in working tree" + } else { + prompt += "\n- Working tree clean" + } + } + + return prompt + } +} diff --git a/Sources/Gitify/Model/AI/AIClient.swift b/Sources/Gitify/Model/AI/AIClient.swift new file mode 100644 index 0000000..0baebf9 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIClient.swift @@ -0,0 +1,114 @@ +import Foundation + +/// A tool call requested by the AI. +struct AIToolCall: Sendable { + let id: String + let name: String + let arguments: String // JSON string +} + +/// Events emitted while streaming an AI response. +enum AIStreamEvent: Sendable { + /// A fragment of assistant text. + case textDelta(String) + /// The AI is requesting a tool call. The full arguments JSON is assembled from deltas + /// before this event fires. + case toolCall(AIToolCall) + /// The response is complete (no more events). + case done +} + +/// Role of a message in the conversation. +enum AIRole: String, Codable, Sendable { + case system + case user + case assistant + case tool +} + +/// A single content block within a message. +enum AIContentBlock: Sendable { + case text(String) + case toolCall(AIToolCall) + case toolResult(toolCallID: String, content: String) +} + +/// A message in the conversation history, suitable for sending to either provider. +struct AIMessage: Sendable { + let role: AIRole + let content: [AIContentBlock] + + static func user(_ text: String) -> AIMessage { + AIMessage(role: .user, content: [.text(text)]) + } + + static func assistant(_ text: String) -> AIMessage { + AIMessage(role: .assistant, content: [.text(text)]) + } + + static func assistantToolCalls(_ calls: [AIToolCall], text: String = "") -> AIMessage { + var blocks: [AIContentBlock] = [] + if !text.isEmpty { blocks.append(.text(text)) } + blocks.append(contentsOf: calls.map { .toolCall($0) }) + return AIMessage(role: .assistant, content: blocks) + } + + static func toolResult(callID: String, content: String) -> AIMessage { + AIMessage(role: .tool, content: [.toolResult(toolCallID: callID, content: content)]) + } +} + +/// A tool definition to expose to the AI. Marked `@unchecked Sendable` because the +/// parameters dictionary is set once at init and never mutated. +struct AIToolDefinition: @unchecked Sendable { + let name: String + let description: String + /// JSON Schema object describing the parameters (as a dictionary). + let parameters: [String: Any] + + /// Converts to the Anthropic tool format. + func anthropicJSON() -> [String: Any] { + ["name": name, "description": description, "input_schema": parameters] + } + + /// Converts to the OpenAI function-calling format. + func openAIJSON() -> [String: Any] { + [ + "type": "function", + "function": [ + "name": name, + "description": description, + "parameters": parameters, + ] as [String: Any], + ] + } +} + +/// Protocol for AI provider clients. Implementations must be `Sendable` for use with +/// Swift 6 strict concurrency. +protocol AIClient: Sendable { + /// Sends a conversation and streams back events. The caller is responsible for the + /// tool-use loop (executing tool calls and re-sending with results). + func stream( + messages: [AIMessage], + systemPrompt: String, + tools: [AIToolDefinition], + model: String + ) -> AsyncThrowingStream +} + +// MARK: - SSE line parsing + +/// Extracts the payload from a Server-Sent Events `data:` line. +/// Returns nil for comment lines, empty lines, or non-data fields. +enum SSEParser { + static func dataPayload(from line: String) -> String? { + guard line.hasPrefix("data:") else { return nil } + let payload = line.dropFirst(5) + // Trim a single leading space per the SSE spec. + if payload.hasPrefix(" ") { + return String(payload.dropFirst()) + } + return String(payload) + } +} diff --git a/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift b/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift new file mode 100644 index 0000000..e50193a --- /dev/null +++ b/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift @@ -0,0 +1,110 @@ +import Foundation +import GitKit + +/// Generates a conventional-commit-style message from the staged diff using the configured +/// AI provider. +enum AICommitMessageGenerator { + + /// Generates a commit message for the currently staged changes. + @MainActor + static func generate(viewModel: RepositoryViewModel) async throws -> String { + let provider = AIDefaults.provider + guard let key = AIDefaults.apiKey(for: provider), !key.isEmpty else { + throw AIClientError(message: "No API key configured for \(provider.displayName).") + } + + guard let service = viewModel.gitService else { + throw AIClientError(message: "Repository service is not available.") + } + + let diffText = try await collectStagedDiff(service: service) + guard !diffText.isEmpty else { + throw AIClientError(message: "No staged changes to generate a message for.") + } + + let client: any AIClient = switch provider { + case .anthropic: AnthropicClient(apiKey: key) + case .openAI: OpenAIClient(apiKey: key) + } + + let systemPrompt = """ + Generate a concise conventional commit message for the following staged changes. \ + Use the format: type(optional-scope): description + + Where type is one of: feat, fix, chore, refactor, docs, style, test, perf, ci, build. + + Rules: + - The first line must be at most 72 characters. + - Use imperative mood (e.g. "add" not "added"). + - Do not include a body or footer unless the changes are complex enough to warrant it. + - Return ONLY the commit message text, nothing else. + """ + + let messages: [AIMessage] = [.user(diffText)] + guard let model = AIDefaults.model else { + throw AIClientError(message: "No model selected.") + } + + let stream = client.stream( + messages: messages, + systemPrompt: systemPrompt, + tools: [], + model: model.id + ) + + var result = "" + for try await event in stream { + if case .textDelta(let delta) = event { + result += delta + } + } + + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Collects the staged diff for all staged files, truncated to a reasonable size. + private static func collectStagedDiff(service: any GitService) async throws -> String { + let status = try await service.status() + let staged = status.stagedFiles + + guard !staged.isEmpty else { return "" } + + var parts: [String] = [] + var totalLength = 0 + let maxLength = 8000 + + for file in staged { + guard totalLength < maxLength else { break } + do { + let diff = try await service.diff(path: file.path, staged: true) + let text = diff.hunks.map { hunk in + hunk.lines.map { line in + let prefix: String + switch line.kind { + case .addition: prefix = "+" + case .deletion: prefix = "-" + case .context: prefix = " " + } + return "\(prefix)\(line.content)" + }.joined(separator: "\n") + }.joined(separator: "\n") + + if !text.isEmpty { + let header = "--- \(file.path) ---" + parts.append(header + "\n" + text) + totalLength += header.count + text.count + } + } catch { + // Skip files whose diff can't be read (e.g. binary). + continue + } + } + + if totalLength > maxLength { + let joined = parts.joined(separator: "\n\n") + return String(joined.prefix(maxLength)) + "\n\n[Diff truncated]" + } + + return parts.joined(separator: "\n\n") + } +} diff --git a/Sources/Gitify/Model/AI/AIProvider.swift b/Sources/Gitify/Model/AI/AIProvider.swift new file mode 100644 index 0000000..9123a54 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIProvider.swift @@ -0,0 +1,171 @@ +import Foundation + +/// Supported AI providers for the Git assistant. +enum AIProvider: String, Codable, CaseIterable, Identifiable { + case anthropic + case openAI + + var id: String { rawValue } + + var displayName: String { + switch self { + case .anthropic: "Anthropic" + case .openAI: "OpenAI" + } + } + + /// Fetches available models from the provider's API. + func fetchModels(apiKey: String) async throws -> [AIModel] { + switch self { + case .anthropic: try await Self.fetchAnthropicModels(apiKey: apiKey) + case .openAI: try await Self.fetchOpenAIModels(apiKey: apiKey) + } + } + + // MARK: - Anthropic + + private static func fetchAnthropicModels(apiKey: String) async throws -> [AIModel] { + var all: [AIModel] = [] + var afterID: String? + + // Paginate through all models. + while true { + var urlString = "https://api.anthropic.com/v1/models?limit=100" + if let after = afterID { urlString += "&after_id=\(after)" } + guard let url = URL(string: urlString) else { break } + + var request = URLRequest(url: url) + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let http = response as? HTTPURLResponse + throw AIClientError(message: "Anthropic returned HTTP \(http?.statusCode ?? 0) when listing models.") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { break } + + for item in items { + guard let id = item["id"] as? String else { continue } + let displayName = item["display_name"] as? String ?? id + all.append(AIModel(id: id, displayName: displayName, provider: .anthropic)) + } + + let hasMore = json["has_more"] as? Bool ?? false + if hasMore, let lastID = json["last_id"] as? String { + afterID = lastID + } else { + break + } + } + + return all + } + + // MARK: - OpenAI + + /// Prefixes that indicate a chat-capable OpenAI model. + private static let openAIChatPrefixes = ["gpt-", "o1-", "o3-", "o4-", "chatgpt-"] + + private static func fetchOpenAIModels(apiKey: String) async throws -> [AIModel] { + guard let url = URL(string: "https://api.openai.com/v1/models") else { + throw AIClientError(message: "Invalid OpenAI URL.") + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let http = response as? HTTPURLResponse + throw AIClientError(message: "OpenAI returned HTTP \(http?.statusCode ?? 0) when listing models.") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { + return [] + } + + // Filter to chat-capable models and sort alphabetically. + return items.compactMap { item -> AIModel? in + guard let id = item["id"] as? String else { return nil } + let isChatModel = openAIChatPrefixes.contains { id.hasPrefix($0) } + guard isChatModel else { return nil } + // Skip fine-tuned models (contain ":ft-" or "::" patterns). + if id.contains(":ft-") || id.contains("::") { return nil } + return AIModel(id: id, displayName: id, provider: .openAI) + }.sorted { $0.id < $1.id } + } +} + +/// An AI model that can be used with the assistant. +struct AIModel: Identifiable, Hashable { + let id: String // API model identifier (e.g. "claude-sonnet-4-20250514") + let displayName: String // Human-readable name + let provider: AIProvider +} + +/// UserDefaults accessors for AI assistant preferences. +enum AIDefaults { + private static let providerKey = "ai.provider" + private static let modelKey = "ai.model" + + static var provider: AIProvider { + get { + UserDefaults.standard.string(forKey: providerKey) + .flatMap(AIProvider.init(rawValue:)) ?? .anthropic + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: providerKey) + } + } + + static var modelID: String { + get { + UserDefaults.standard.string(forKey: modelKey) ?? "" + } + set { + UserDefaults.standard.set(newValue, forKey: modelKey) + } + } + + /// Resolves the selected model. Returns an AIModel with the stored ID, + /// or nil if no model has been selected yet. + static var model: AIModel? { + let id = modelID + guard !id.isEmpty else { return nil } + return AIModel(id: id, displayName: id, provider: provider) + } + + // MARK: - Hide AI features + + private static let hideKey = "ai.hidden" + + static var hideAIFeatures: Bool { + get { UserDefaults.standard.bool(forKey: hideKey) } + set { UserDefaults.standard.set(newValue, forKey: hideKey) } + } + + /// True when an API key is configured for the current provider. + static var hasAPIKey: Bool { + apiKey(for: provider) != nil + } + + // MARK: - Keychain (API keys) + + private static let keychainService = "com.gitify.ai" + + static func apiKey(for provider: AIProvider) -> String? { + Keychain.get(account: provider.rawValue, service: keychainService) + } + + static func setAPIKey(_ key: String, for provider: AIProvider) { + Keychain.set(key, account: provider.rawValue, service: keychainService) + } + + static func removeAPIKey(for provider: AIProvider) { + Keychain.delete(account: provider.rawValue, service: keychainService) + } +} diff --git a/Sources/Gitify/Model/AI/AIToolDefinitions.swift b/Sources/Gitify/Model/AI/AIToolDefinitions.swift new file mode 100644 index 0000000..779f698 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIToolDefinitions.swift @@ -0,0 +1,227 @@ +import Foundation + +/// Tool definitions exposed to the AI assistant, mapping to GitService operations. +enum AIToolDefinitions { + + static let all: [AIToolDefinition] = [ + // MARK: Read-only tools + + AIToolDefinition( + name: "get_status", + description: "Get the working tree status: current branch, staged files, unstaged changes, and untracked files.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + AIToolDefinition( + name: "get_log", + description: "Get recent commit history. Returns commit SHA, author, date, and message.", + parameters: [ + "type": "object", + "properties": [ + "limit": ["type": "integer", "description": "Number of commits to return (default 10, max 50)."], + ] as [String: Any], + "required": [] as [String], + ] + ), + AIToolDefinition( + name: "get_refs", + description: "List all branches (local and remote) and tags with their current SHAs.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + AIToolDefinition( + name: "get_diff", + description: "Get the diff for a specific file. Shows what has changed.", + parameters: [ + "type": "object", + "properties": [ + "path": ["type": "string", "description": "File path relative to the repo root."], + "staged": ["type": "boolean", "description": "If true, show the staged (index) diff. Default false."], + ] as [String: Any], + "required": ["path"], + ] + ), + AIToolDefinition( + name: "get_commit_detail", + description: "Get detailed information about a specific commit, including its changed files and line counts.", + parameters: [ + "type": "object", + "properties": [ + "sha": ["type": "string", "description": "The commit SHA (full or abbreviated)."], + ] as [String: Any], + "required": ["sha"], + ] + ), + AIToolDefinition( + name: "get_conflicts", + description: "List files with unresolved merge conflicts.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + AIToolDefinition( + name: "get_file_contents", + description: "Read the contents of a file in the working tree (including any conflict markers).", + parameters: [ + "type": "object", + "properties": [ + "path": ["type": "string", "description": "File path relative to the repo root."], + ] as [String: Any], + "required": ["path"], + ] + ), + AIToolDefinition( + name: "get_stashes", + description: "List all stash entries.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + AIToolDefinition( + name: "get_remotes", + description: "List configured remotes with their fetch and push URLs.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + + // MARK: Mutation tools + + AIToolDefinition( + name: "stage_files", + description: "Stage files for commit (git add). Use [\".\"] to stage all changes.", + parameters: [ + "type": "object", + "properties": [ + "paths": [ + "type": "array", + "items": ["type": "string"], + "description": "File paths to stage. Use [\".\"] to stage all.", + ] as [String: Any], + ] as [String: Any], + "required": ["paths"], + ] + ), + AIToolDefinition( + name: "commit", + description: "Create a commit with the staged changes.", + parameters: [ + "type": "object", + "properties": [ + "message": ["type": "string", "description": "The commit message."], + "amend": ["type": "boolean", "description": "If true, amend the last commit. Default false."], + ] as [String: Any], + "required": ["message"], + ] + ), + AIToolDefinition( + name: "checkout", + description: "Check out an existing branch, tag, or commit.", + parameters: [ + "type": "object", + "properties": [ + "revision": ["type": "string", "description": "Branch name, tag, or commit SHA to check out."], + ] as [String: Any], + "required": ["revision"], + ] + ), + AIToolDefinition( + name: "create_branch", + description: "Create a new branch, optionally checking it out.", + parameters: [ + "type": "object", + "properties": [ + "name": ["type": "string", "description": "Name for the new branch."], + "start_point": ["type": "string", "description": "Starting point (branch, tag, or SHA). Default HEAD."], + "checkout": ["type": "boolean", "description": "Switch to the new branch. Default true."], + ] as [String: Any], + "required": ["name"], + ] + ), + AIToolDefinition( + name: "merge", + description: "Merge a branch into the current branch. This is a destructive operation \u{2014} explain to the user what you are about to do before calling this.", + parameters: [ + "type": "object", + "properties": [ + "branch": ["type": "string", "description": "The branch to merge into the current branch."], + "squash": ["type": "boolean", "description": "Squash all commits into the index without committing. Default false."], + "no_fast_forward": ["type": "boolean", "description": "Always create a merge commit. Default false."], + "no_commit": ["type": "boolean", "description": "Perform merge but stop before committing. Default false."], + ] as [String: Any], + "required": ["branch"], + ] + ), + AIToolDefinition( + name: "rebase", + description: "Rebase the current branch onto another branch. This rewrites history \u{2014} explain to the user what you are about to do before calling this.", + parameters: [ + "type": "object", + "properties": [ + "onto": ["type": "string", "description": "The branch to rebase onto."], + ] as [String: Any], + "required": ["onto"], + ] + ), + AIToolDefinition( + name: "cherry_pick", + description: "Apply a specific commit onto the current branch.", + parameters: [ + "type": "object", + "properties": [ + "sha": ["type": "string", "description": "The commit SHA to cherry-pick."], + ] as [String: Any], + "required": ["sha"], + ] + ), + AIToolDefinition( + name: "revert", + description: "Create a new commit that undoes a specific commit.", + parameters: [ + "type": "object", + "properties": [ + "sha": ["type": "string", "description": "The commit SHA to revert."], + ] as [String: Any], + "required": ["sha"], + ] + ), + AIToolDefinition( + name: "resolve_conflict", + description: "Resolve a merge conflict in a file by choosing ours or theirs.", + parameters: [ + "type": "object", + "properties": [ + "path": ["type": "string", "description": "Path of the conflicted file."], + "use_ours": ["type": "boolean", "description": "If true, take our version. If false, take theirs."], + ] as [String: Any], + "required": ["path", "use_ours"], + ] + ), + AIToolDefinition( + name: "abort_operation", + description: "Abort an in-progress merge or rebase.", + parameters: [ + "type": "object", + "properties": [ + "operation": ["type": "string", "enum": ["merge", "rebase"], "description": "Which operation to abort."], + ] as [String: Any], + "required": ["operation"], + ] + ), + AIToolDefinition( + name: "stash_push", + description: "Stash the current working tree changes.", + parameters: [ + "type": "object", + "properties": [ + "message": ["type": "string", "description": "Optional stash message."], + "include_untracked": ["type": "boolean", "description": "Include untracked files. Default true."], + ] as [String: Any], + "required": [] as [String], + ] + ), + AIToolDefinition( + name: "stash_pop", + description: "Apply and remove the most recent stash (or a specified stash).", + parameters: [ + "type": "object", + "properties": [ + "selector": ["type": "string", "description": "Stash selector (e.g. stash@{0}). Default: most recent."], + ] as [String: Any], + "required": [] as [String], + ] + ), + ] +} diff --git a/Sources/Gitify/Model/AI/AIToolExecutor.swift b/Sources/Gitify/Model/AI/AIToolExecutor.swift new file mode 100644 index 0000000..e1673c7 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIToolExecutor.swift @@ -0,0 +1,336 @@ +import Foundation +import GitKit + +/// Executes AI tool calls against a repository's GitService, formatting results as strings +/// for the AI to consume. Errors are returned as descriptive strings rather than thrown, +/// so the AI can reason about failures. +@MainActor +final class AIToolExecutor { + private let viewModel: RepositoryViewModel + + init(viewModel: RepositoryViewModel) { + self.viewModel = viewModel + } + + /// Dispatches a tool call by name and returns a string result for the AI. + func execute(name: String, arguments: String) async -> String { + let args = parseArgs(arguments) + do { + guard let service = viewModel.gitService else { + return "Error: Repository service is not available." + } + + switch name { + // MARK: Read-only + case "get_status": + return try await executeGetStatus(service) + case "get_log": + return try await executeGetLog(service, args: args) + case "get_refs": + return try await executeGetRefs(service) + case "get_diff": + return try await executeGetDiff(service, args: args) + case "get_commit_detail": + return try await executeGetCommitDetail(service, args: args) + case "get_conflicts": + return try await executeGetConflicts(service) + case "get_file_contents": + return try await executeGetFileContents(service, args: args) + case "get_stashes": + return try await executeGetStashes(service) + case "get_remotes": + return try await executeGetRemotes(service) + + // MARK: Mutations + case "stage_files": + return try await executeStageFiles(service, args: args) + case "commit": + return try await executeCommit(service, args: args) + case "checkout": + return try await executeCheckout(service, args: args) + case "create_branch": + return try await executeCreateBranch(service, args: args) + case "merge": + return try await executeMerge(service, args: args) + case "rebase": + return try await executeRebase(service, args: args) + case "cherry_pick": + return try await executeCherryPick(service, args: args) + case "revert": + return try await executeRevert(service, args: args) + case "resolve_conflict": + return try await executeResolveConflict(service, args: args) + case "abort_operation": + return try await executeAbortOperation(service, args: args) + case "stash_push": + return try await executeStashPush(service, args: args) + case "stash_pop": + return try await executeStashPop(service, args: args) + default: + return "Error: Unknown tool \u{201C}\(name)\u{201D}." + } + } catch { + return "Error: \(error.localizedDescription)" + } + } + + // MARK: - Read-only implementations + + private func executeGetStatus(_ service: any GitService) async throws -> String { + let status = try await service.status() + var lines: [String] = [] + lines.append("Branch: \(status.branch ?? "(detached HEAD)")") + if let upstream = status.upstream { + var tracking = "Upstream: \(upstream)" + if status.ahead > 0 { tracking += " [ahead \(status.ahead)]" } + if status.behind > 0 { tracking += " [behind \(status.behind)]" } + lines.append(tracking) + } + if status.files.isEmpty { + lines.append("Working tree clean.") + } else { + for file in status.files { + let idx = file.indexState == .unmodified ? "." : String(file.indexState.rawValue) + let wt = file.worktreeState == .unmodified ? "." : String(file.worktreeState.rawValue) + lines.append("\(idx)\(wt) \(file.path)") + } + } + return lines.joined(separator: "\n") + } + + private func executeGetLog(_ service: any GitService, args: [String: Any]) async throws -> String { + let limit = min(args["limit"] as? Int ?? 10, 50) + let page = try await service.log(skip: 0, limit: limit, revisions: ["HEAD"]) + if page.commits.isEmpty { return "No commits found." } + return page.commits.map { c in + "\(c.shortID) \(c.authorName) \(c.authorDate) \(c.summary)" + }.joined(separator: "\n") + } + + private func executeGetRefs(_ service: any GitService) async throws -> String { + let refs = try await service.refs() + if refs.isEmpty { return "No refs found." } + var sections: [String] = [] + let local = refs.filter { $0.kind == .localBranch } + if !local.isEmpty { + sections.append("Local branches:\n" + local.map { + " \($0.isHead ? "* " : " ")\($0.name) \($0.targetSHA.prefix(7))" + }.joined(separator: "\n")) + } + let remote = refs.filter { $0.kind == .remoteBranch } + if !remote.isEmpty { + sections.append("Remote branches:\n" + remote.map { + " \($0.name) \($0.targetSHA.prefix(7))" + }.joined(separator: "\n")) + } + let tags = refs.filter { $0.kind == .tag } + if !tags.isEmpty { + sections.append("Tags:\n" + tags.map { + " \($0.name) \($0.targetSHA.prefix(7))" + }.joined(separator: "\n")) + } + return sections.joined(separator: "\n\n") + } + + private func executeGetDiff(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let path = args["path"] as? String else { return "Error: Missing required parameter \u{201C}path\u{201D}." } + let staged = args["staged"] as? Bool ?? false + let diff = try await service.diff(path: path, staged: staged) + if diff.hunks.isEmpty { return "No differences found for \(path)." } + return diff.hunks.map { hunk in + hunk.lines.map { line in + let prefix: String + switch line.kind { + case .addition: prefix = "+" + case .deletion: prefix = "-" + case .context: prefix = " " + } + return "\(prefix)\(line.content)" + }.joined(separator: "\n") + }.joined(separator: "\n---\n") + } + + private func executeGetCommitDetail(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let sha = args["sha"] as? String else { return "Error: Missing required parameter \u{201C}sha\u{201D}." } + let changes = try await service.commitChanges(sha: sha) + if changes.isEmpty { return "No changes found for commit \(sha)." } + return changes.map { c in + "\(c.status.rawValue) \(c.path) (+\(c.additions) -\(c.deletions))" + }.joined(separator: "\n") + } + + private func executeGetConflicts(_ service: any GitService) async throws -> String { + let files = try await service.conflictedFiles() + if files.isEmpty { return "No conflicted files." } + return "Conflicted files:\n" + files.map { " \($0)" }.joined(separator: "\n") + } + + private func executeGetFileContents(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let path = args["path"] as? String else { return "Error: Missing required parameter \u{201C}path\u{201D}." } + guard let contents = await service.fileContents(path: path) else { + return "Error: Could not read file \u{201C}\(path)\u{201D}." + } + // Truncate very large files to avoid blowing up the context. + if contents.count > 50_000 { + return String(contents.prefix(50_000)) + "\n\n[Truncated \u{2014} file is \(contents.count) characters.]" + } + return contents + } + + private func executeGetStashes(_ service: any GitService) async throws -> String { + let stashes = try await service.stashes() + if stashes.isEmpty { return "No stashes." } + return stashes.map { "\($0.id): \($0.message)" }.joined(separator: "\n") + } + + private func executeGetRemotes(_ service: any GitService) async throws -> String { + let remotes = try await service.remotes() + if remotes.isEmpty { return "No remotes configured." } + return remotes.map { "\($0.name) fetch=\($0.fetchURL) push=\($0.pushURL)" }.joined(separator: "\n") + } + + // MARK: - Mutation implementations + + private func executeStageFiles(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let paths = args["paths"] as? [String], !paths.isEmpty else { + return "Error: Missing required parameter \u{201C}paths\u{201D}." + } + if paths == ["."] { + try await service.stageAll() + } else { + try await service.stage(paths: paths) + } + await viewModel.refreshStatus() + return "Staged \(paths == ["."] ? "all changes" : paths.joined(separator: ", "))." + } + + private func executeCommit(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let message = args["message"] as? String, !message.isEmpty else { + return "Error: Missing required parameter \u{201C}message\u{201D}." + } + let amend = args["amend"] as? Bool ?? false + try await service.commit(message: message, amend: amend) + await viewModel.refreshStatus() + return amend ? "Amended the last commit." : "Committed with message: \(message)" + } + + private func executeCheckout(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let revision = args["revision"] as? String else { + return "Error: Missing required parameter \u{201C}revision\u{201D}." + } + try await service.checkout(revision) + await viewModel.refreshStatus() + return "Checked out \u{201C}\(revision)\u{201D}." + } + + private func executeCreateBranch(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let name = args["name"] as? String else { + return "Error: Missing required parameter \u{201C}name\u{201D}." + } + let startPoint = args["start_point"] as? String + let checkout = args["checkout"] as? Bool ?? true + try await service.createBranch(name: name, startPoint: startPoint, checkout: checkout) + await viewModel.refreshStatus() + return "Created branch \u{201C}\(name)\u{201D}\(checkout ? " and checked it out" : "")." + } + + private func executeMerge(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let branch = args["branch"] as? String else { + return "Error: Missing required parameter \u{201C}branch\u{201D}." + } + let squash = args["squash"] as? Bool ?? false + let noFF = args["no_fast_forward"] as? Bool ?? false + let noCommit = args["no_commit"] as? Bool ?? false + try await service.merge(branch: branch, squash: squash, noFastForward: noFF, + noCommit: noCommit, skipHooks: false) + await viewModel.refreshStatus() + let conflicts = try await service.conflictedFiles() + if !conflicts.isEmpty { + return "Merge of \u{201C}\(branch)\u{201D} resulted in conflicts in: \(conflicts.joined(separator: ", ")). Resolve them and commit." + } + return "Merged \u{201C}\(branch)\u{201D} into the current branch." + } + + private func executeRebase(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let onto = args["onto"] as? String else { + return "Error: Missing required parameter \u{201C}onto\u{201D}." + } + try await service.rebase(onto: onto) + await viewModel.refreshStatus() + return "Rebased current branch onto \u{201C}\(onto)\u{201D}." + } + + private func executeCherryPick(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let sha = args["sha"] as? String else { + return "Error: Missing required parameter \u{201C}sha\u{201D}." + } + try await service.cherryPick(sha: sha) + await viewModel.refreshStatus() + return "Cherry-picked commit \(sha)." + } + + private func executeRevert(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let sha = args["sha"] as? String else { + return "Error: Missing required parameter \u{201C}sha\u{201D}." + } + try await service.revert(sha: sha) + await viewModel.refreshStatus() + return "Reverted commit \(sha)." + } + + private func executeResolveConflict(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let path = args["path"] as? String else { + return "Error: Missing required parameter \u{201C}path\u{201D}." + } + guard let useOurs = args["use_ours"] as? Bool else { + return "Error: Missing required parameter \u{201C}use_ours\u{201D}." + } + try await service.resolveConflict(path: path, useOurs: useOurs) + await viewModel.refreshStatus() + return "Resolved \u{201C}\(path)\u{201D} using \(useOurs ? "ours" : "theirs")." + } + + private func executeAbortOperation(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let op = args["operation"] as? String else { + return "Error: Missing required parameter \u{201C}operation\u{201D}." + } + switch op { + case "merge": + try await service.abortMerge() + await viewModel.refreshStatus() + return "Aborted the in-progress merge." + case "rebase": + try await service.abortRebase() + await viewModel.refreshStatus() + return "Aborted the in-progress rebase." + default: + return "Error: Unknown operation \u{201C}\(op)\u{201D}. Use \u{201C}merge\u{201D} or \u{201C}rebase\u{201D}." + } + } + + private func executeStashPush(_ service: any GitService, args: [String: Any]) async throws -> String { + let message = args["message"] as? String + let includeUntracked = args["include_untracked"] as? Bool ?? true + try await service.stashPush(message: message, includeUntracked: includeUntracked) + await viewModel.refreshStatus() + return "Stashed working tree changes\(message.map { ": \($0)" } ?? "")." + } + + private func executeStashPop(_ service: any GitService, args: [String: Any]) async throws -> String { + let selector = args["selector"] as? String ?? "stash@{0}" + try await service.stashPop(selector) + await viewModel.refreshStatus() + return "Applied and removed stash \(selector)." + } + + // MARK: - Helpers + + private func parseArgs(_ json: String) -> [String: Any] { + guard !json.isEmpty, + let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return obj + } +} diff --git a/Sources/Gitify/Model/AI/AnthropicClient.swift b/Sources/Gitify/Model/AI/AnthropicClient.swift new file mode 100644 index 0000000..1bfa1b1 --- /dev/null +++ b/Sources/Gitify/Model/AI/AnthropicClient.swift @@ -0,0 +1,188 @@ +import Foundation + +/// Anthropic Messages API client with streaming and tool-use support. +struct AnthropicClient: AIClient { + let apiKey: String + + private static let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! + + func stream( + messages: [AIMessage], + systemPrompt: String, + tools: [AIToolDefinition], + model: String + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let request = try buildRequest(messages: messages, systemPrompt: systemPrompt, + tools: tools, model: model) + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let http = response as? HTTPURLResponse else { + throw AIClientError(message: "No response from Anthropic.") + } + guard (200..<300).contains(http.statusCode) else { + // Read error body. + var body = "" + for try await line in bytes.lines { body += line } + throw AIClientError(message: "Anthropic returned HTTP \(http.statusCode): \(body)") + } + try await parseStream(bytes: bytes, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Request building + + private func buildRequest( + messages: [AIMessage], systemPrompt: String, + tools: [AIToolDefinition], model: String + ) throws -> URLRequest { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + var body: [String: Any] = [ + "model": model, + "max_tokens": 4096, + "stream": true, + "system": systemPrompt, + "messages": messages.map(encodeMessage), + ] + if !tools.isEmpty { + body["tools"] = tools.map { $0.anthropicJSON() } + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + private func encodeMessage(_ message: AIMessage) -> [String: Any] { + switch message.role { + case .user: + // User messages with tool results use the Anthropic content-array format. + let hasToolResult = message.content.contains { + if case .toolResult = $0 { return true } + return false + } + if hasToolResult { + return [ + "role": "user", + "content": message.content.map(encodeContentBlock), + ] + } + let text = message.content.compactMap { block -> String? in + if case .text(let t) = block { return t } + return nil + }.joined() + return ["role": "user", "content": text] + + case .assistant: + let blocks = message.content.map(encodeContentBlock) + return ["role": "assistant", "content": blocks] + + case .tool: + // Tool results are sent as user messages with tool_result content blocks. + return [ + "role": "user", + "content": message.content.map(encodeContentBlock), + ] + + case .system: + return ["role": "user", "content": ""] + } + } + + private func encodeContentBlock(_ block: AIContentBlock) -> [String: Any] { + switch block { + case .text(let text): + return ["type": "text", "text": text] + case .toolCall(let call): + let input = (try? JSONSerialization.jsonObject(with: Data(call.arguments.utf8))) ?? [:] + return [ + "type": "tool_use", + "id": call.id, + "name": call.name, + "input": input, + ] + case .toolResult(let callID, let content): + return [ + "type": "tool_result", + "tool_use_id": callID, + "content": content, + ] + } + } + + // MARK: - Stream parsing + + private func parseStream( + bytes: URLSession.AsyncBytes, + continuation: AsyncThrowingStream.Continuation + ) async throws { + // Accumulators for tool calls being built across deltas. + var currentToolID: String? + var currentToolName: String? + var currentToolArgs = "" + + for try await line in bytes.lines { + guard let data = SSEParser.dataPayload(from: line) else { continue } + if data == "[DONE]" { break } + + guard let json = try? JSONSerialization.jsonObject(with: Data(data.utf8)) as? [String: Any], + let type = json["type"] as? String else { continue } + + switch type { + case "content_block_start": + if let block = json["content_block"] as? [String: Any], + block["type"] as? String == "tool_use" { + currentToolID = block["id"] as? String + currentToolName = block["name"] as? String + currentToolArgs = "" + } + + case "content_block_delta": + if let delta = json["delta"] as? [String: Any] { + let deltaType = delta["type"] as? String ?? "" + if deltaType == "text_delta", let text = delta["text"] as? String { + continuation.yield(.textDelta(text)) + } else if deltaType == "input_json_delta", let partial = delta["partial_json"] as? String { + currentToolArgs += partial + } + } + + case "content_block_stop": + if let id = currentToolID, let name = currentToolName { + let call = AIToolCall(id: id, name: name, arguments: currentToolArgs) + continuation.yield(.toolCall(call)) + currentToolID = nil + currentToolName = nil + currentToolArgs = "" + } + + case "message_stop": + continuation.yield(.done) + + case "error": + if let error = json["error"] as? [String: Any], + let msg = error["message"] as? String { + throw AIClientError(message: "Anthropic error: \(msg)") + } + + default: + break + } + } + } +} + +struct AIClientError: LocalizedError { + let message: String + var errorDescription: String? { message } +} diff --git a/Sources/Gitify/Model/AI/OpenAIClient.swift b/Sources/Gitify/Model/AI/OpenAIClient.swift new file mode 100644 index 0000000..335b198 --- /dev/null +++ b/Sources/Gitify/Model/AI/OpenAIClient.swift @@ -0,0 +1,184 @@ +import Foundation + +/// OpenAI Chat Completions API client with streaming and function-calling support. +struct OpenAIClient: AIClient { + let apiKey: String + + private static let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")! + + func stream( + messages: [AIMessage], + systemPrompt: String, + tools: [AIToolDefinition], + model: String + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let request = try buildRequest(messages: messages, systemPrompt: systemPrompt, + tools: tools, model: model) + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let http = response as? HTTPURLResponse else { + throw AIClientError(message: "No response from OpenAI.") + } + guard (200..<300).contains(http.statusCode) else { + var body = "" + for try await line in bytes.lines { body += line } + throw AIClientError(message: "OpenAI returned HTTP \(http.statusCode): \(body)") + } + try await parseStream(bytes: bytes, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Request building + + private func buildRequest( + messages: [AIMessage], systemPrompt: String, + tools: [AIToolDefinition], model: String + ) throws -> URLRequest { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + // Build the OpenAI messages array with system prompt first. + var apiMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ] + for msg in messages { + apiMessages.append(contentsOf: encodeMessage(msg)) + } + + var body: [String: Any] = [ + "model": model, + "stream": true, + "messages": apiMessages, + ] + if !tools.isEmpty { + body["tools"] = tools.map { $0.openAIJSON() } + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + /// Encodes a message for the OpenAI API. Tool results may produce multiple messages. + private func encodeMessage(_ message: AIMessage) -> [[String: Any]] { + switch message.role { + case .user: + let text = message.content.compactMap { block -> String? in + if case .text(let t) = block { return t } + return nil + }.joined() + return [["role": "user", "content": text]] + + case .assistant: + var msg: [String: Any] = ["role": "assistant"] + let texts = message.content.compactMap { block -> String? in + if case .text(let t) = block { return t } + return nil + } + if !texts.isEmpty { msg["content"] = texts.joined() } + + let toolCalls = message.content.compactMap { block -> [String: Any]? in + guard case .toolCall(let call) = block else { return nil } + return [ + "id": call.id, + "type": "function", + "function": [ + "name": call.name, + "arguments": call.arguments, + ] as [String: Any], + ] + } + if !toolCalls.isEmpty { msg["tool_calls"] = toolCalls } + return [msg] + + case .tool: + return message.content.compactMap { block -> [String: Any]? in + guard case .toolResult(let callID, let content) = block else { return nil } + return [ + "role": "tool", + "tool_call_id": callID, + "content": content, + ] + } + + case .system: + return [["role": "system", "content": ""]] + } + } + + // MARK: - Stream parsing + + private func parseStream( + bytes: URLSession.AsyncBytes, + continuation: AsyncThrowingStream.Continuation + ) async throws { + // OpenAI streams tool calls across multiple deltas; accumulate per index. + var toolCallAccumulators: [Int: (id: String, name: String, args: String)] = [:] + + for try await line in bytes.lines { + guard let data = SSEParser.dataPayload(from: line) else { continue } + if data == "[DONE]" { + // Flush any accumulated tool calls. + for index in toolCallAccumulators.keys.sorted() { + if let acc = toolCallAccumulators[index] { + let call = AIToolCall(id: acc.id, name: acc.name, arguments: acc.args) + continuation.yield(.toolCall(call)) + } + } + toolCallAccumulators.removeAll() + continuation.yield(.done) + break + } + + guard let json = try? JSONSerialization.jsonObject(with: Data(data.utf8)) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let choice = choices.first, + let delta = choice["delta"] as? [String: Any] else { continue } + + // Text content. + if let content = delta["content"] as? String, !content.isEmpty { + continuation.yield(.textDelta(content)) + } + + // Tool calls (function calling). + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for tc in toolCalls { + guard let index = tc["index"] as? Int else { continue } + if let id = tc["id"] as? String { + // Start of a new tool call. + let name = (tc["function"] as? [String: Any])?["name"] as? String ?? "" + let args = (tc["function"] as? [String: Any])?["arguments"] as? String ?? "" + toolCallAccumulators[index] = (id: id, name: name, args: args) + } else if let fn = tc["function"] as? [String: Any] { + // Continuation delta — append arguments. + if let args = fn["arguments"] as? String { + toolCallAccumulators[index]?.args += args + } + if let name = fn["name"] as? String, !(name.isEmpty) { + toolCallAccumulators[index]?.name += name + } + } + } + } + + // Check for finish_reason to flush tool calls. + if let reason = choice["finish_reason"] as? String, reason == "tool_calls" { + for index in toolCallAccumulators.keys.sorted() { + if let acc = toolCallAccumulators[index] { + let call = AIToolCall(id: acc.id, name: acc.name, arguments: acc.args) + continuation.yield(.toolCall(call)) + } + } + toolCallAccumulators.removeAll() + } + } + } +} diff --git a/Sources/Gitify/Model/Keychain.swift b/Sources/Gitify/Model/Keychain.swift index 696670e..7468f73 100644 --- a/Sources/Gitify/Model/Keychain.swift +++ b/Sources/Gitify/Model/Keychain.swift @@ -1,22 +1,29 @@ import Foundation import Security -/// Minimal Keychain wrapper for storing hosting-provider access tokens. +/// Minimal Keychain wrapper for storing secrets (hosting tokens, AI API keys, etc.). enum Keychain { - private static let service = "com.gitify.accounts" + private static let defaultService = "com.gitify.accounts" - static func set(_ token: String, account: String) { - delete(account: account) - let query: [String: Any] = [ + static func set(_ token: String, account: String, service: String = "com.gitify.accounts") { + delete(account: account, service: service) + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: Data(token.utf8), ] + // Use a permissive access control so unsigned debug builds don\u{2019}t trigger + // repeated keychain authorisation prompts on every relaunch. + if let access = SecAccessCreateWithOwnerAndACL( + service: service, account: account + ) { + query[kSecAttrAccess as String] = access + } SecItemAdd(query as CFDictionary, nil) } - static func get(account: String) -> String? { + static func get(account: String, service: String = "com.gitify.accounts") -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -30,7 +37,7 @@ enum Keychain { return String(decoding: data, as: UTF8.self) } - static func delete(account: String) { + static func delete(account: String, service: String = "com.gitify.accounts") { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -38,4 +45,15 @@ enum Keychain { ] SecItemDelete(query as CFDictionary) } + + /// Creates a SecAccess that allows any application to read the keychain item, + /// avoiding per-binary ACL checks that cause prompts for unsigned debug builds. + private static func SecAccessCreateWithOwnerAndACL(service: String, account: String) -> SecAccess? { + var access: SecAccess? + let label = "\(service).\(account)" as CFString + // An empty trusted-apps list means "allow access by any application". + let status = SecAccessCreate(label, [] as CFArray, &access) + guard status == errSecSuccess else { return nil } + return access + } } diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift index 4084883..6b9864c 100644 --- a/Sources/Gitify/Model/RepositoryViewModel.swift +++ b/Sources/Gitify/Model/RepositoryViewModel.swift @@ -53,6 +53,9 @@ final class RepositoryViewModel { private var pendingMergeSourceToDelete: String? private var service: CLIGitService? + + /// Exposes the resolved git service for use by the AI tool executor. + var gitService: (any GitService)? { service } private var nextSkip: Int? = 0 private var watcher: (any RepositoryWatching)? private var autoRefreshTask: Task? diff --git a/Sources/Gitify/Resources/CHANGELOG.md b/Sources/Gitify/Resources/CHANGELOG.md index 8f5bd23..bb61033 100644 --- a/Sources/Gitify/Resources/CHANGELOG.md +++ b/Sources/Gitify/Resources/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.13.1](https://github.com/craigharman/gitify/compare/v1.13.0...v1.13.1) (2026-06-30) + + +### Bug Fixes + +* read changelog file causing app to crash ([#34](https://github.com/craigharman/gitify/issues/34)) ([380e11f](https://github.com/craigharman/gitify/commit/380e11fafbb27572ec6a5e8f1e23b036935452f5)) + +## [1.13.0](https://github.com/craigharman/gitify/compare/v1.12.0...v1.13.0) (2026-06-30) + + +### Features + +* what's new changelog on update ([#32](https://github.com/craigharman/gitify/issues/32)) ([2e7dc1d](https://github.com/craigharman/gitify/commit/2e7dc1d7f53e01196e75772b92982502afad85c5)) + ## [1.12.0](https://github.com/craigharman/gitify/compare/v1.11.0...v1.12.0) (2026-06-30) diff --git a/Sources/Gitify/Views/AI/AIAssistantView.swift b/Sources/Gitify/Views/AI/AIAssistantView.swift new file mode 100644 index 0000000..65888e4 --- /dev/null +++ b/Sources/Gitify/Views/AI/AIAssistantView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +/// The AI assistant chat sheet. Presents a scrollable conversation with a text input bar. +struct AIAssistantView: View { + @State var assistant: AIAssistantViewModel + @Environment(\.dismiss) private var dismiss + + init(viewModel: RepositoryViewModel) { + _assistant = State(initialValue: AIAssistantViewModel(viewModel: viewModel)) + } + + var body: some View { + VStack(spacing: 0) { + // Toolbar + HStack { + Text("AI Assistant").font(.headline) + Spacer() + if let model = AIDefaults.model { + Text(model.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + Button { assistant.clear() } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Clear conversation") + .disabled(assistant.messages.isEmpty) + Button("Done") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + + Divider() + + // Messages + ScrollViewReader { proxy in + ScrollView { + if assistant.messages.isEmpty { + Text("Ask me about this repository. I can help with any Git related tasks such as merges, cherry pick, commits etc.") + .font(.callout) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(40) + } + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(assistant.messages) { message in + MessageBubble(message: message) + .id(message.id) + } + } + .padding(16) + } + .onChange(of: assistant.messages.count) { _, _ in + if let last = assistant.messages.last { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + .onChange(of: assistant.messages.last?.text) { _, _ in + if let last = assistant.messages.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + + // Error banner + if let error = assistant.error { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error) + .font(.caption) + .lineLimit(2) + Spacer() + } + .padding(8) + .background(.orange.opacity(0.1)) + } + + Divider() + + // Input bar + HStack(spacing: 8) { + TextField("Ask about your repository\u{2026}", text: $assistant.inputText, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...5) + .onSubmit { + if !NSEvent.modifierFlags.contains(.shift) { + assistant.send() + } + } + + if assistant.isStreaming { + Button { assistant.stop() } label: { + Image(systemName: "stop.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + .help("Stop generating") + } else { + Button { assistant.send() } label: { + Image(systemName: "arrow.up.circle.fill") + .foregroundStyle(.tint) + } + .buttonStyle(.borderless) + .disabled(assistant.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Send message") + } + } + .padding(12) + .background(.bar) + } + .frame(width: 620, height: 520) + } +} + +/// Renders a single chat message as a styled bubble. +private struct MessageBubble: View { + let message: ChatMessage + + var body: some View { + switch message.role { + case .user: + HStack { + Spacer(minLength: 60) + Text(message.text) + .padding(10) + .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 10)) + .textSelection(.enabled) + } + + case .assistant: + HStack { + Text(LocalizedStringKey(message.text)) + .padding(10) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 10)) + .textSelection(.enabled) + Spacer(minLength: 60) + } + + case .toolCall: + ToolCallCard(name: message.toolName ?? "tool", isResult: false, content: nil) + + case .toolResult: + ToolCallCard(name: message.toolName ?? "tool", isResult: true, + content: message.toolResultContent) + } + } +} + +/// A collapsible card showing a tool call or its result. +private struct ToolCallCard: View { + let name: String + let isResult: Bool + let content: String? + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + if isResult { isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: isResult ? "checkmark.circle.fill" : "gearshape.fill") + .foregroundStyle(isResult ? .green : .secondary) + .font(.caption) + Text(isResult ? "Result: \(name)" : "Calling: \(name)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + if isResult, content != nil { + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .buttonStyle(.plain) + + if isExpanded, let content { + Text(content) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(20) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + .textSelection(.enabled) + } + } + .padding(.horizontal, 4) + } +} diff --git a/Sources/Gitify/Views/AI/AISettingsTab.swift b/Sources/Gitify/Views/AI/AISettingsTab.swift new file mode 100644 index 0000000..a6a3e5e --- /dev/null +++ b/Sources/Gitify/Views/AI/AISettingsTab.swift @@ -0,0 +1,141 @@ +import SwiftUI + +/// AI configuration tab for the app settings window. Lets the user choose a provider, +/// enter an API key, and select a model (fetched dynamically from the provider's API). +struct AISettingsTab: View { + @State private var provider: AIProvider = AIDefaults.provider + @State private var apiKey: String = "" + @State private var modelID: String = AIDefaults.modelID + @State private var hasKey = false + @State private var hideAI: Bool = AIDefaults.hideAIFeatures + @State private var availableModels: [AIModel] = [] + @State private var isLoadingModels = false + @State private var modelError: String? + + var body: some View { + Form { + Picker("Provider", selection: $provider) { + ForEach(AIProvider.allCases) { p in + Text(p.displayName).tag(p) + } + } + .pickerStyle(.segmented) + + Section { + if hasKey { + HStack { + Label("API key is saved", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Spacer() + Button("Remove", role: .destructive) { + AIDefaults.removeAPIKey(for: provider) + apiKey = "" + hasKey = false + availableModels = [] + modelID = "" + AIDefaults.modelID = "" + } + .controlSize(.small) + } + } else { + SecureField("API Key", text: $apiKey) + HStack { + Text("Enter your \(provider.displayName) API key. It will be stored in your Keychain.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Save") { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + AIDefaults.setAPIKey(trimmed, for: provider) + hasKey = true + apiKey = "" + Task { await loadModels() } + } + .controlSize(.small) + .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + Section { + if isLoadingModels { + HStack { + ProgressView() + .controlSize(.small) + Text("Loading models\u{2026}") + .foregroundStyle(.secondary) + } + } else if availableModels.isEmpty { + if let error = modelError { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } else if hasKey { + Text("No models available.") + .foregroundStyle(.secondary) + } else { + Text("Add an API key to see available models.") + .foregroundStyle(.secondary) + } + } else { + Picker("Model", selection: $modelID) { + ForEach(availableModels) { model in + Text(model.displayName).tag(model.id) + } + } + } + } + + Toggle("Hide AI features", isOn: $hideAI) + } + .formStyle(.grouped) + .frame(width: 400) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: provider) { _, newValue in + AIDefaults.provider = newValue + hasKey = AIDefaults.apiKey(for: newValue) != nil + availableModels = [] + modelError = nil + if hasKey { + Task { await loadModels() } + } else { + modelID = "" + AIDefaults.modelID = "" + } + } + .onChange(of: modelID) { _, newValue in + AIDefaults.modelID = newValue + } + .onChange(of: hideAI) { _, newValue in + AIDefaults.hideAIFeatures = newValue + } + .onAppear { + hasKey = AIDefaults.apiKey(for: provider) != nil + if hasKey { + Task { await loadModels() } + } + } + } + + private func loadModels() async { + guard let key = AIDefaults.apiKey(for: provider) else { return } + isLoadingModels = true + modelError = nil + defer { isLoadingModels = false } + + do { + let models = try await provider.fetchModels(apiKey: key) + availableModels = models + + // If the currently selected model isn't in the list, pick the first. + if !models.contains(where: { $0.id == modelID }) { + modelID = models.first?.id ?? "" + AIDefaults.modelID = modelID + } + } catch { + modelError = error.localizedDescription + availableModels = [] + } + } +} diff --git a/Sources/Gitify/Views/AppSettingsView.swift b/Sources/Gitify/Views/AppSettingsView.swift index 562eaed..3f0f49a 100644 --- a/Sources/Gitify/Views/AppSettingsView.swift +++ b/Sources/Gitify/Views/AppSettingsView.swift @@ -1,8 +1,20 @@ import SwiftUI -/// App-level preferences pane (Cmd+,). Provides pickers for the default terminal and editor -/// applications, populated from installed apps on the system. +/// App-level preferences pane (Cmd+,). Provides tabs for General settings and AI configuration. struct AppSettingsView: View { + var body: some View { + TabView { + GeneralSettingsTab() + .tabItem { Label("General", systemImage: "gearshape") } + AISettingsTab() + .tabItem { Label("AI", systemImage: "sparkles") } + } + .frame(width: 420) + } +} + +/// General settings tab: default terminal and editor pickers. +private struct GeneralSettingsTab: View { @State private var terminalID: String = AppDefaults.terminalBundleID @State private var editorID: String = AppDefaults.editorBundleID ?? "" diff --git a/Sources/Gitify/Views/Prompts.swift b/Sources/Gitify/Views/Prompts.swift index e81ade5..a82049a 100644 --- a/Sources/Gitify/Views/Prompts.swift +++ b/Sources/Gitify/Views/Prompts.swift @@ -19,7 +19,22 @@ enum Prompt { alert.accessoryView = field alert.window.initialFirstResponder = field - guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let response = alert.runModal() + + // Unhook the accessory field from the alert window's key view loop before + // teardown. `initialFirstResponder` and `nextKeyView` are unretained (assign) + // references, and the alert's internal accessory hosting view is destroyed + // lazily via autorelease -- if the loop still points at the freed field when + // that happens, `-[NSView _removeFromKeyViewLoop]` crashes on a dangling + // pointer (EXC_BAD_ACCESS in objc_msgSend). + alert.window.makeFirstResponder(nil) + alert.window.initialFirstResponder = nil + field.nextKeyView = nil + alert.accessoryView = nil + field.removeFromSuperview() + alert.window.recalculateKeyViewLoop() + + guard response == .alertFirstButtonReturn else { return nil } let value = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) if value.isEmpty { return allowEmpty ? "" : nil } return value diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift index 83be4e6..7ce765e 100644 --- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift +++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift @@ -24,6 +24,9 @@ struct RepositoryWorkspaceView: View { @State private var section: WorkspaceSection @State private var integrationSheet: IntegrationSheet? @State private var showSettings = false + @State private var showAssistant = false + @State private var showAIPopover = false + @AppStorage("ai.hidden") private var hideAIFeatures = false // Per-repository key for the last-selected section. private var sectionKey: String { SidebarDefaults.sectionKey(ref.path) } @@ -114,6 +117,31 @@ struct RepositoryWorkspaceView: View { branchMenu moreMenu + if !hideAIFeatures { + Button { + if AIDefaults.hasAPIKey { + showAssistant = true + } else { + showAIPopover = true + } + } label: { + Label("AI Assistant", systemImage: "sparkles") + } + .help("AI Assistant") + .popover(isPresented: $showAIPopover) { + VStack(alignment: .leading, spacing: 8) { + Text("AI Assistant") + .font(.headline) + Text("Gitify allows you to use AI to explain your Git requirements in plain English and compose commit messages. To use this feature add an API key via settings.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .frame(width: 300) + } + } + Button { Task { await viewModel.load() } } label: { Image(systemName: "arrow.clockwise") } @@ -127,6 +155,7 @@ struct RepositoryWorkspaceView: View { } } .sheet(isPresented: $showSettings) { SettingsSheet(viewModel: viewModel) } + .sheet(isPresented: $showAssistant) { AIAssistantView(viewModel: viewModel) } .task { await viewModel.load() } .overlay { if viewModel.isBusy { diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index 5388f17..bb59d7d 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -542,6 +542,9 @@ private struct CommitBox: View { @Bindable var viewModel: RepositoryViewModel @FocusState private var editorFocused: Bool @State private var commitAndPush: Bool + @State private var isGeneratingMessage = false + @State private var showStagingHint = false + @AppStorage("ai.hidden") private var hideAIFeatures = false init(viewModel: RepositoryViewModel) { self.viewModel = viewModel @@ -549,6 +552,10 @@ private struct CommitBox: View { _commitAndPush = State(initialValue: UserDefaults.standard.bool(forKey: key)) } + private var showAIButton: Bool { + !hideAIFeatures && AIDefaults.hasAPIKey + } + private var primaryLabel: String { if viewModel.amendMode { return commitAndPush ? "Amend & Push" : "Amend Commit" @@ -577,6 +584,29 @@ private struct CommitBox: View { .frame(height: 88) .background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .textBackgroundColor))) .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) + .overlay(alignment: .topTrailing) { + if showAIButton { + if isGeneratingMessage { + ProgressView() + .controlSize(.small) + .padding(8) + } else { + GenerateMessageButton( + viewModel: viewModel, + isGenerating: $isGeneratingMessage, + showStagingHint: $showStagingHint + ) + .padding(4) + .popover(isPresented: $showStagingHint, arrowEdge: .bottom) { + Text("Stage your changes before generating a commit message.") + .font(.caption) + .padding(10) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 220) + } + } + } + } HStack { Toggle("Amend", isOn: Binding( @@ -601,3 +631,118 @@ private struct CommitBox: View { UserDefaults.standard.set(value, forKey: key) } } + +/// An AppKit-level button that displays the sparkles icon for AI commit message generation. +/// Uses a custom NSView subclass so it receives clicks even when overlaid on a TextEditor +/// (whose NSTextView swallows mouse events at the AppKit layer, preventing SwiftUI buttons +/// from working). +private struct GenerateMessageButton: NSViewRepresentable { + let viewModel: RepositoryViewModel + @Binding var isGenerating: Bool + @Binding var showStagingHint: Bool + + final class ButtonView: NSView { + var viewModel: RepositoryViewModel? + var setGenerating: ((Bool) -> Void)? + var showStagingHint: ((Bool) -> Void)? + private let imageView = NSImageView() + private var isHovered = false + + override init(frame: NSRect) { + super.init(frame: frame) + setupImageView() + setupTrackingArea() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + private func setupImageView() { + let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .regular) + let image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Generate commit message")? + .withSymbolConfiguration(config) + imageView.image = image + imageView.contentTintColor = .secondaryLabelColor + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.centerXAnchor.constraint(equalTo: centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: centerYAnchor), + widthAnchor.constraint(equalToConstant: 20), + heightAnchor.constraint(equalToConstant: 20), + ]) + toolTip = "Generate commit message with AI" + } + + private func setupTrackingArea() { + let area = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect, .cursorUpdate], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + imageView.contentTintColor = .controlAccentColor + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + imageView.contentTintColor = .secondaryLabelColor + } + + override func cursorUpdate(with event: NSEvent) { + NSCursor.arrow.set() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.invalidateCursorRects(for: self) + } + + override func mouseDown(with event: NSEvent) { + guard let vm = viewModel else { return } + + // If nothing is staged, show a hint instead of generating. + if vm.status?.stagedFiles.isEmpty != false { + showStagingHint?(true) + return + } + + let callback = setGenerating + callback?(true) + Task { @MainActor in + do { + let message = try await AICommitMessageGenerator.generate(viewModel: vm) + if !message.isEmpty { + vm.commitMessage = message + } + } catch { + print("[Gitify AI] Commit message generation failed: \(error)") + } + callback?(false) + } + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .arrow) + } + } + + func makeNSView(context: Context) -> ButtonView { + let view = ButtonView(frame: .zero) + view.viewModel = viewModel + view.setGenerating = { isGenerating = $0 } + view.showStagingHint = { showStagingHint = $0 } + return view + } + + func updateNSView(_ view: ButtonView, context: Context) { + view.viewModel = viewModel + view.setGenerating = { isGenerating = $0 } + view.showStagingHint = { showStagingHint = $0 } + } +}