diff --git a/README.md b/README.md index e36f0cb5..8baeb5bc 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Opensource Git GUI client. * Git LFS * Issue Link * Workspace +* Using AI to generate commit message (C# port of [anjerodev/commitollama](https://github.com/anjerodev/commitollama)) > [!WARNING] > **Linux** only tested on **Debian 12** on both **X11** & **Wayland**. diff --git a/src/App.JsonCodeGen.cs b/src/App.JsonCodeGen.cs index 8daea4f9..70567af5 100644 --- a/src/App.JsonCodeGen.cs +++ b/src/App.JsonCodeGen.cs @@ -46,6 +46,8 @@ namespace SourceGit [JsonSerializable(typeof(Models.ExternalToolPaths))] [JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))] [JsonSerializable(typeof(Models.JetBrainsState))] + [JsonSerializable(typeof(Models.OpenAIChatRequest))] + [JsonSerializable(typeof(Models.OpenAIChatResponse))] [JsonSerializable(typeof(Models.ThemeOverrides))] [JsonSerializable(typeof(Models.Version))] [JsonSerializable(typeof(Models.RepositorySettings))] diff --git a/src/Commands/GenerateCommitMessage.cs b/src/Commands/GenerateCommitMessage.cs new file mode 100644 index 00000000..1bbf6cd8 --- /dev/null +++ b/src/Commands/GenerateCommitMessage.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +namespace SourceGit.Commands +{ + /// + /// A C# version of https://github.com/anjerodev/commitollama + /// + public class GenerateCommitMessage + { + public class GetDiffContent : Command + { + public GetDiffContent(string repo, Models.DiffOption opt) + { + WorkingDirectory = repo; + Context = repo; + Args = $"diff --diff-algorithm=minimal {opt}"; + } + } + + public GenerateCommitMessage(string repo, List changes, CancellationToken cancelToken, Action onProgress) + { + _repo = repo; + _changes = changes; + _cancelToken = cancelToken; + _onProgress = onProgress; + } + + public string Result() + { + try + { + var summaries = new List(); + foreach (var change in _changes) + { + if (_cancelToken.IsCancellationRequested) + return ""; + + _onProgress?.Invoke($"Analyzing {change.Path}..."); + var summary = GenerateChangeSummary(change); + summaries.Add(summary); + } + + if (_cancelToken.IsCancellationRequested) + return ""; + + _onProgress?.Invoke($"Generating commit message..."); + var builder = new StringBuilder(); + builder.Append(GenerateSubject(string.Join("", summaries))); + builder.Append("\n"); + foreach (var summary in summaries) + { + builder.Append("\n- "); + builder.Append(summary.Trim()); + } + + return builder.ToString(); + } + catch (Exception e) + { + App.RaiseException(_repo, $"Failed to generate commit message: {e}"); + return ""; + } + } + + private string GenerateChangeSummary(Models.Change change) + { + var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd(); + var diff = rs.IsSuccess ? rs.StdOut : "unknown change"; + + var prompt = new StringBuilder(); + prompt.AppendLine("You are an expert developer specialist in creating commits."); + prompt.AppendLine("Provide a super concise one sentence overall changes summary of the user `git diff` output following strictly the next rules:"); + prompt.AppendLine("- Do not use any code snippets, imports, file routes or bullets points."); + prompt.AppendLine("- Do not mention the route of file that has been change."); + prompt.AppendLine("- Simply describe the MAIN GOAL of the changes."); + prompt.AppendLine("- Output directly the summary in plain text.`"); + + var rsp = Models.OpenAI.Chat(prompt.ToString(), $"Here is the `git diff` output: {diff}"); + if (rsp != null && rsp.Choices.Count > 0) + return rsp.Choices[0].Message.Content; + + return string.Empty; + } + + private string GenerateSubject(string summary) + { + var prompt = new StringBuilder(); + prompt.AppendLine("You are an expert developer specialist in creating commits messages."); + prompt.AppendLine("Your only goal is to retrieve a single commit message."); + prompt.AppendLine("Based on the provided user changes, combine them in ONE SINGLE commit message retrieving the global idea, following strictly the next rules:"); + prompt.AppendLine("- Assign the commit {type} according to the next conditions:"); + prompt.AppendLine(" feat: Only when adding a new feature."); + prompt.AppendLine(" fix: When fixing a bug."); + prompt.AppendLine(" docs: When updating documentation."); + prompt.AppendLine(" style: When changing elements styles or design and/or making changes to the code style (formatting, missing semicolons, etc.) without changing the code logic."); + prompt.AppendLine(" test: When adding or updating tests. "); + prompt.AppendLine(" chore: When making changes to the build process or auxiliary tools and libraries. "); + prompt.AppendLine(" revert: When undoing a previous commit."); + prompt.AppendLine(" refactor: When restructuring code without changing its external behavior, or is any of the other refactor types."); + prompt.AppendLine("- Do not add any issues numeration, explain your output nor introduce your answer."); + prompt.AppendLine("- Output directly only one commit message in plain text with the next format: {type}: {commit_message}."); + prompt.AppendLine("- Be as concise as possible, keep the message under 50 characters."); + + var rsp = Models.OpenAI.Chat(prompt.ToString(), $"Here are the summaries changes: {summary}"); + if (rsp != null && rsp.Choices.Count > 0) + return rsp.Choices[0].Message.Content; + + return string.Empty; + } + + private string _repo; + private List _changes; + private CancellationToken _cancelToken; + private Action _onProgress; + } +} diff --git a/src/Models/OpenAI.cs b/src/Models/OpenAI.cs new file mode 100644 index 00000000..4acbe371 --- /dev/null +++ b/src/Models/OpenAI.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SourceGit.Models +{ + public class OpenAIChatMessage + { + [JsonPropertyName("role")] + public string Role + { + get; + set; + } + + [JsonPropertyName("content")] + public string Content + { + get; + set; + } + } + + public class OpenAIChatChoice + { + [JsonPropertyName("index")] + public int Index + { + get; + set; + } + + [JsonPropertyName("message")] + public OpenAIChatMessage Message + { + get; + set; + } + } + + public class OpenAIChatResponse + { + [JsonPropertyName("choices")] + public List Choices + { + get; + set; + } = []; + } + + public class OpenAIChatRequest + { + [JsonPropertyName("model")] + public string Model + { + get; + set; + } + + [JsonPropertyName("messages")] + public List Messages + { + get; + set; + } = []; + + public void AddMessage(string role, string content) + { + Messages.Add(new OpenAIChatMessage { Role = role, Content = content }); + } + } + + public static class OpenAI + { + public static string Server + { + get; + set; + } + + public static string ApiKey + { + get; + set; + } + + public static string Model + { + get; + set; + } + + public static bool IsValid + { + get => !string.IsNullOrEmpty(Server) && !string.IsNullOrEmpty(ApiKey) && !string.IsNullOrEmpty(Model); + } + + public static OpenAIChatResponse Chat(string prompt, string question) + { + var chat = new OpenAIChatRequest() { Model = Model }; + chat.AddMessage("system", prompt); + chat.AddMessage("user", question); + + var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(60) }; + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}"); + + var req = new StringContent(JsonSerializer.Serialize(chat, JsonCodeGen.Default.OpenAIChatRequest)); + var task = client.PostAsync(Server, req); + task.Wait(); + + var rsp = task.Result; + if (!rsp.IsSuccessStatusCode) + throw new Exception($"AI service returns error code {rsp.StatusCode}"); + + var reader = rsp.Content.ReadAsStringAsync(); + reader.Wait(); + + return JsonSerializer.Deserialize(reader.Result, JsonCodeGen.Default.OpenAIChatResponse); + } + } +} diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index 62c46ee6..0b32369c 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -1,4 +1,5 @@ + M951 419a255 255 0 00-22-209 258 258 0 00-278-124A259 259 0 00213 178a255 255 0 00-171 124 258 258 0 0032 303 255 255 0 0022 210 258 258 0 00278 124A255 255 0 00566 1024a258 258 0 00246-179 256 256 0 00171-124 258 258 0 00-32-302zM566 957a191 191 0 01-123-44l6-3 204-118a34 34 0 0017-29v-287l86 50a3 3 0 012 2v238a192 192 0 01-192 192zM154 781a191 191 0 01-23-129l6 4 204 118a33 33 0 0033 0l249-144v99a3 3 0 01-1 3L416 851a192 192 0 01-262-70zM100 337a191 191 0 01101-84V495a33 33 0 0017 29l248 143-86 50a3 3 0 01-3 0l-206-119A192 192 0 01100 336zm708 164-249-145L645 307a3 3 0 013 0l206 119a192 192 0 01-29 346v-242a34 34 0 00-17-28zm86-129-6-4-204-119a33 33 0 00-33 0L401 394V294a3 3 0 011-3l206-119a192 192 0 01285 199zm-539 176-86-50a3 3 0 01-2-2V259a192 192 0 01315-147l-6 3-204 118a34 34 0 00-17 29zm47-101 111-64 111 64v128l-111 64-111-64z M296 392h64v64h-64zM296 582v160h128V582h-64v-62h-64v62zm80 48v64h-32v-64h32zM360 328h64v64h-64zM296 264h64v64h-64zM360 456h64v64h-64zM360 200h64v64h-64zM855 289 639 73c-6-6-14-9-23-9H192c-18 0-32 14-32 32v832c0 18 14 32 32 32h640c18 0 32-14 32-32V311c0-9-3-17-9-23zM790 326H602V138L790 326zm2 562H232V136h64v64h64v-64h174v216c0 23 19 42 42 42h216v494z M71 1024V0h661L953 219V1024H71zm808-731-220-219H145V951h735V293zM439 512h-220V219h220V512zm-74-219H292v146h74v-146zm0 512h74v73h-220v-73H292v-146H218V585h147v219zm294-366h74V512H512v-73h74v-146H512V219h147v219zm74 439H512V585h220v293zm-74-219h-74v146h74v-146z M128 256h192a64 64 0 110 128H128a64 64 0 110-128zm576 192h192a64 64 0 010 128h-192a64 64 0 010-128zm-576 192h192a64 64 0 010 128H128a64 64 0 010-128zm576 0h192a64 64 0 010 128h-192a64 64 0 010-128zm0-384h192a64 64 0 010 128h-192a64 64 0 010-128zM128 448h192a64 64 0 110 128H128a64 64 0 110-128zm384-320a64 64 0 0164 64v640a64 64 0 01-128 0V192a64 64 0 0164-64z diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index de801802..c56bb3b5 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -17,6 +17,8 @@ Optional. Default is the destination folder name. Track Branch: Tracking remote branch + OpenAI Assistant + Use OpenAI to generate commit message Patch Error Raise errors and refuses to apply the patch @@ -384,6 +386,10 @@ Last year {0} years ago Preference + OPEN AI + Server + API Key + Model APPEARANCE Default Font Default Font Size diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 9ab76245..aed1c259 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -20,6 +20,8 @@ 选填。默认使用目标文件夹名称。 跟踪分支 设置上游跟踪分支 + OpenAI助手 + 使用OpenAI助手生成提交信息 应用补丁(apply) 错误 输出错误,并终止应用补丁 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 90059b41..7816b604 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -20,6 +20,8 @@ 選填。預設使用目標資料夾名稱。 追蹤分支 設定遠端追蹤分支 + OpenAI 助手 + 使用 OpenAI 產生提交消息 套用修補檔 (apply patch) 錯誤 輸出錯誤,並中止套用修補檔 diff --git a/src/Resources/Styles.axaml b/src/Resources/Styles.axaml index b5d4dddb..6de1a479 100644 --- a/src/Resources/Styles.axaml +++ b/src/Resources/Styles.axaml @@ -466,21 +466,6 @@ - - - - -