Skip to content

feat: add commit message linter #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions COMMITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
* 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.
95 changes: 95 additions & 0 deletions cmd/aicommit/lint.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions cmd/aicommit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ type runOptions struct {
dryRun bool
amend bool
ref string
lint string
context []string
}

Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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.",
Expand Down
210 changes: 157 additions & 53 deletions prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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{
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we include past commit titles here as well, like in BuildPrompt, for reference of the style? Ideally a solid set of rules is probably better than what was written in the past, so this is just a thought.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure. Theoretically, the style guide rules should be sufficient. I skipped them to significantly reduce the number of $ tokens in the prompt.

Do you think we should experiment with previous commit messages?

}

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 {
Expand Down