Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .github/workflows/review.yml
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]

Copy link
Copy Markdown

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 the on trigger is crucial for the new reply functionality and is well-implemented.

jobs:
review:
runs-on: macos-latest
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Consider using ubuntu-latest instead of macos-latest for the runs-on property. Ubuntu runners are generally more cost-effective and often faster for Go applications, unless there's a specific macOS requirement not apparent here.

steps:
- uses: actions/checkout@v4
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Using macos-latest for runs-on is quite expensive. For a Go build and a simple action execution, ubuntu-latest should be sufficient and significantly cheaper. Consider changing this unless there's a specific macOS dependency.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: The runs-on: macos-latest is still present. As noted previously, ubuntu-latest is generally more cost-effective and often faster for Go applications unless there's a specific macOS requirement not apparent here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: The runs-on: macos-latest is still present. As noted previously, ubuntu-latest is generally more cost-effective and often faster for Go applications unless there's a specific macOS requirement not apparent here.


- uses: ./
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: The runs-on: macos-latest is still present. As noted previously, ubuntu-latest is generally more cost-effective and often faster for Go applications unless there's a specific macOS requirement not apparent here.

with:
gemini-api-key: ${{ secrets.GEMINI_API_KEY }}
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*.dll
*.so
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: Changing codereview to /codereview makes the exclusion more specific to the root binary, which is a good minor improvement.

*.dylib
codereview
/codereview
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: Changing codereview to /codereview makes the exclusion more specific to the root binary, which is a good minor improvement.


# Test binary
*.test
Expand Down
21 changes: 21 additions & 0 deletions .projections.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

praise: This new .projections.json file provides useful configuration for development environments and tooling, improving the developer experience.

"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"
}
}
48 changes: 48 additions & 0 deletions action.yml
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'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: While 1.24 works, it's generally recommended to pin to a specific patch version (e.g., 1.22.x) or just use 1 to avoid unexpected breaking changes if the upstream action updates 1.24 to a future 1.24.x that has issues, though for Go 1.24 is not a strict release yet.

cache-dependency-path: go.sum

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: The go-version: '1.24' remains. While 1.24 is fine, it's generally safer to pin to a specific patch version (e.g., 1.22.x) or just use 1 for stability. As 1.24 is not a stable release yet, this is less critical, but still a general best practice.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: The go-version: '1.24' remains. While 1.24 is fine, it's generally safer to pin to a specific patch version (e.g., 1.22.x) or just use 1 for stability. As 1.24 is not a stable release yet, this is less critical, but still a general best practice.

- name: Build
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: The go-version: '1.24' remains. While 1.24 is fine, it's generally safer to pin to a specific patch version (e.g., 1.22.x) or just use 1 for stability, although 1.24 isn't a stable release yet, so it's less critical here.

shell: bash
run: cd ${{ github.action_path }} && go build -o $RUNNER_TEMP/codereview ./cmd/codereview
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Building into $RUNNER_TEMP/codereview is fine, but it might be slightly cleaner to build directly into the action's path (e.g., cd ${{ github.action_path }} && go build -o codereview ./cmd/codereview) and then run $(github.action_path)/codereview. This avoids potential issues if $RUNNER_TEMP has unexpected contents or permissions, although less likely with GitHub-managed runners.


- 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
185 changes: 185 additions & 0 deletions cmd/codereview/main.go
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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

praise: The overall structure with runReview and runReply functions clearly separates concerns and makes the main logic easy to follow.

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
}
Loading
Loading