-
Notifications
You must be signed in to change notification settings - Fork 0
feat: LLM-powered PR code review action #1
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
Changes from all commits
48d1e6f
560afe2
16deadb
b36c65b
e1430a4
c08f5af
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,17 @@ | ||
| name: Code Review | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize] | ||
| pull_request_review_comment: | ||
| types: [created] | ||
|
|
||
| jobs: | ||
| review: | ||
| runs-on: macos-latest | ||
|
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. suggestion: Consider using |
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
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. suggestion: Using 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. suggestion: The 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. suggestion: The |
||
|
|
||
| - uses: ./ | ||
|
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. suggestion: The |
||
| with: | ||
| gemini-api-key: ${{ secrets.GEMINI_API_KEY }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,7 @@ | |
| *.dll | ||
| *.so | ||
|
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. nit: Changing |
||
| *.dylib | ||
| codereview | ||
| /codereview | ||
|
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. nit: Changing |
||
|
|
||
| # Test binary | ||
| *.test | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| { | ||
|
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. praise: This new |
||
| "internal/*.go": { | ||
| "alternate": "internal/{}_test.go", | ||
| "type": "source" | ||
| }, | ||
| "internal/*_test.go": { | ||
| "alternate": "internal/{}.go", | ||
| "type": "test" | ||
| }, | ||
| "internal/**/*.go": { | ||
| "alternate": "internal/**/{}_test.go", | ||
| "type": "source" | ||
| }, | ||
| "internal/**/*_test.go": { | ||
| "alternate": "internal/**/{}.go", | ||
| "type": "test" | ||
| }, | ||
| "cmd/codereview/*.go": { | ||
| "type": "command" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| name: 'Code Review' | ||
| description: 'LLM-powered PR code review with conventional comments' | ||
|
|
||
| inputs: | ||
| github-token: | ||
| description: 'GitHub token for API access' | ||
| required: true | ||
| default: '${{ github.token }}' | ||
| provider: | ||
| description: 'LLM provider' | ||
| required: false | ||
| default: 'gemini' | ||
| gemini-api-key: | ||
| description: 'API key for Google Gemini' | ||
| required: false | ||
| model: | ||
| description: 'Model name override' | ||
| required: false | ||
| instructions: | ||
| description: 'Additional review instructions' | ||
| required: false | ||
| bot-login: | ||
| description: 'Bot username to ignore for loop prevention with custom tokens' | ||
| required: false | ||
|
|
||
| runs: | ||
| using: 'composite' | ||
| steps: | ||
| - name: Setup Go | ||
| uses: actions/setup-go@v5 | ||
| with: | ||
| go-version: '1.24' | ||
|
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. nit: While |
||
| cache-dependency-path: go.sum | ||
|
|
||
|
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. nit: The 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. nit: The |
||
| - name: Build | ||
|
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. nit: The |
||
| shell: bash | ||
| run: cd ${{ github.action_path }} && go build -o $RUNNER_TEMP/codereview ./cmd/codereview | ||
|
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. suggestion: Building into |
||
|
|
||
| - name: Review | ||
| shell: bash | ||
| env: | ||
| INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} | ||
| INPUT_PROVIDER: ${{ inputs.provider }} | ||
| INPUT_GEMINI_API_KEY: ${{ inputs.gemini-api-key }} | ||
| INPUT_MODEL: ${{ inputs.model }} | ||
| INPUT_INSTRUCTIONS: ${{ inputs.instructions }} | ||
| INPUT_BOT_LOGIN: ${{ inputs.bot-login }} | ||
| run: $RUNNER_TEMP/codereview | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/monokrome/codereview/internal/action" | ||
| "github.com/monokrome/codereview/internal/diff" | ||
| "github.com/monokrome/codereview/internal/github" | ||
| "github.com/monokrome/codereview/internal/prompt" | ||
| "github.com/monokrome/codereview/internal/provider" | ||
| "github.com/monokrome/codereview/internal/provider/gemini" | ||
| "github.com/monokrome/codereview/internal/review" | ||
| ) | ||
|
|
||
| const defaultBotLogin = "github-actions[bot]" | ||
|
|
||
| func main() { | ||
| if err := run(); err != nil { | ||
| fmt.Fprintf(os.Stderr, "error: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| } | ||
|
|
||
| func run() error { | ||
| ctx := context.Background() | ||
|
|
||
| cfg, err := action.Parse() | ||
| if err != nil { | ||
| return fmt.Errorf("parsing inputs: %w", err) | ||
| } | ||
|
|
||
| var providerFn provider.ReviewFunc | ||
| switch cfg.Provider { | ||
|
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. praise: The overall structure with |
||
| case "gemini": | ||
| if cfg.GeminiAPIKey == "" { | ||
| return fmt.Errorf("INPUT_GEMINI_API_KEY is required for gemini provider") | ||
| } | ||
| providerFn = gemini.New(cfg.GeminiAPIKey, cfg.Model) | ||
| default: | ||
| return fmt.Errorf("unsupported provider: %s", cfg.Provider) | ||
| } | ||
|
|
||
| gh := github.New(cfg.GitHubToken) | ||
|
|
||
| switch cfg.Mode { | ||
| case action.ModeReview: | ||
| return runReview(ctx, cfg, providerFn, gh) | ||
| case action.ModeReply: | ||
| return runReply(ctx, cfg, providerFn, gh) | ||
| default: | ||
| return fmt.Errorf("unknown mode: %s", cfg.Mode) | ||
| } | ||
| } | ||
|
|
||
| func runReview(ctx context.Context, cfg action.Config, providerFn provider.ReviewFunc, gh *github.Client) error { | ||
| diffText, err := gh.FetchDiff(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber) | ||
| if err != nil { | ||
| return fmt.Errorf("fetching diff: %w", err) | ||
| } | ||
|
|
||
| botLogin := cfg.BotLogin | ||
| if botLogin == "" { | ||
| botLogin = defaultBotLogin | ||
| } | ||
|
|
||
| priorGH, err := gh.FetchBotReviewComments(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, botLogin) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "warning: could not fetch prior comments: %v\n", err) | ||
| } | ||
|
|
||
| var priorComments []prompt.PriorComment | ||
| for _, pc := range priorGH { | ||
| priorComments = append(priorComments, prompt.PriorComment{ | ||
| Path: pc.Path, | ||
| Body: pc.Body, | ||
| }) | ||
| } | ||
|
|
||
| fileContents := fetchChangedFiles(ctx, gh, cfg, diffText) | ||
|
|
||
| result, err := review.Run(ctx, review.Config{ | ||
| Diff: diffText, | ||
| Provider: providerFn, | ||
| Instructions: cfg.Instructions, | ||
| PriorComments: priorComments, | ||
| FileContents: fileContents, | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("running review: %w", err) | ||
| } | ||
|
|
||
| if err := gh.SubmitReview(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, cfg.CommitSHA, result); err != nil { | ||
| return fmt.Errorf("submitting review: %w", err) | ||
| } | ||
|
|
||
| fmt.Fprintf(os.Stderr, "review submitted: %s\n", result.Verdict) | ||
| return nil | ||
| } | ||
|
|
||
| func runReply(ctx context.Context, cfg action.Config, providerFn provider.ReviewFunc, gh *github.Client) error { | ||
| if cfg.SkipReply { | ||
| fmt.Fprintf(os.Stderr, "skipping: comment is from bot or is a top-level comment\n") | ||
| return nil | ||
| } | ||
|
|
||
| thread, err := gh.FetchCommentThread(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, cfg.Comment.CommentID) | ||
| if err != nil { | ||
| return fmt.Errorf("fetching comment thread: %w", err) | ||
| } | ||
|
|
||
| var messages []prompt.ThreadMessage | ||
| for _, tc := range thread { | ||
| messages = append(messages, prompt.ThreadMessage{ | ||
| Author: tc.UserLogin, | ||
| Body: tc.Body, | ||
| }) | ||
| } | ||
|
|
||
| result, err := review.RunReply(ctx, review.ReplyConfig{ | ||
| Provider: providerFn, | ||
| Thread: messages, | ||
| DiffHunk: cfg.Comment.DiffHunk, | ||
| Instructions: cfg.Instructions, | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("generating reply: %w", err) | ||
| } | ||
|
|
||
| replyTo := cfg.Comment.InReplyToID | ||
| if replyTo == 0 { | ||
| replyTo = cfg.Comment.CommentID | ||
| } | ||
|
|
||
| if err := gh.ReplyToComment(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, replyTo, result.Reply); err != nil { | ||
| return fmt.Errorf("posting reply: %w", err) | ||
| } | ||
|
|
||
| if result.Resolved { | ||
| botLogin := cfg.BotLogin | ||
| if botLogin == "" { | ||
| botLogin = defaultBotLogin | ||
| } | ||
|
|
||
| rootID := cfg.Comment.InReplyToID | ||
| if rootID == 0 { | ||
| rootID = cfg.Comment.CommentID | ||
| } | ||
|
|
||
| if err := gh.ResolveThread(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, rootID, botLogin); err != nil { | ||
| fmt.Fprintf(os.Stderr, "warning: could not resolve thread: %v\n", err) | ||
| } else { | ||
| fmt.Fprintf(os.Stderr, "thread resolved\n") | ||
| } | ||
| } | ||
|
|
||
| fmt.Fprintf(os.Stderr, "reply posted\n") | ||
| return nil | ||
| } | ||
|
|
||
| func fetchChangedFiles(ctx context.Context, gh *github.Client, cfg action.Config, diffText string) map[string]string { | ||
| files, err := diff.Parse(diffText) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "warning: could not parse diff for file fetching: %v\n", err) | ||
| return nil | ||
| } | ||
|
|
||
| contents := make(map[string]string) | ||
| for _, f := range files { | ||
| content, err := gh.FetchFile(ctx, cfg.Owner, cfg.Repo, cfg.CommitSHA, f.Path) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "warning: could not fetch %s: %v\n", f.Path, err) | ||
| continue | ||
| } | ||
|
|
||
| if content == "" { | ||
| continue | ||
| } | ||
|
|
||
| contents[f.Path] = content | ||
| } | ||
|
|
||
| return contents | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
praise: The addition of
pull_request_review_comment: types: [created]to theontrigger is crucial for the new reply functionality and is well-implemented.