Skip to content

feat: LLM-powered PR code review action#1

Merged
monokrome merged 6 commits intomainfrom
feat/initial-implementation
Mar 14, 2026
Merged

feat: LLM-powered PR code review action#1
monokrome merged 6 commits intomainfrom
feat/initial-implementation

Conversation

@monokrome
Copy link
Member

Summary

  • Composite GitHub Action (macOS runner) that reviews PRs using configurable LLM providers
  • Posts inline review comments using conventional comment format (nit:, issue:, suggestion:, etc.)
  • Submits review verdict (APPROVE, REQUEST_CHANGES, COMMENT) based on findings
  • Starts with Gemini provider, designed for easy addition of others
  • Includes dogfooding workflow to review its own PRs

Test plan

  • Verify the review workflow triggers on this PR
  • Check that inline comments appear with correct conventional labels
  • Verify the review verdict is appropriate

Composite GitHub Action that reviews pull requests using configurable
LLM providers. Posts inline review comments using conventional comment
format (nit:, issue:, suggestion:, etc.) and submits a verdict.

Starts with Gemini support, designed to add more providers easily.
Includes dogfooding workflow to review its own PRs.
LLMs sometimes reference lines outside the diff. Filter those out
with warnings instead of aborting the entire review.
Also fixes cache-dependency-path for setup-go.
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Overall, the project is well-structured with good separation of concerns and robust error handling, especially in the filterValidComments function. However, an issue was found with the diff hunk header reconstruction for the LLM prompt, and a few minor suggestions were identified.


jobs:
review:
runs-on: macos-latest

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.


if cfg.Provider == "" {
cfg.Provider = "gemini"
}

Choose a reason for hiding this comment

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

suggestion: The default for provider is already set to gemini in action.yml. This line (if cfg.Provider == "" { cfg.Provider = "gemini" }) duplicates that logic. It's generally better to have such defaults in a single, clear place, like action.yml.

newFile := File{Path: path}
files = append(files, newFile)
current = &files[len(files)-1]
current.Path = resolvePathFromHeaders(lines, i, path)

Choose a reason for hiding this comment

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

question: Is resolvePathFromHeaders strictly necessary? extractPath already appears to correctly derive the new file path from the diff --git line, even in cases of renames (e.g., diff --git a/old b/new). If extractPath is always reliable for the b/ path, this might be redundant, or perhaps it's a defensive fallback for specific diff formats not covered by extractPath?

token: token,
httpClient: &http.Client{},
}
}

Choose a reason for hiding this comment

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

suggestion: It's good practice to add a timeout to the http.Client for robustness, especially when making external network requests. E.g., Timeout: 30 * time.Second.

func Build(files []diff.File, instructions string) (string, string) {
var user strings.Builder

if instructions != "" {

Choose a reason for hiding this comment

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

praise: The systemTemplate is exceptionally well-defined. It clearly outlines the rules, expected JSON structure, comment labels, and output format, which is crucial for guiding the LLM's responses effectively.

fmt.Fprintf(&user, "@@ -%d +%d @@\n", h.StartLine, h.StartLine)

for _, l := range h.Lines {
switch l.Kind {

Choose a reason for hiding this comment

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

issue: The diff hunk header reconstruction is inaccurate. fmt.Fprintf(&user, "@@ -%d +%d @@\n", h.StartLine, h.StartLine) generates @@ -X +X @@. A correct unified diff hunk header should be @@ -old_start_line,old_num_lines +new_start_line,new_num_lines @@. The diff.Hunk struct currently only stores StartLine (which is the new start line). To accurately reconstruct the diff for the LLM, the diff.Hunk struct needs to be extended to capture the old_start_line, old_num_lines, new_start_line, and new_num_lines from the hunk header during parsing. Providing the LLM with an accurate diff is critical for its ability to understand the context and generate precise comments.

return provider.Response{}, fmt.Errorf("calling Gemini API: %w", err)
}
defer resp.Body.Close()

Choose a reason for hiding this comment

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

suggestion: Similar to the GitHub client, consider using a custom http.Client with a timeout for Gemini API calls to prevent requests from hanging indefinitely.


var valid []Comment
for _, c := range comments {
if !IsValidLabel(c.Label) {

Choose a reason for hiding this comment

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

praise: The filterValidComments function is excellent. It performs robust validation, ensuring that comments have valid labels, positive line numbers, and correctly map to lines in the new file within the provided diff. This is crucial for successful interaction with the GitHub API and preventing invalid comments.

The action now responds when someone replies to a review comment.
Fetches the conversation thread, sends it to the LLM with the
relevant diff hunk, and posts a reply. Skips its own comments
to prevent infinite loops.
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

The overall architecture and implementation are well-structured and logical. The action successfully sets up the Go environment, builds the application, and orchestrates the review process. The diff parsing, GitHub API interaction, and LLM prompting are handled robustly. Several minor issues and suggestions are provided for improvement, primarily around cost optimization, error handling, and prompt clarity.

review:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

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.

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'

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.


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

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.

Repository struct {
Owner struct {
Login string `json:"login"`
} `json:"owner"`

Choose a reason for hiding this comment

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

thought: The githubEvent struct is quite large and includes fields that are directly mapped to Config or CommentContext. This duplication is handled manually. It's functional, but worth noting the manual mapping might become verbose if event structures grow significantly.


i++
}

Choose a reason for hiding this comment

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

thought: The loop j <= start+4 in resolvePathFromHeaders assumes the relevant headers (like +++) will always appear within the first 4 lines after the diff --git line. This is typically true for standard diff formats, but in very unusual cases, it might miss a +++ line if there are many other headers first. It's a pragmatic choice, not necessarily an 'issue'.


page := 1
for {
url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/comments?per_page=100&page=%d", apiBase, owner, repo, prNumber, page)

Choose a reason for hiding this comment

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

issue: In FetchCommentThread, the resp.Body.Close() is called after io.ReadAll(resp.Body). If io.ReadAll returns an error, resp.Body.Close() will still be called (deferred). However, the body variable ([]byte) might not contain valid data for the subsequent json.Unmarshal. If io.ReadAll fails, body would be nil or partial, and json.Unmarshal would fail, but the error message would be misleading. It's safer to check err after io.ReadAll before attempting to unmarshal.

- If the developer's response resolves your concern, acknowledge it
- If you still have concerns, explain why clearly
- Be collaborative, not adversarial
- Output plain text only, no JSON, no markdown fences`

Choose a reason for hiding this comment

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

nit: The prompt rule Output ONLY the JSON object, no markdown fences, no extra text is given. However, review.stripMarkdownFences exists, implying the LLM might still output fences. While the stripping acts as a safeguard, it might be worth refining the prompt to reinforce strict adherence, or acknowledge that the stripping is a necessary robustness measure.

for _, f := range files {
lines := make(map[int]bool)
for _, h := range f.Hunks {
for _, l := range h.Lines {

Choose a reason for hiding this comment

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

suggestion: Logging warnings to os.Stderr is appropriate, but consider using a structured logger (e.g., log/slog or an external library) for more consistent and potentially more useful output, especially in CI environments where these warnings might be parsed by other tools. For a simple action, fmt.Fprintf is acceptable.

On synchronize events, fetch the bot's existing review comments and
pass them to the LLM as context so it doesn't repeat itself. Also
refactors duplicated struct definitions in the GitHub client.
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

The PR introduces a new 'reply' mode and significant refactoring to support it, which is well-structured. However, several previously identified issues and suggestions remain unaddressed, most notably the critical bug related to inaccurate diff hunk header reconstruction for the LLM.

steps:
- uses: actions/checkout@v4

- uses: ./

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.

types: [opened, synchronize]
pull_request_review_comment:
types: [created]

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.

@@ -4,7 +4,7 @@
*.dll
*.so

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.

@@ -0,0 +1,21 @@
{

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.

go-version: '1.24'
cache-dependency-path: go.sum

- name: Build

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.


Rules:
- Be concise and directly address the question or comment
- Reference the code when relevant

Choose a reason for hiding this comment

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

praise: The inclusion of prior comments in the user prompt is a significant improvement, helping the LLM avoid repetition and focus on new feedback or changes addressing previous comments.

switch l.Kind {
case diff.KindAdded:
fmt.Fprintf(&user, "+%s\n", l.Content)
case diff.KindRemoved:

Choose a reason for hiding this comment

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

praise: The new replySystemTemplate is clear and concise, providing excellent guidance for the LLM when generating replies within a conversation thread.

return provider.Response{}, fmt.Errorf("calling Gemini API: %w", err)
}
defer resp.Body.Close()

Choose a reason for hiding this comment

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

suggestion: Similar to the GitHub client, using http.DefaultClient.Do(httpReq) means there's no timeout configured for Gemini API calls. Consider using a custom http.Client with a timeout to prevent requests from hanging indefinitely, improving the action's robustness.

@@ -0,0 +1,14 @@
package provider

Choose a reason for hiding this comment

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

praise: Introducing the provider interface and associated types (Request, Response, ReviewFunc) is a great abstraction, making the codebase more modular and easier to extend with other LLM providers in the future.


result, err := ParseResponse(resp.Content)
if err != nil {
return Result{}, fmt.Errorf("parsing response: %w", err)

Choose a reason for hiding this comment

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

praise: The RunReply function correctly orchestrates the reply workflow, building the prompt, calling the provider, and processing the response.

On review, fetch the complete contents of each changed file so the
LLM can understand surrounding code, not just the diff. Files over
256KB are skipped silently. Deleted files return empty and are also
skipped.
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Key issues related to diff parsing for the LLM and HTTP client robustness remain unaddressed. Please review the highlighted issues.

review:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

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.

*.so
*.dylib
codereview
/codereview

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.

with:
go-version: '1.24'
cache-dependency-path: go.sum

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.


var event githubEvent
if err := json.Unmarshal(data, &event); err != nil {
return Config{}, fmt.Errorf("parsing event JSON: %w", err)

Choose a reason for hiding this comment

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

suggestion: This line if cfg.Provider == "" { cfg.Provider = "gemini" } still duplicates the default provider: 'gemini' already set in action.yml. It's generally better to maintain defaults in a single, authoritative place to avoid inconsistencies.

}

type File struct {
Path string

Choose a reason for hiding this comment

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

issue: The diff.Hunk struct still only contains StartLine int, which represents the new start line. To accurately reconstruct a unified diff hunk header for the LLM (e.g., @@ -old_start_line,old_num_lines +new_start_line,new_num_lines @@), the Hunk struct must be extended to capture OldStartLine, OldNumLines, NewStartLine (which is current StartLine), and NewNumLines during parsing. This is critical for the LLM to precisely understand the diff context.


func New(token string) *Client {
return &Client{
token: token,

Choose a reason for hiding this comment

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

suggestion: The http.Client created in New still doesn't have a timeout. It's good practice to add a timeout (e.g., Timeout: 30 * time.Second) for robustness when making external network requests.

req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github.v3+json")

resp, err := c.httpClient.Do(req)

Choose a reason for hiding this comment

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

issue: In fetchAllPRComments, resp.Body.Close() is called after io.ReadAll(resp.Body). If io.ReadAll returns an error, body might be partial or invalid, and json.Unmarshal would receive bad data. It's safer to defer resp.Body.Close() immediately after c.httpClient.Do(req) to ensure it's called reliably, and then check the error from io.ReadAll before attempting to unmarshal the body or check resp.StatusCode.

}
}
}
}

Choose a reason for hiding this comment

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

issue: This is directly related to the internal/diff/types.go issue. The hunk header is still being reconstructed incorrectly as @@ -%d +%d @@. A correct unified diff hunk header requires old/new start lines and number of lines (e.g., @@ -old_start,old_num +new_start,new_num @@). This inaccurate format will hinder the LLM's ability to understand the exact context of the changes, leading to less precise comments. This needs to be corrected by using the full hunk header information, which requires updates to the diff.Hunk struct and its parsing logic.

return provider.Response{}, fmt.Errorf("calling Gemini API: %w", err)
}
defer resp.Body.Close()

Choose a reason for hiding this comment

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

suggestion: Similar to the GitHub client, using http.DefaultClient.Do(httpReq) means there's no timeout configured for Gemini API calls. Consider using a custom http.Client with a timeout to prevent requests from hanging indefinitely, improving the action's robustness.

The LLM now returns structured replies indicating whether the concern
is resolved. If resolved and the thread was started by the bot, it
calls the GitHub GraphQL API to resolve the thread automatically.
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Multiple previously raised issues and suggestions remain unaddressed, most critically the inaccurate diff hunk header generation and HTTP client error handling. Please review the comments and make the necessary changes.

review:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

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:
go-version: '1.24'
cache-dependency-path: go.sum

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.


if cfg.Provider == "" {
cfg.Provider = "gemini"
}

Choose a reason for hiding this comment

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

suggestion: This line if cfg.Provider == "" { cfg.Provider = "gemini" } still duplicates the default provider: 'gemini' already set in action.yml. It's generally better to maintain defaults in a single, authoritative place to avoid inconsistencies.

StartLine int
Lines []Line
}

Choose a reason for hiding this comment

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

issue: This is a re-highlight of a critical, unaddressed issue. The diff.Hunk struct still only contains StartLine int, which represents the new start line. To accurately reconstruct a unified diff hunk header for the LLM (e.g., @@ -old_start_line,old_num_lines +new_start_line,new_num_lines @@), the Hunk struct must be extended to capture OldStartLine, OldNumLines, NewStartLine (which is current StartLine), and NewNumLines during parsing. This is critical for the LLM to precisely understand the diff context.

return &Client{
token: token,
httpClient: &http.Client{},
}

Choose a reason for hiding this comment

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

suggestion: The http.Client created in New still doesn't have a timeout. It's good practice to add a timeout (e.g., Timeout: 30 * time.Second) for robustness when making external network requests.


body, err := io.ReadAll(resp.Body)
resp.Body.Close()

Choose a reason for hiding this comment

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

issue: This is a re-highlight of a previously identified problem. In fetchAllPRComments, resp.Body.Close() is called after io.ReadAll(resp.Body). If io.ReadAll returns an error (e.g., due to network issues while reading a large body), body might be partial or invalid, and json.Unmarshal would receive bad data. It's safer to defer resp.Body.Close() immediately after c.httpClient.Do(req) to ensure it's called reliably, and then check the error from io.ReadAll before attempting to unmarshal the body or check resp.StatusCode.


if resp.StatusCode != http.StatusOK {
return provider.Response{}, fmt.Errorf("Gemini API returned status %d: %s", resp.StatusCode, string(respBody))
}

Choose a reason for hiding this comment

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

suggestion: Similar to the GitHub client, using http.DefaultClient.Do(httpReq) means there's no timeout configured for Gemini API calls. Consider using a custom http.Client with a timeout to prevent requests from hanging indefinitely, improving the action's robustness.

@monokrome monokrome merged commit 3fac9db into main Mar 14, 2026
1 check passed
@monokrome monokrome deleted the feat/initial-implementation branch March 14, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant