diff --git a/COMMITS.md b/COMMITS.md index 2b4ba45..d93ea10 100644 --- a/COMMITS.md +++ b/COMMITS.md @@ -2,7 +2,7 @@ This style guide is used chiefly to test that aicommit follows the style guide. * Only provide a multi-line message when the change is non-trivial. -* For example, a few lines changed is trivial. Prefer a single-line message. -* Most changes under 100 lines changed are trivial and only need a single-line - message. -* Never begin the commit with an emoji \ No newline at end of file +* For example, a few lines change is trivial. Prefer a single-line message. +* Most changes under 100 lines changed are trivial and only need a single-line message. +* Never begin the commit with an emoji. +* Mind standard Conventional Commits v1.0.0 using most appropriate type. diff --git a/cmd/aicommit/lint.go b/cmd/aicommit/lint.go new file mode 100644 index 0000000..9b58aa5 --- /dev/null +++ b/cmd/aicommit/lint.go @@ -0,0 +1,95 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/coder/aicommit" + "github.com/coder/serpent" + "github.com/sashabaranov/go-openai" +) + +func lint(inv *serpent.Invocation, opts runOptions) error { + workdir, err := os.Getwd() + if err != nil { + return err + } + + // Build linting prompt considering role, style guide, commit message + msgs, err := aicommit.BuildLintPrompt(inv.Stdout, workdir, opts.lint) + if err != nil { + return err + } + if len(opts.context) > 0 { + msgs = append(msgs, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleSystem, + Content: "The user has provided additional context that MUST be" + + " included in the commit message", + }) + for _, context := range opts.context { + msgs = append(msgs, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: context, + }) + } + } + + ctx := inv.Context() + if debugMode { + for _, msg := range msgs { + debugf("%s (%v tokens)\n%s\n", msg.Role, aicommit.CountTokens(msg), msg.Content) + } + } + + // Stream AI response + stream, err := opts.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ + Model: opts.model, + Stream: true, + Temperature: 0, + StreamOptions: &openai.StreamOptions{ + IncludeUsage: true, + }, + Messages: msgs, + }) + if err != nil { + return err + } + defer stream.Close() + + var validationFailed bool + + for { + resp, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + debugf("stream EOF") + break + } + return err + } + + if len(resp.Choices) > 0 { + c := resp.Choices[0].Delta.Content + inv.Stdout.Write([]byte(c)) + + if strings.HasPrefix(c, "❌") { + validationFailed = true + } + } else { + inv.Stdout.Write([]byte("\n")) + } + + // Usage is only sent in the last message. + if resp.Usage != nil { + debugf("total tokens: %d", resp.Usage.TotalTokens) + } + } + + if validationFailed { + return fmt.Errorf("validation failed") + } + return nil +} diff --git a/cmd/aicommit/main.go b/cmd/aicommit/main.go index dcfb210..9ca14ba 100644 --- a/cmd/aicommit/main.go +++ b/cmd/aicommit/main.go @@ -199,6 +199,7 @@ type runOptions struct { dryRun bool amend bool ref string + lint string context []string } @@ -261,6 +262,11 @@ func main() { if len(inv.Args) > 0 { opts.ref = inv.Args[0] } + + if opts.lint != "" { + return lint(inv, opts) + } + return run(inv, opts) }, Options: []serpent.Option{ @@ -308,6 +314,12 @@ func main() { Description: "Amend the last commit.", Value: serpent.BoolOf(&opts.amend), }, + { + Name: "lint", + Description: "Lint the commit message.", + Flag: "lint", + Value: serpent.StringOf(&opts.lint), + }, { Name: "context", Description: "Extra context beyond the diff to consider when generating the commit message.", diff --git a/prompt.go b/prompt.go index 820ed47..82df210 100644 --- a/prompt.go +++ b/prompt.go @@ -152,16 +152,6 @@ func BuildPrompt( }, } - gitRoot, err := findGitRoot(dir) - if err != nil { - return nil, fmt.Errorf("find git root: %w", err) - } - - repo, err := git.PlainOpen(gitRoot) - if err != nil { - return nil, fmt.Errorf("open repo %q: %w", dir, err) - } - var buf bytes.Buffer // Get the working directory diff if err := generateDiff(&buf, dir, commitHash, amend); err != nil { @@ -182,10 +172,11 @@ func BuildPrompt( targetDiffString := buf.String() - // Get the HEAD reference - head, err := repo.Head() + commitMsgs, err := commitMessages(dir, commitHash) if err != nil { - // No commits yet + return nil, fmt.Errorf("can't read commit messages: %w", err) + } + if len(commitMsgs) == 0 { fmt.Fprintln(log, "no commits yet") resp = append(resp, openai.ChatCompletionMessage{ Role: openai.ChatMessageRoleUser, @@ -194,46 +185,6 @@ func BuildPrompt( return resp, nil } - // Create a log options struct - logOptions := &git.LogOptions{ - From: head.Hash(), - Order: git.LogOrderCommitterTime, - } - - // Get the commit iterator - commitIter, err := repo.Log(logOptions) - if err != nil { - return nil, fmt.Errorf("get commit iterator: %w", err) - } - defer commitIter.Close() - - // Collect the last N commits - var commits []*object.Commit - for i := 0; i < 300; i++ { - commit, err := commitIter.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, fmt.Errorf("iterate commits: %w", err) - } - // Ignore if commit equals ref, because we are trying to recalculate - // that particular commit's message. - if commit.Hash.String() == commitHash { - continue - } - commits = append(commits, commit) - - } - - // We want to reverse the commits so that the most recent commit is the - // last or "most recent" in the chat. - reverseSlice(commits) - - var commitMsgs []string - for _, commit := range commits { - commitMsgs = append(commitMsgs, Ellipse(commit.Message, 1000)) - } // We provide the commit messages in case the actual commit diffs are cut // off due to token limits. resp = append(resp, openai.ChatCompletionMessage{ @@ -276,6 +227,159 @@ func BuildPrompt( return resp, nil } +func BuildLintPrompt(log io.Writer, dir, commitMessage string) ([]openai.ChatCompletionMessage, error) { + resp := []openai.ChatCompletionMessage{ + // Describe the role + { + Role: openai.ChatMessageRoleSystem, + Content: strings.Join([]string{ + "You are `aicommit`, a commit title linting tool.", + "You are currently operating in PR title linting mode with access to repository history to ensure consistency.", + "Rules to follow:", + "1. Apply the repository's style guide strictly. Do not assume beyond the explicit rules provided.", + "2. Generate a linting report formatted as follows:", + " * Prefix each line:", + " * ✅ for passed rules", + " * ❌ for violated rules", + " * 🤫 for non-applicable rules, appending (non-applicable).", + " * Include all rules in the report. Do not skip any.", + "3. If violations occur, suggest a single corrected PR title prefixed with `suggestion`: (plain text, no code formatting). If no violations exist, skip the suggestion.", + "4. Only output the report and suggestion. Avoid any additional text or explanations for the suggestion.", + }, "\n"), + }, + // Output example + { + Role: openai.ChatMessageRoleSystem, + Content: strings.Join([]string{ + "Output Example:", + "", + "Negative Report", + "❌ Rule 1: Limit the subject line to 50 characters.", + "✅ Rule 2: Use the imperative mood.", + "✅ Rule 3: Capitalize subject and omit period.", + "🤫 Rule 5: Include a body only if necessary. (non-applicable)", + "", + "suggestion: fix: reduce false positives in GetWorkspacesEligibleForTransition", + }, "\n"), + }, + } + + // Describe style guide rules + styleGuide, err := readStyleGuide(dir) + if err != nil { + return nil, err + } + resp = append(resp, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleSystem, + Content: strings.Join([]string{ + "Style Guide Rules:", + styleGuide, + }, "\n"), + }) + + // Previous commit messages + commitMsgs, err := commitMessages(dir, "") + if err != nil { + return nil, fmt.Errorf("can't read commit messages: %w", err) + } + if len(commitMsgs) == 0 { + resp = append(resp, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: "No commits in the repository yet.", + }) + } else { + resp = append(resp, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleSystem, + Content: "Here are recent commit messages in the same repository:\n" + + mustJSON(commitMsgs), + }, + ) + } + + // Provide commit message to lint + resp = append(resp, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleSystem, + Content: "Commit Message to Lint:\n" + commitMessage, + }) + return resp, nil +} + +func readStyleGuide(dir string) (string, error) { + styleGuide, err := findRepoStyleGuide(dir) + if err != nil { + return "", fmt.Errorf("find repository style guide: %w", err) + } else if styleGuide != "" { + return styleGuide, nil + } + + styleGuide, err = findUserStyleGuide() + if err != nil { + return "", fmt.Errorf("find user style guide: %w", err) + } else if styleGuide != "" { + return styleGuide, nil + } + return defaultUserStyleGuide, nil +} + +func commitMessages(dir string, commitHash string) ([]string, error) { + gitRoot, err := findGitRoot(dir) + if err != nil { + return nil, fmt.Errorf("find Git root: %w", err) + } + + repo, err := git.PlainOpen(gitRoot) + if err != nil { + return nil, fmt.Errorf("open repository %q: %w", dir, err) + } + + // Get the HEAD reference + head, err := repo.Head() + if err != nil { + return nil, nil // no commits yet + } + + // Create a log options struct + logOptions := &git.LogOptions{ + From: head.Hash(), + Order: git.LogOrderCommitterTime, + } + + // Get the commit iterator + commitIter, err := repo.Log(logOptions) + if err != nil { + return nil, fmt.Errorf("get commit iterator: %w", err) + } + defer commitIter.Close() + + // Collect the last N commits + var commits []*object.Commit + for i := 0; i < 300; i++ { + commit, err := commitIter.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("iterate commits: %w", err) + } + // Ignore if commit equals ref, because we are trying to recalculate + // that particular commit's message. + if commitHash != "" && commit.Hash.String() == commitHash { + continue + } + commits = append(commits, commit) + } + + // We want to reverse the commits so that the most recent commit is the + // last or "most recent" in the chat. + reverseSlice(commits) + + var msgs []string + for _, commit := range commits { + msgs = append(msgs, Ellipse(commit.Message, 1000)) + } + return msgs, nil +} + // generateDiff uses the git CLI to generate a diff for the given reference. // If refName is empty, it will generate a diff of staged changes for the working directory. func generateDiff(w io.Writer, dir string, refName string, amend bool) error {