-
Notifications
You must be signed in to change notification settings - Fork 8
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
base: main
Are you sure you want to change the base?
Changes from all commits
63e7eb5
0290f3d
2bf3e0f
f6d6294
b414a9b
a79afdc
6e1ff48
ed93239
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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") | ||
mafredri marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
return nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
} | ||
mtojek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we include past commit titles here as well, like in There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
Uh oh!
There was an error while loading. Please reload this page.