ブランチ名を考えるのが面倒くさいのでAIに考えさせる

タイトルの通りです。

私の会社ではなんと個人で作るブランチ名のルールはありません。
（人数的にも問題になっていないだけで今後どうにかするかも です）

私は
```
feature/issue#12345/some-feature
fix/issue#54321/fatal-bug
chore/issue#9999/update-docs
```
のような感じで命名しているのですが、３つ目のセグメントのブランチ目的の要約が地味に面倒だと思っていました。
かといって省略すると、それはそれでわかりにくいのでもやもやしていたのですが、「AIに考えさせればいいんじゃね？」ということで、作ってみました。

## つくる
今回は.NETツールの一つとして作ります。
別に.NETそんなに関係ないんだけどPATH通す手間とかが省けるので・・

大まかには「ISSUE番号を入れるとブランチ名候補が出る。選べば自動でスイッチしてくれる」ツールにしようと思います。

### .NETツールとして初期化
- PackAsToolをtrueに
- ToolCommandNameを定義
  - "New Branch"という意味で`nb`にしました。
- PackageOutputPathを./nupkg に
```xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
	<PackAsTool>true</PackAsTool>
	<ToolCommandName>nb</ToolCommandName>
	<PackageOutputPath>./nupkg</PackageOutputPath>
  </PropertyGroup>

</Project>
```

### APIを使う
Github APIを使うためのライブラリと、OpenAI APIを使うためのライブラリを参照します。

```xml
  <ItemGroup>
    <PackageReference Include="Betalgo.OpenAI.GPT3" Version="6.8.3" />
    <PackageReference Include="octokit" Version="5.0.2" />
  </ItemGroup>
```

### Promptを考える
色々試して今のとここんな感じのPromptで考えてもらっています。
英語が不自然かもですが、まあいいでしょう。
```
For the given issue titles in Japanese, please come up with five appropriate branch names in Git.
Branch names should be output as bullet points in Markdown format.
Follow the format below for branch names, with XXXX being the issue number and {DESCRIPTION} roughly indicating the purpose of the branch (about 15 characters).
{DESCRIPTION} must be in English with words separated by hyphens.

- For new features 
feature/issue#XXXX/{DESCRIPTION}

- For bug fixes:
fix/issue#XXXX/{DESCRIPTION}

- For changes that don't fit the above, such as CI/CD and other meta-level improvements:
chore/issue#XXXX/{DESCRIPTION}
```

### プログラムで全部を組み合わせる
gitコマンドの実行にはProcess.Startを使います。
全部解説するのは大変なのでプログラムだけおいておきます。

```cs
using Octokit;
using Octokit.Internal;
using OpenAI.GPT3.Managers;
using OpenAI.GPT3;
using OpenAI.GPT3.ObjectModels;
using OpenAI.GPT3.ObjectModels.RequestModels;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;

namespace BranchNamer;

internal class Program
{
	public static async Task Main(string[] args)
	{
		if (int.TryParse(args.FirstOrDefault(), out var issueId))
		{
			var options = new Options
			{
				IssueId = issueId
			};

			await ExecuteAsync(options);
		}
		else
		{
			Error("Invalid command line argument");
		}
	}

	private static async Task ExecuteAsync(Options options)
	{
		if (GitUtil.IsGitDirectory() == false)
		{
			Error("Git is not available on this computer or the Git repository has not been initialized.");
		}

		Console.WriteLine("switching to 'master' ...");
		if (GitUtil.Switch("master") == false)
		{
			Error("Could not switch to master branch");
		}

		Console.WriteLine("pulling 'origin/master' ...");
		if (GitUtil.Pull("origin", "master") == false)
		{
			Error("Could not pull origin/master");
		}

		int? selectedIndex = null;
		string[] newBranchNames;
		do
		{
			Console.WriteLine("ChatGPT is considering branch names ...");
			newBranchNames = await GenerateBranchNamesAsync(options);

			Console.WriteLine();
			Console.WriteLine("Please select from the following candidates: ");
			foreach (var item in newBranchNames.Select((branchName, i) => new { Index = i + 1, BranchName = branchName }))
			{
				Console.WriteLine($"{item.Index}: {item.BranchName}");
			}

			Console.WriteLine();

			do
			{
				Console.Write("Type a number. Enter 0 to retry.> ");
				if (int.TryParse(Console.ReadLine() ?? "", out var tmp))
				{
					selectedIndex = tmp - 1;
				}
			} while (selectedIndex.HasValue == false);

		} while (selectedIndex == -1);

		var newBranchName = newBranchNames[selectedIndex.Value];
		if (GitUtil.SwitchToNewBranch(branchName: newBranchName) == false)
		{
			Error($"Could not switch to new branch '{newBranchName}'");
		}

		Console.WriteLine($"Switched to new branch '{newBranchName}'");
	}

	private static async Task<string[]> GenerateBranchNamesAsync(Options options)
	{
		try
		{
			var issueTitle = await GetIssueTitleAsync(options.IssueId);
			var answer = await AskToChatGptAsync(
				new List<ChatMessage>
				{
					ChatMessage.FromSystem(Prompts.PromptForGenerateBranchName),
					ChatMessage.FromUser($"#{options.IssueId} {issueTitle}")
				});

			var result = answer
				.Replace("\r\n", "\n")
				.Replace("\r", "\n")
				.Replace("\n", "\r\n")
				.Split("\r\n")
				.Select(s => s.TrimStart('-').Trim()) // どうやってもmarkdown形式のリストで来るので処理する
				.ToArray();
			return result;
		}
		catch (Exception ex)
		{
			Error(ex.ToString()); // does not return
			return Array.Empty<string>();
		}
	}

	private static async Task<string> AskToChatGptAsync(List<ChatMessage> messages)
	{
		var openAi = new OpenAIService(
			new OpenAiOptions
			{
				ApiKey = Credentials.OpenAiToken
			});

		var chat = await openAi.ChatCompletion.CreateCompletion(
			new ChatCompletionCreateRequest
			{
				Messages = messages,
				Model = Models.ChatGpt3_5Turbo,
			});

		return chat.Choices[0].Message.Content;
	}

	private static async Task<string> GetIssueTitleAsync(int issueNumber)
	{
		var github = new GitHubClient(
			new ProductHeaderValue("branch-namer"),
			new InMemoryCredentialStore(new Octokit.Credentials(Credentials.GithubToken)));

		var issue = await github.Issue.Get("ほげ", "もげ", issueNumber); // 適宜置き換え
		return issue.Title;
	}

	[DoesNotReturn]
	private static void Error(string message)
	{
		Console.Error.WriteLine(message);
		Environment.Exit(-1);
	}
}

internal class Options
{
	public int IssueId { get; set; }
}

internal static class Prompts
{
	public const string PromptForGenerateBranchName = """"
		For the given issue titles in Japanese, please come up with five appropriate branch names in Git.
		Branch names should be output as bullet points in Markdown format.
		Follow the format below for branch names, with XXXX being the issue number and {DESCRIPTION} roughly indicating the purpose of the branch (about 15 characters).
		{DESCRIPTION} must be in English with words separated by hyphens.

		- For new features:
		```
		feature/issue#XXXX/{DESCRIPTION}
		```
		- For bug fixes:
		```
		fix/issue#XXXX/{DESCRIPTION}
		```
		- For changes that don't fit the above, such as CI/CD and other meta-level improvements:
		```
		chore/issue#XXXX/{DESCRIPTION}
		```
		"""";
}

internal static class GitUtil
{
	public static bool IsGitDirectory()
	{
		var (_, _, exitCode) = RunCommand("git status");
		return exitCode == 0;
	}

	public static bool Switch(string branchName)
	{
		var (_, _, exitCode) = RunCommand($"git switch {branchName}");
		return exitCode == 0;
	}

	public static bool SwitchToNewBranch(string branchName)
	{
		var (_, _, exitCode) = RunCommand($"git switch -c {branchName}");
		return exitCode == 0;
	}

	public static bool Pull(string remote, string branchName)
	{
		var (_, _, exitCode) = RunCommand($"git pull {remote} {branchName}");
		return exitCode == 0;
	}

	private static (string, string, int) RunCommand(string command)
	{
		var split = command.Split(' ');
		var processStartInfo = new ProcessStartInfo(split.ElementAt(0), string.Join(" ", split.Skip(1)))
		{
			CreateNoWindow = true,
			UseShellExecute = false,
			RedirectStandardOutput = true,
			RedirectStandardError = true,
			WorkingDirectory = Environment.CurrentDirectory,
		};

		var process = Process.Start(processStartInfo) ?? throw new Exception("Could not start the process.");
		process.WaitForExit();

		string? tmp;
		var stdOut = new StringBuilder();
		while ((tmp = process.StandardOutput.ReadLine() ?? null) != null)
		{
			stdOut.AppendLine(tmp);
		}

		var stdErr = new StringBuilder();
		while ((tmp = process.StandardError.ReadLine() ?? null) != null)
		{
			stdErr.AppendLine(tmp);
		}

		return (stdOut.ToString(), stdErr.ToString(), process.ExitCode);
	}
}

internal static class Credentials
{
	public const string GithubToken = "トークン";
	public const string OpenAiToken = "トークン";
}

```

## インストール
dotnet pack でツールをビルドします。
```
dotnet pack .\GitBranchNamer.csproj -c Release
```

インストールします。
```
dotnet tool install -g --add-source .\nupkg\ GitBranchNamer
```

`nb`のコマンドが叩ければ成功です。

## まとめ
AIすげえ