Add Github reviewer and improve reasoning

This commit is contained in:
Ettore Di Giacinto
2025-04-10 23:36:44 +02:00
parent e4c7d1acfc
commit 9dad2b0ba4
5 changed files with 388 additions and 222 deletions

View File

@@ -28,6 +28,7 @@ const (
ActionGithubIssueCommenter = "github-issue-commenter"
ActionGithubPRReader = "github-pr-reader"
ActionGithubPRCommenter = "github-pr-commenter"
ActionGithubPRReviewer = "github-pr-reviewer"
ActionGithubREADME = "github-readme"
ActionScraper = "scraper"
ActionWikipedia = "wikipedia"
@@ -53,6 +54,7 @@ var AvailableActions = []string{
ActionGithubIssueCommenter,
ActionGithubPRReader,
ActionGithubPRCommenter,
ActionGithubPRReviewer,
ActionGithubREADME,
ActionScraper,
ActionBrowse,
@@ -114,6 +116,8 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
a = actions.NewGithubPRReader(config)
case ActionGithubPRCommenter:
a = actions.NewGithubPRCommenter(config)
case ActionGithubPRReviewer:
a = actions.NewGithubPRReviewer(config)
case ActionGithubIssueCommenter:
a = actions.NewGithubIssueCommenter(config)
case ActionGithubRepositoryGet:
@@ -217,6 +221,11 @@ func ActionsConfigMeta() []config.FieldGroup {
Label: "GitHub PR Commenter",
Fields: actions.GithubPRCommenterConfigMeta(),
},
{
Name: "github-pr-reviewer",
Label: "GitHub PR Reviewer",
Fields: actions.GithubPRReviewerConfigMeta(),
},
{
Name: "twitter-post",
Label: "Twitter Post",

View File

@@ -3,11 +3,8 @@ package actions
import (
"context"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
@@ -124,16 +121,10 @@ func NewGithubPRCommenter(config map[string]string) *GithubPRCommenter {
func (g *GithubPRCommenter) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
PRNumber int `json:"pr_number"`
GeneralComment string `json:"general_comment"`
Comments []struct {
File string `json:"file"`
Line int `json:"line"`
Comment string `json:"comment"`
StartLine int `json:"start_line,omitempty"`
} `json:"comments"`
Repository string `json:"repository"`
Owner string `json:"owner"`
PRNumber int `json:"pr_number"`
Comment string `json:"comment"`
}{}
err := params.Unmarshal(&result)
if err != nil {
@@ -159,134 +150,31 @@ func (g *GithubPRCommenter) Run(ctx context.Context, params types.ActionParams)
return types.ActionResult{Result: fmt.Sprintf("Pull request #%d is not open (current state: %s)", result.PRNumber, *pr.State)}, nil
}
// Get the list of changed files to verify the files exist in the PR
files, _, err := g.client.PullRequests.ListFiles(ctx, result.Owner, result.Repository, result.PRNumber, &github.ListOptions{})
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to list PR files: %w", err)
if result.Comment == "" {
return types.ActionResult{Result: "No comment provided"}, nil
}
// Create a map of valid files with their commit info
validFiles := make(map[string]*commitFileInfo)
for _, file := range files {
if *file.Status != "deleted" {
info, err := getCommitInfo(file)
if err != nil {
continue
}
validFiles[*file.Filename] = info
}
}
// Try both PullRequests and Issues API for general comments
var resp *github.Response
// Process each comment
var results []string
for _, comment := range result.Comments {
// Check if file exists in PR
fileInfo, exists := validFiles[comment.File]
if !exists {
availableFiles := make([]string, 0, len(validFiles))
for f := range validFiles {
availableFiles = append(availableFiles, f)
}
results = append(results, fmt.Sprintf("Error: File %s not found in PR #%d. Available files: %v",
comment.File, result.PRNumber, availableFiles))
continue
}
// First try PullRequests API
_, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.PullRequestComment{
Body: &result.Comment,
})
// Check if line is in a changed hunk
if !fileInfo.isLineInChange(comment.Line) {
results = append(results, fmt.Sprintf("Error: Line %d is not in a changed hunk in file %s",
comment.Line, comment.File))
continue
}
// Calculate position
position := fileInfo.calculatePosition(comment.Line)
if position == nil {
results = append(results, fmt.Sprintf("Error: Could not calculate position for line %d in file %s",
comment.Line, comment.File))
continue
}
// Create the review comment
reviewComment := &github.PullRequestComment{
Path: &comment.File,
Line: &comment.Line,
Body: &comment.Comment,
Position: position,
CommitID: &fileInfo.sha,
}
// Set start line if provided
if comment.StartLine > 0 {
reviewComment.StartLine = &comment.StartLine
}
// Create the comment with retries
var resp *github.Response
for i := 0; i < 6; i++ {
_, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, reviewComment)
if err == nil {
break
}
if resp != nil && resp.StatusCode == 422 {
// Rate limit hit, wait and retry
retrySeconds := i * i
time.Sleep(time.Second * time.Duration(retrySeconds))
continue
}
break
}
if err != nil {
errorDetails := fmt.Sprintf("Error commenting on file %s, line %d: %s", comment.File, comment.Line, err.Error())
if resp != nil {
errorDetails += fmt.Sprintf("\nResponse Status: %s", resp.Status)
if resp.Body != nil {
body, _ := io.ReadAll(resp.Body)
errorDetails += fmt.Sprintf("\nResponse Body: %s", string(body))
}
}
results = append(results, errorDetails)
continue
}
results = append(results, fmt.Sprintf("Successfully commented on file %s, line %d", comment.File, comment.Line))
}
if result.GeneralComment != "" {
// Try both PullRequests and Issues API for general comments
var resp *github.Response
var err error
// First try PullRequests API
_, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.PullRequestComment{
Body: &result.GeneralComment,
// If that fails with 403, try Issues API
if err != nil && resp != nil && resp.StatusCode == 403 {
_, resp, err = g.client.Issues.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.IssueComment{
Body: &result.Comment,
})
}
// If that fails with 403, try Issues API
if err != nil && resp != nil && resp.StatusCode == 403 {
_, resp, err = g.client.Issues.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.IssueComment{
Body: &result.GeneralComment,
})
}
if err != nil {
errorDetails := fmt.Sprintf("Error adding general comment: %s", err.Error())
if resp != nil {
errorDetails += fmt.Sprintf("\nResponse Status: %s", resp.Status)
if resp.Body != nil {
body, _ := io.ReadAll(resp.Body)
errorDetails += fmt.Sprintf("\nResponse Body: %s", string(body))
}
}
results = append(results, errorDetails)
} else {
results = append(results, "Successfully added general comment to pull request")
}
if err != nil {
return types.ActionResult{Result: fmt.Sprintf("Error adding general comment: %s", err.Error())}, nil
}
return types.ActionResult{
Result: strings.Join(results, "\n"),
Result: "Successfully added general comment to pull request",
}, nil
}
@@ -305,38 +193,12 @@ func (g *GithubPRCommenter) Definition() types.ActionDefinition {
Type: jsonschema.Number,
Description: "The number of the pull request to comment on.",
},
"general_comment": {
"comment": {
Type: jsonschema.String,
Description: "A general comment to add to the pull request.",
},
"comments": {
Type: jsonschema.Array,
Items: &jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"file": {
Type: jsonschema.String,
Description: "The file to comment on.",
},
"line": {
Type: jsonschema.Number,
Description: "The line number to comment on.",
},
"comment": {
Type: jsonschema.String,
Description: "The comment text.",
},
"start_line": {
Type: jsonschema.Number,
Description: "Optional start line for multi-line comments.",
},
},
Required: []string{"file", "line", "comment"},
},
Description: "Array of comments to add to the pull request.",
},
},
Required: []string{"pr_number", "comments"},
Required: []string{"pr_number", "comment"},
}
}
return types.ActionDefinition{
@@ -355,38 +217,12 @@ func (g *GithubPRCommenter) Definition() types.ActionDefinition {
Type: jsonschema.String,
Description: "The owner of the repository.",
},
"general_comment": {
"comment": {
Type: jsonschema.String,
Description: "A general comment to add to the pull request.",
},
"comments": {
Type: jsonschema.Array,
Items: &jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"file": {
Type: jsonschema.String,
Description: "The file to comment on.",
},
"line": {
Type: jsonschema.Number,
Description: "The line number to comment on.",
},
"comment": {
Type: jsonschema.String,
Description: "The comment text.",
},
"start_line": {
Type: jsonschema.Number,
Description: "Optional start line for multi-line comments.",
},
},
Required: []string{"file", "line", "comment"},
},
Description: "Array of comments to add to the pull request.",
},
},
Required: []string{"pr_number", "repository", "owner", "comments"},
Required: []string{"pr_number", "repository", "owner", "comment"},
}
}

View File

@@ -0,0 +1,286 @@
package actions
import (
"context"
"fmt"
"io"
"strings"
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
type GithubPRReviewer struct {
token, repository, owner, customActionName string
client *github.Client
}
func NewGithubPRReviewer(config map[string]string) *GithubPRReviewer {
client := github.NewClient(nil).WithAuthToken(config["token"])
return &GithubPRReviewer{
client: client,
token: config["token"],
customActionName: config["customActionName"],
repository: config["repository"],
owner: config["owner"],
}
}
func (g *GithubPRReviewer) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
PRNumber int `json:"pr_number"`
ReviewComment string `json:"review_comment"`
ReviewAction string `json:"review_action"` // APPROVE, REQUEST_CHANGES, or COMMENT
Comments []struct {
File string `json:"file"`
Line int `json:"line"`
Comment string `json:"comment"`
StartLine int `json:"start_line,omitempty"`
} `json:"comments"`
}{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
}
if g.repository != "" && g.owner != "" {
result.Repository = g.repository
result.Owner = g.owner
}
// First verify the PR exists and is in a valid state
pr, _, err := g.client.PullRequests.Get(ctx, result.Owner, result.Repository, result.PRNumber)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to fetch PR #%d: %w", result.PRNumber, err)
}
if pr == nil {
return types.ActionResult{Result: fmt.Sprintf("Pull request #%d not found in repository %s/%s", result.PRNumber, result.Owner, result.Repository)}, nil
}
// Check if PR is in a state that allows reviews
if *pr.State != "open" {
return types.ActionResult{Result: fmt.Sprintf("Pull request #%d is not open (current state: %s)", result.PRNumber, *pr.State)}, nil
}
// Get the list of changed files to verify the files exist in the PR
files, _, err := g.client.PullRequests.ListFiles(ctx, result.Owner, result.Repository, result.PRNumber, &github.ListOptions{})
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to list PR files: %w", err)
}
// Create a map of valid files
validFiles := make(map[string]bool)
for _, file := range files {
if *file.Status != "deleted" {
validFiles[*file.Filename] = true
}
}
// Process each comment
var reviewComments []*github.DraftReviewComment
for _, comment := range result.Comments {
// Check if file exists in PR
if !validFiles[comment.File] {
continue
}
reviewComment := &github.DraftReviewComment{
Path: &comment.File,
Line: &comment.Line,
Body: &comment.Comment,
}
// Set start line if provided
if comment.StartLine > 0 {
reviewComment.StartLine = &comment.StartLine
}
reviewComments = append(reviewComments, reviewComment)
}
// Create the review
review := &github.PullRequestReviewRequest{
Event: &result.ReviewAction,
Body: &result.ReviewComment,
Comments: reviewComments,
}
xlog.Debug("[githubprreviewer] review", "review", review)
// Submit the review
_, resp, err := g.client.PullRequests.CreateReview(ctx, result.Owner, result.Repository, result.PRNumber, review)
if err != nil {
errorDetails := fmt.Sprintf("Error submitting review: %s", err.Error())
if resp != nil {
errorDetails += fmt.Sprintf("\nResponse Status: %s", resp.Status)
if resp.Body != nil {
body, _ := io.ReadAll(resp.Body)
errorDetails += fmt.Sprintf("\nResponse Body: %s", string(body))
}
}
return types.ActionResult{Result: errorDetails}, err
}
actionResult := strings.Builder{}
actionResult.WriteString("========================== PR Reviewer Result ==========================\n")
actionResult.WriteString(fmt.Sprintf("Pull request https://github.com/%s/%s/pull/%d", result.Owner, result.Repository, result.PRNumber))
actionResult.WriteString("\n")
actionResult.WriteString(fmt.Sprintf("Successfully reviewed with status: %s", strings.ToLower(result.ReviewAction)))
actionResult.WriteString("\n")
return types.ActionResult{Result: actionResult.String()}, nil
}
func (g *GithubPRReviewer) Definition() types.ActionDefinition {
actionName := "review_github_pr"
if g.customActionName != "" {
actionName = g.customActionName
}
description := "Review a GitHub pull request by approving, requesting changes, or commenting."
if g.repository != "" && g.owner != "" {
return types.ActionDefinition{
Name: types.ActionDefinitionName(actionName),
Description: description,
Properties: map[string]jsonschema.Definition{
"pr_number": {
Type: jsonschema.Number,
Description: "The number of the pull request to review.",
},
"review_comment": {
Type: jsonschema.String,
Description: "The main review comment to add to the pull request.",
},
"review_action": {
Type: jsonschema.String,
Description: "The type of review to submit (APPROVE, REQUEST_CHANGES, or COMMENT).",
Enum: []string{"APPROVE", "REQUEST_CHANGES", "COMMENT"},
},
"comments": {
Type: jsonschema.Array,
Items: &jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"file": {
Type: jsonschema.String,
Description: "The file to comment on.",
},
"line": {
Type: jsonschema.Number,
Description: "The line number to comment on.",
},
"comment": {
Type: jsonschema.String,
Description: "The comment text.",
},
"start_line": {
Type: jsonschema.Number,
Description: "Optional start line for multi-line comments.",
},
},
Required: []string{"file", "line", "comment"},
},
Description: "Array of line-specific comments to add to the review.",
},
},
Required: []string{"pr_number", "review_action"},
}
}
return types.ActionDefinition{
Name: types.ActionDefinitionName(actionName),
Description: description,
Properties: map[string]jsonschema.Definition{
"pr_number": {
Type: jsonschema.Number,
Description: "The number of the pull request to review.",
},
"repository": {
Type: jsonschema.String,
Description: "The repository containing the pull request.",
},
"owner": {
Type: jsonschema.String,
Description: "The owner of the repository.",
},
"review_comment": {
Type: jsonschema.String,
Description: "The main review comment to add to the pull request.",
},
"review_action": {
Type: jsonschema.String,
Description: "The type of review to submit (APPROVE, REQUEST_CHANGES, or COMMENT).",
Enum: []string{"APPROVE", "REQUEST_CHANGES", "COMMENT"},
},
"comments": {
Type: jsonschema.Array,
Items: &jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"file": {
Type: jsonschema.String,
Description: "The file to comment on.",
},
"line": {
Type: jsonschema.Number,
Description: "The line number to comment on.",
},
"comment": {
Type: jsonschema.String,
Description: "The comment text.",
},
"start_line": {
Type: jsonschema.Number,
Description: "Optional start line for multi-line comments.",
},
},
Required: []string{"file", "line", "comment"},
},
Description: "Array of line-specific comments to add to the review.",
},
},
Required: []string{"pr_number", "repository", "owner", "review_action"},
}
}
func (a *GithubPRReviewer) Plannable() bool {
return true
}
// GithubPRReviewerConfigMeta returns the metadata for GitHub PR Reviewer action configuration fields
func GithubPRReviewerConfigMeta() []config.Field {
return []config.Field{
{
Name: "token",
Label: "GitHub Token",
Type: config.FieldTypeText,
Required: true,
HelpText: "GitHub API token with repository access",
},
{
Name: "repository",
Label: "Repository",
Type: config.FieldTypeText,
Required: false,
HelpText: "GitHub repository name",
},
{
Name: "owner",
Label: "Owner",
Type: config.FieldTypeText,
Required: false,
HelpText: "GitHub repository owner",
},
{
Name: "customActionName",
Label: "Custom Action Name",
Type: config.FieldTypeText,
HelpText: "Custom name for this action",
},
}
}