From 2d1accd86f79c2322551dc9a47533809d270d5b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 19:18:00 +0000 Subject: [PATCH] Add --ai flag to commit, ship, and pr commands for AI message generation Uses GitHub Models API (gpt-4o-mini) to auto-generate commit messages from staged diffs and PR titles/descriptions from branch diffs. Falls back to interactive prompt when no message is provided and --ai is not set. Auth via GITHUB_TOKEN env var or gh CLI token. https://claude.ai/code/session_01CSf8jFLPGw7CicGJpkaEiU --- src/Glyph/Commands/CommitCommand.cs | 79 +++++++++++- src/Glyph/Commands/PrCommand.cs | 75 +++++++++++- src/Glyph/Commands/ShipCommand.cs | 28 ++++- src/Glyph/Services/AiService.cs | 181 ++++++++++++++++++++++++++++ 4 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 src/Glyph/Services/AiService.cs diff --git a/src/Glyph/Commands/CommitCommand.cs b/src/Glyph/Commands/CommitCommand.cs index be8ef15..128e804 100644 --- a/src/Glyph/Commands/CommitCommand.cs +++ b/src/Glyph/Commands/CommitCommand.cs @@ -8,20 +8,23 @@ public static class CommitCommand { public static Command Create() { - var messageArg = new Argument("message") { Description = "Commit message" }; + var messageArg = new Argument("message") { Description = "Commit message", DefaultValueFactory = _ => null }; var amendOption = new Option("--amend") { Description = "Amend the previous commit" }; var allOption = new Option("-A") { Description = "Stage all changes before committing" }; + var aiOption = new Option("--ai") { Description = "Generate commit message using AI" }; var command = new Command("commit") { Description = "Create a git commit" }; command.Arguments.Add(messageArg); command.Options.Add(amendOption); command.Options.Add(allOption); + command.Options.Add(aiOption); command.SetAction(async (ParseResult parseResult, CancellationToken ct) => { - var message = parseResult.GetValue(messageArg)!; + var message = parseResult.GetValue(messageArg); var amend = parseResult.GetValue(amendOption); var addAll = parseResult.GetValue(allOption); + var useAi = parseResult.GetValue(aiOption); if (addAll) { @@ -35,6 +38,27 @@ public static Command Create() } } + // Resolve commit message if not provided + if (string.IsNullOrEmpty(message)) + { + if (useAi) + { + message = await GenerateCommitMessageWithAi(); + if (message == null) + return; + } + else + { + message = AnsiConsole.Prompt( + new TextPrompt("Enter commit message:")); + if (string.IsNullOrWhiteSpace(message)) + { + AnsiConsole.MarkupLine("[red]Commit message cannot be empty.[/]"); + return; + } + } + } + var escapedMessage = message.Replace("\"", "\\\""); var args = amend ? $"commit --amend -m \"{escapedMessage}\"" @@ -60,4 +84,55 @@ public static Command Create() return command; } + + internal static async Task GenerateCommitMessageWithAi() + { + var (diffExit, diff, diffErr) = await ProcessRunner.RunAsync("git", "diff --staged"); + if (diffExit != 0 || string.IsNullOrWhiteSpace(diff)) + { + // Try unstaged diff as fallback (for ship command which stages after) + (diffExit, diff, diffErr) = await ProcessRunner.RunAsync("git", "diff"); + if (diffExit != 0 || string.IsNullOrWhiteSpace(diff)) + { + AnsiConsole.MarkupLine("[yellow]No changes found to generate a message from.[/]"); + return null; + } + } + + string? message = null; + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Generating commit message...", async _ => + { + try + { + message = await AiService.GenerateCommitMessageAsync(diff); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]AI generation failed: {Markup.Escape(ex.Message)}[/]"); + } + }); + + if (message == null) + { + AnsiConsole.MarkupLine("[red]Failed to generate commit message.[/]"); + AnsiConsole.MarkupLine("[dim]Make sure GITHUB_TOKEN is set or 'gh' CLI is authenticated.[/]"); + return null; + } + + AnsiConsole.MarkupLine($"[bold]Generated message:[/] {Markup.Escape(message)}"); + if (!AnsiConsole.Confirm("Use this message?", defaultValue: true)) + { + message = AnsiConsole.Prompt( + new TextPrompt("Enter commit message:")); + if (string.IsNullOrWhiteSpace(message)) + { + AnsiConsole.MarkupLine("[red]Commit message cannot be empty.[/]"); + return null; + } + } + + return message; + } } diff --git a/src/Glyph/Commands/PrCommand.cs b/src/Glyph/Commands/PrCommand.cs index c257442..f1a860d 100644 --- a/src/Glyph/Commands/PrCommand.cs +++ b/src/Glyph/Commands/PrCommand.cs @@ -12,13 +12,16 @@ public static Command Create() { Description = "PR title (defaults to branch name)" }; + var aiOption = new Option("--ai") { Description = "Generate PR title and description using AI" }; var command = new Command("pr") { Description = "Create a pull request into the parent branch" }; command.Options.Add(titleOption); + command.Options.Add(aiOption); command.SetAction(async (ParseResult parseResult, CancellationToken ct) => { var title = parseResult.GetValue(titleOption); + var useAi = parseResult.GetValue(aiOption); using var git = new GitService(); var current = git.CurrentBranchName; var parent = git.GetParentBranch(current); @@ -32,12 +35,37 @@ public static Command Create() return; } - // Create PR via gh CLI + string? body = null; + + if (useAi && string.IsNullOrEmpty(title)) + { + // Generate both title and body via AI + var generated = await GeneratePrWithAi(current, parent); + if (generated != null) + { + title = generated.Value.Title; + body = generated.Value.Body; + } + } + + // Fall back to defaults var prTitle = title ?? current.Replace("-", " ").Replace("/", ": "); + AnsiConsole.MarkupLine($"Creating PR: [bold]{Markup.Escape(prTitle)}[/] -> [blue]{parent}[/]"); - var (exitCode, output, error) = await ProcessRunner.RunAsync( - "gh", $"pr create --base {parent} --title \"{prTitle}\" --fill"); + var escapedTitle = prTitle.Replace("\"", "\\\""); + string ghArgs; + if (!string.IsNullOrEmpty(body)) + { + var escapedBody = body.Replace("\"", "\\\""); + ghArgs = $"pr create --base {parent} --title \"{escapedTitle}\" --body \"{escapedBody}\""; + } + else + { + ghArgs = $"pr create --base {parent} --title \"{escapedTitle}\" --fill"; + } + + var (exitCode, output, error) = await ProcessRunner.RunAsync("gh", ghArgs); if (exitCode == 0) { @@ -55,4 +83,45 @@ public static Command Create() return command; } + + private static async Task<(string Title, string Body)?> GeneratePrWithAi(string branchName, string parentBranch) + { + var (diffExit, diff, _) = await ProcessRunner.RunAsync("git", $"diff {parentBranch}...HEAD"); + if (diffExit != 0 || string.IsNullOrWhiteSpace(diff)) + { + AnsiConsole.MarkupLine("[yellow]No changes found between branch and parent.[/]"); + return null; + } + + (string Title, string Body)? result = null; + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Generating PR description...", async _ => + { + try + { + result = await AiService.GeneratePrDescriptionAsync(diff, branchName, parentBranch); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]AI generation failed: {Markup.Escape(ex.Message)}[/]"); + } + }); + + if (result == null) + { + AnsiConsole.MarkupLine("[red]Failed to generate PR description.[/]"); + AnsiConsole.MarkupLine("[dim]Make sure GITHUB_TOKEN is set or 'gh' CLI is authenticated.[/]"); + return null; + } + + AnsiConsole.MarkupLine($"[bold]Generated title:[/] {Markup.Escape(result.Value.Title)}"); + AnsiConsole.MarkupLine("[bold]Generated body:[/]"); + AnsiConsole.WriteLine(result.Value.Body); + + if (!AnsiConsole.Confirm("Use this PR description?", defaultValue: true)) + return null; + + return result; + } } diff --git a/src/Glyph/Commands/ShipCommand.cs b/src/Glyph/Commands/ShipCommand.cs index 6fb70b4..4782c0b 100644 --- a/src/Glyph/Commands/ShipCommand.cs +++ b/src/Glyph/Commands/ShipCommand.cs @@ -8,14 +8,17 @@ public static class ShipCommand { public static Command Create() { - var messageArg = new Argument("message") { Description = "Commit message" }; + var messageArg = new Argument("message") { Description = "Commit message", DefaultValueFactory = _ => null }; + var aiOption = new Option("--ai") { Description = "Generate commit message using AI" }; var command = new Command("ship") { Description = "Stage all changes, commit, and push to origin" }; command.Arguments.Add(messageArg); + command.Options.Add(aiOption); command.SetAction(async (ParseResult parseResult, CancellationToken ct) => { - var message = parseResult.GetValue(messageArg)!; + var message = parseResult.GetValue(messageArg); + var useAi = parseResult.GetValue(aiOption); using var git = new GitService(); var current = git.CurrentBranchName; @@ -29,6 +32,27 @@ public static Command Create() return; } + // Resolve commit message if not provided + if (string.IsNullOrEmpty(message)) + { + if (useAi) + { + message = await CommitCommand.GenerateCommitMessageWithAi(); + if (message == null) + return; + } + else + { + message = AnsiConsole.Prompt( + new TextPrompt("Enter commit message:")); + if (string.IsNullOrWhiteSpace(message)) + { + AnsiConsole.MarkupLine("[red]Commit message cannot be empty.[/]"); + return; + } + } + } + // Commit var escapedMessage = message.Replace("\"", "\\\""); var (commitExit, commitOut, commitErr) = await ProcessRunner.RunAsync("git", $"commit -m \"{escapedMessage}\""); diff --git a/src/Glyph/Services/AiService.cs b/src/Glyph/Services/AiService.cs new file mode 100644 index 0000000..894768c --- /dev/null +++ b/src/Glyph/Services/AiService.cs @@ -0,0 +1,181 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Glyph.Services; + +[JsonSerializable(typeof(AiService.ChatRequest))] +[JsonSerializable(typeof(AiService.ChatResponse))] +internal partial class AiJsonContext : JsonSerializerContext; + +public static class AiService +{ + private const string Endpoint = "https://models.inference.ai.azure.com/chat/completions"; + private const string Model = "gpt-4o-mini"; + private const int MaxDiffLength = 8000; + + public static async Task GenerateCommitMessageAsync(string diff) + { + var prompt = """ + You are a concise commit message generator. Given a git diff, write a single-line + commit message following conventional commit style (e.g. feat:, fix:, refactor:, docs:, chore:). + Be specific about what changed. Do not include a body or footer. + Output ONLY the commit message, nothing else. + """; + + return await CallAsync(prompt, TruncateDiff(diff)); + } + + public static async Task<(string Title, string Body)?> GeneratePrDescriptionAsync( + string diff, string branchName, string parentBranch) + { + var prompt = $""" + You are a pull request description generator. Given a git diff for a branch being merged + from "{branchName}" into "{parentBranch}", generate a PR title and body. + + Respond in EXACTLY this format (no other text): + TITLE: + BODY: + ## Summary + <2-4 bullet points describing the changes> + + ## Changes + + """; + + var result = await CallAsync(prompt, TruncateDiff(diff)); + if (result == null) + return null; + + return ParsePrResponse(result); + } + + private static (string Title, string Body) ParsePrResponse(string response) + { + var lines = response.Split('\n', StringSplitOptions.None); + string title = ""; + var bodyLines = new List(); + var inBody = false; + + foreach (var line in lines) + { + if (line.StartsWith("TITLE:", StringComparison.OrdinalIgnoreCase)) + { + title = line["TITLE:".Length..].Trim(); + } + else if (line.StartsWith("BODY:", StringComparison.OrdinalIgnoreCase)) + { + inBody = true; + var rest = line["BODY:".Length..].Trim(); + if (!string.IsNullOrEmpty(rest)) + bodyLines.Add(rest); + } + else if (inBody) + { + bodyLines.Add(line); + } + } + + if (string.IsNullOrEmpty(title)) + title = lines[0]; // Fallback: use first line as title + + var body = string.Join('\n', bodyLines).Trim(); + return (title, body); + } + + public static async Task GetTokenAsync() + { + // 1. Check environment variable + var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + if (!string.IsNullOrEmpty(token)) + return token; + + // 2. Fall back to gh CLI + try + { + var (exitCode, output, _) = await ProcessRunner.RunAsync("gh", "auth token"); + if (exitCode == 0 && !string.IsNullOrEmpty(output)) + return output.Trim(); + } + catch + { + // gh not available + } + + return null; + } + + private static async Task CallAsync(string systemPrompt, string userMessage) + { + var token = await GetTokenAsync(); + if (token == null) + return null; + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + http.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var request = new ChatRequest + { + Model = Model, + Messages = + [ + new ChatMessage { Role = "system", Content = systemPrompt }, + new ChatMessage { Role = "user", Content = userMessage } + ], + MaxTokens = 500, + Temperature = 0.3 + }; + + var response = await http.PostAsJsonAsync(Endpoint, request, AiJsonContext.Default.ChatRequest); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(AiJsonContext.Default.ChatResponse); + return result?.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); + } + + private static string TruncateDiff(string diff) + { + if (diff.Length <= MaxDiffLength) + return diff; + return diff[..MaxDiffLength] + "\n... (diff truncated)"; + } + + // JSON models for GitHub Models API (OpenAI-compatible) + + internal sealed class ChatRequest + { + [JsonPropertyName("model")] + public string Model { get; set; } = ""; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; + + [JsonPropertyName("max_tokens")] + public int MaxTokens { get; set; } + + [JsonPropertyName("temperature")] + public double Temperature { get; set; } + } + + internal sealed class ChatMessage + { + [JsonPropertyName("role")] + public string Role { get; set; } = ""; + + [JsonPropertyName("content")] + public string Content { get; set; } = ""; + } + + internal sealed class ChatResponse + { + [JsonPropertyName("choices")] + public List? Choices { get; set; } + } + + internal sealed class ChatChoice + { + [JsonPropertyName("message")] + public ChatMessage? Message { get; set; } + } +}