Compare commits

..

1 Commits

Author SHA1 Message Date
mudler
3232dda8ce feat(github): add actions to comment and read PRs
Signed-off-by: mudler <mudler@localai.io>
2025-04-10 20:06:45 +02:00
9 changed files with 292 additions and 615 deletions

View File

@@ -52,7 +52,7 @@ docker compose up -f docker-compose.yml
docker compose up -f docker-compose.gpu.yml docker compose up -f docker-compose.gpu.yml
``` ```
Access your agents at `http://localhost:8080` Access your agents at `http://localhost:3000`
## 🏆 Why Choose LocalAGI? ## 🏆 Why Choose LocalAGI?

View File

@@ -1,49 +0,0 @@
package action
import (
"context"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
)
// NewGoal creates a new intention action
// The inention action is special as it tries to identify
// a tool to use and a reasoning over to use it
func NewGoal(s ...string) *GoalAction {
return &GoalAction{tools: s}
}
type GoalAction struct {
tools []string
}
type GoalResponse struct {
Goal string `json:"goal"`
Achieved bool `json:"achieved"`
}
func (a *GoalAction) Run(context.Context, types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{}, nil
}
func (a *GoalAction) Plannable() bool {
return false
}
func (a *GoalAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "goal",
Description: "Check if the goal is achieved",
Properties: map[string]jsonschema.Definition{
"goal": {
Type: jsonschema.String,
Description: "The goal to check if it is achieved.",
},
"achieved": {
Type: jsonschema.Boolean,
Description: "Whether the goal is achieved",
},
},
Required: []string{"goal", "achieved"},
}
}

View File

@@ -79,15 +79,6 @@ func (m Messages) ToOpenAI() []openai.ChatCompletionMessage {
return []openai.ChatCompletionMessage(m) return []openai.ChatCompletionMessage(m)
} }
func (m Messages) RemoveIf(f func(msg openai.ChatCompletionMessage) bool) Messages {
for i := len(m) - 1; i >= 0; i-- {
if f(m[i]) {
m = append(m[:i], m[i+1:]...)
}
}
return m
}
func (m Messages) String() string { func (m Messages) String() string {
s := "" s := ""
for _, cc := range m { for _, cc := range m {
@@ -367,10 +358,7 @@ func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) { func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) {
c := messages c := messages
xlog.Debug("[pickAction] picking action", "messages", messages)
if !a.options.forceReasoning { if !a.options.forceReasoning {
xlog.Debug("not forcing reasoning")
// We also could avoid to use functions here and get just a reply from the LLM // We also could avoid to use functions here and get just a reply from the LLM
// and then use the reply to get the action // and then use the reply to get the action
thought, err := a.decision(ctx, thought, err := a.decision(ctx,
@@ -398,8 +386,6 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.
return chosenAction, thought.actionParams, thought.message, nil return chosenAction, thought.actionParams, thought.message, nil
} }
xlog.Debug("[pickAction] forcing reasoning")
prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "") prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "")
if err != nil { if err != nil {
return nil, nil, "", err return nil, nil, "", err
@@ -415,121 +401,67 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.
}, c...) }, c...)
} }
// We also could avoid to use functions here and get just a reply from the LLM
// and then use the reply to get the action
thought, err := a.decision(ctx,
c,
types.Actions{action.NewReasoning()}.ToTools(),
action.NewReasoning().Definition().Name, maxRetries)
if err != nil {
return nil, nil, "", err
}
reason := ""
response := &action.ReasoningResponse{}
if thought.actionParams != nil {
if err := thought.actionParams.Unmarshal(response); err != nil {
return nil, nil, "", err
}
reason = response.Reasoning
}
if thought.message != "" {
reason = thought.message
}
// From the thought, get the action call
// Get all the available actions IDs
actionsID := []string{} actionsID := []string{}
for _, m := range a.availableActions() { for _, m := range a.availableActions() {
actionsID = append(actionsID, m.Definition().Name.String()) actionsID = append(actionsID, m.Definition().Name.String())
} }
intentionsTools := action.NewIntention(actionsID...)
// thoughtPromptStringBuilder := strings.Builder{} //XXX: Why we add the reason here?
// thoughtPromptStringBuilder.WriteString("You have to pick an action based on the conversation and the prompt. Describe the full reasoning process for your choice. Here is a list of actions: ")
// for _, m := range a.availableActions() {
// thoughtPromptStringBuilder.WriteString(
// m.Definition().Name.String() + ": " + m.Definition().Description + "\n",
// )
// }
// thoughtPromptStringBuilder.WriteString("To not use any action, respond with 'none'")
//thoughtPromptStringBuilder.WriteString("\n\nConversation: " + Messages(c).RemoveIf(func(msg openai.ChatCompletionMessage) bool {
// return msg.Role == "system"
//}).String())
//thoughtPrompt := thoughtPromptStringBuilder.String()
//thoughtConv := []openai.ChatCompletionMessage{}
thought, err := a.askLLM(ctx,
c,
maxRetries,
)
if err != nil {
return nil, nil, "", err
}
originalReasoning := thought.Content
// From the thought, get the action call
// Get all the available actions IDs
// by grammar, let's decide if we have achieved the goal
// 1. analyze response and check if goal is achieved
params, err := a.decision(ctx, params, err := a.decision(ctx,
[]openai.ChatCompletionMessage{ append(c, openai.ChatCompletionMessage{
{
Role: "system", Role: "system",
Content: "Extract an action to perform from the following reasoning: ", Content: "Given the assistant thought, pick the relevant action: " + reason,
}, }),
{ types.Actions{intentionsTools}.ToTools(),
Role: "user", intentionsTools.Definition().Name, maxRetries)
Content: originalReasoning,
}},
types.Actions{action.NewGoal()}.ToTools(),
action.NewGoal().Definition().Name, maxRetries)
if err != nil { if err != nil {
return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err) return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err)
} }
goalResponse := action.GoalResponse{} actionChoice := action.IntentResponse{}
err = params.actionParams.Unmarshal(&goalResponse)
if params.actionParams == nil {
return nil, nil, params.message, nil
}
err = params.actionParams.Unmarshal(&actionChoice)
if err != nil { if err != nil {
return nil, nil, "", err return nil, nil, "", err
} }
if goalResponse.Achieved { if actionChoice.Tool == "" || actionChoice.Tool == "none" {
xlog.Debug("[pickAction] goal achieved", "goal", goalResponse.Goal) return nil, nil, "", fmt.Errorf("no intent detected")
return nil, nil, "", nil
} }
// if the goal is not achieved, pick an action // Find the action
xlog.Debug("[pickAction] goal not achieved", "goal", goalResponse.Goal) chosenAction := a.availableActions().Find(actionChoice.Tool)
if chosenAction == nil {
xlog.Debug("[pickAction] thought", "conv", c, "originalReasoning", originalReasoning) return nil, nil, "", fmt.Errorf("no action found for intent:" + actionChoice.Tool)
// TODO: FORCE to select ana ction here
// NOTE: we do not give the full conversation here to pick the action
// to avoid hallucinations
params, err = a.decision(ctx,
[]openai.ChatCompletionMessage{
{
Role: "system",
Content: "Extract an action to perform from the following reasoning: ",
},
{
Role: "user",
Content: originalReasoning,
}},
a.availableActions().ToTools(),
nil, maxRetries)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err)
} }
chosenAction := a.availableActions().Find(params.actioName) return chosenAction, nil, actionChoice.Reasoning, nil
// xlog.Debug("[pickAction] params", "params", params)
// if params.actionParams == nil {
// return nil, nil, params.message, nil
// }
// xlog.Debug("[pickAction] actionChoice", "actionChoice", params.actionParams, "message", params.message)
// actionChoice := action.IntentResponse{}
// err = params.actionParams.Unmarshal(&actionChoice)
// if err != nil {
// return nil, nil, "", err
// }
// if actionChoice.Tool == "" || actionChoice.Tool == "none" {
// return nil, nil, "", nil
// }
// // Find the action
// chosenAction := a.availableActions().Find(actionChoice.Tool)
// if chosenAction == nil {
// return nil, nil, "", fmt.Errorf("no action found for intent:" + actionChoice.Tool)
// }
return chosenAction, nil, originalReasoning, nil
} }

View File

@@ -515,21 +515,10 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
//job.Result.Finish(fmt.Errorf("no action to do"))\ //job.Result.Finish(fmt.Errorf("no action to do"))\
xlog.Info("No action to do, just reply", "agent", a.Character.Name, "reasoning", reasoning) xlog.Info("No action to do, just reply", "agent", a.Character.Name, "reasoning", reasoning)
if reasoning != "" {
conv = append(conv, openai.ChatCompletionMessage{ conv = append(conv, openai.ChatCompletionMessage{
Role: "assistant", Role: "assistant",
Content: reasoning, Content: reasoning,
}) })
} else {
xlog.Info("No reasoning, just reply", "agent", a.Character.Name)
msg, err := a.askLLM(job.GetContext(), conv, maxRetries)
if err != nil {
job.Result.Finish(fmt.Errorf("error asking LLM for a reply: %w", err))
return
}
conv = append(conv, msg)
reasoning = msg.Content
}
xlog.Debug("Finish job with reasoning", "reasoning", reasoning, "agent", a.Character.Name, "conversation", fmt.Sprintf("%+v", conv)) xlog.Debug("Finish job with reasoning", "reasoning", reasoning, "agent", a.Character.Name, "conversation", fmt.Sprintf("%+v", conv))
job.Result.Conversation = conv job.Result.Conversation = conv
@@ -668,7 +657,11 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
conv = a.addFunctionResultToConversation(chosenAction, actionParams, result, conv) conv = a.addFunctionResultToConversation(chosenAction, actionParams, result, conv)
} }
// given the result, we can now re-evaluate the conversation //conv = append(conv, messages...)
//conv = messages
// given the result, we can now ask OpenAI to complete the conversation or
// to continue using another tool given the result
followingAction, followingParams, reasoning, err := a.pickAction(job.GetContext(), reEvaluationTemplate, conv, maxRetries) followingAction, followingParams, reasoning, err := a.pickAction(job.GetContext(), reEvaluationTemplate, conv, maxRetries)
if err != nil { if err != nil {
job.Result.Conversation = conv job.Result.Conversation = conv
@@ -681,7 +674,6 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
!chosenAction.Definition().Name.Is(action.ReplyActionName) { !chosenAction.Definition().Name.Is(action.ReplyActionName) {
xlog.Info("Following action", "action", followingAction.Definition().Name, "agent", a.Character.Name) xlog.Info("Following action", "action", followingAction.Definition().Name, "agent", a.Character.Name)
job.ConversationHistory = conv
// We need to do another action (?) // We need to do another action (?)
// The agent decided to do another action // The agent decided to do another action

View File

@@ -43,104 +43,61 @@ func renderTemplate(templ string, hud *PromptHUD, actions types.Actions, reasoni
return prompt.String(), nil return prompt.String(), nil
} }
const innerMonologueTemplate = `You are an autonomous AI agent thinking out loud and evaluating your current situation. const innerMonologueTemplate = `"This is not a typical conversation between an assistant and an user.
Your task is to analyze your goals and determine the best course of action. You are thinking out loud by yourself now, and you are evaluating the current situation.
Considering the goal and the persistent goal (if you have one) do an action or decide to plan something for later on. If possible for you, you might also decide to engage a conversation with the user by notifying him."`
Consider: const hudTemplate = `{{with .HUD }}{{if .ShowCharacter}}The assistant acts like an human, has a character and the replies and actions might be influenced by it.
1. Your permanent goal (if any) {{if .Character.Name}}This is the assistant name: {{.Character.Name}}
2. Your current state and progress {{end}}{{if .Character.Age}}This is the assistant age: {{.Character.Age}}
3. Available tools and capabilities {{end}}{{if .Character.Occupation}}This is the assistant job: {{.Character.Occupation}}
4. Previous actions and their outcomes {{end}}{{if .Character.Hobbies}}This is the assistant's hobbies: {{.Character.Hobbies}}
{{end}}{{if .Character.MusicTaste}}This is the assistant's music taste: {{.Character.MusicTaste}}
You can:
- Take immediate actions using available tools
- Plan future actions
- Update your state and goals
- Initiate conversations with the user when appropriate
Remember to:
- Think critically about each decision
- Consider both short-term and long-term implications
- Be proactive in addressing potential issues
- Maintain awareness of your current state and goals`
const hudTemplate = `{{with .HUD }}{{if .ShowCharacter}}You are an AI assistant with a distinct personality and character traits that influence your responses and actions.
{{if .Character.Name}}Name: {{.Character.Name}}
{{end}}{{if .Character.Age}}Age: {{.Character.Age}}
{{end}}{{if .Character.Occupation}}Occupation: {{.Character.Occupation}}
{{end}}{{if .Character.Hobbies}}Hobbies: {{.Character.Hobbies}}
{{end}}{{if .Character.MusicTaste}}Music Taste: {{.Character.MusicTaste}}
{{end}} {{end}}
{{end}} {{end}}
Current State: This is your current state:
- Current Action: {{if .CurrentState.NowDoing}}{{.CurrentState.NowDoing}}{{else}}None{{end}} NowDoing: {{if .CurrentState.NowDoing}}{{.CurrentState.NowDoing}}{{else}}Nothing{{end}}
- Next Action: {{if .CurrentState.DoingNext}}{{.CurrentState.DoingNext}}{{else}}None{{end}} DoingNext: {{if .CurrentState.DoingNext}}{{.CurrentState.DoingNext}}{{else}}Nothing{{end}}
- Permanent Goal: {{if .PermanentGoal}}{{.PermanentGoal}}{{else}}None{{end}} Your permanent goal is: {{if .PermanentGoal}}{{.PermanentGoal}}{{else}}Nothing{{end}}
- Current Goal: {{if .CurrentState.Goal}}{{.CurrentState.Goal}}{{else}}None{{end}} Your current goal is: {{if .CurrentState.Goal}}{{.CurrentState.Goal}}{{else}}Nothing{{end}}
- Action History: {{range .CurrentState.DoneHistory}}{{.}} {{end}} You have done: {{range .CurrentState.DoneHistory}}{{.}} {{end}}
- Short-term Memory: {{range .CurrentState.Memories}}{{.}} {{end}}{{end}} You have a short memory with: {{range .CurrentState.Memories}}{{.}} {{end}}{{end}}
Current Time: {{.Time}}` Current time: is {{.Time}}`
const pickSelfTemplate = ` const pickSelfTemplate = `You can take any of the following tools:
You are an autonomous AI agent with a defined character and state (as shown above).
Your task is to evaluate your current situation and determine the best course of action.
Guidelines:
1. Review your current state and goals
2. Consider available tools and their purposes
3. Plan your next steps carefully
4. Update your state appropriately
When making decisions:
- Use the "reply" tool to provide final responses
- Update your state using appropriate tools
- Plan complex tasks using the planning tool
- Consider both immediate and long-term goals
Remember:
- You are autonomous and should not ask for user input
- Your character traits influence your decisions
- Keep track of your progress and state
- Be proactive in addressing potential issues
Available Tools:
{{range .Actions -}} {{range .Actions -}}
- {{.Name}}: {{.Description }} - {{.Name}}: {{.Description }}
{{ end }} {{ end }}
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}} To finish your session, use the "reply" tool with your answer.
Act like as a fully autonomous smart AI agent having a character, the character and your state is defined in the message above.
You are now self-evaluating what to do next based on the state in the previous message.
For example, if the permanent goal is to "make a sandwich", you might want to "get the bread" first, and update the state afterwards by calling two tools in sequence.
You can update the short-term goal, the current action, the next action, the history of actions, and the memories.
You can't ask things to the user as you are thinking by yourself. You are autonomous.
{{if .Reasoning}}Reasoning: {{.Reasoning}}{{end}}
` + hudTemplate ` + hudTemplate
const reSelfEvalTemplate = pickSelfTemplate const reSelfEvalTemplate = pickSelfTemplate + `
We already have called other tools. Evaluate the current situation and decide if we need to execute other tools.`
const pickActionTemplate = hudTemplate + ` const pickActionTemplate = hudTemplate + `
Your only task is to analyze the situation and determine a goal and the best tool to use, or just a final response if we have fullfilled the goal. When you have to pick a tool in the reasoning explain how you would use the tools you'd pick from:
Guidelines:
1. Review the current state, what was done already and context
2. Consider available tools and their purposes
3. Plan your approach carefully
4. Explain your reasoning clearly
When choosing actions:
- Use "reply" or "answer" tools for direct responses
- Select appropriate tools for specific tasks
- Consider the impact of each action
- Plan for potential challenges
Decision Process:
1. Analyze the situation
2. Consider available options
3. Choose the best course of action
4. Explain your reasoning
5. Execute the chosen action
Available Tools:
{{range .Actions -}} {{range .Actions -}}
- {{.Name}}: {{.Description }} - {{.Name}}: {{.Description }}
{{ end }} {{ end }}
To answer back to the user, use the "reply" or the "answer" tool.
Given the text below, decide which action to take and explain the detailed reasoning behind it. For answering without picking a choice, reply with 'none'.
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}` {{if .Reasoning}}Reasoning: {{.Reasoning}}{{end}}
`
const reEvalTemplate = pickActionTemplate const reEvalTemplate = pickActionTemplate + `
We already have called other tools. Evaluate the current situation and decide if we need to execute other tools or answer back with a result.`

View File

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

View File

@@ -3,8 +3,11 @@ package actions
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"time"
"github.com/google/go-github/v69/github" "github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types" "github.com/mudler/LocalAGI/core/types"
@@ -124,7 +127,13 @@ func (g *GithubPRCommenter) Run(ctx context.Context, params types.ActionParams)
Repository string `json:"repository"` Repository string `json:"repository"`
Owner string `json:"owner"` Owner string `json:"owner"`
PRNumber int `json:"pr_number"` PRNumber int `json:"pr_number"`
GeneralComment string `json:"general_comment"`
Comments []struct {
File string `json:"file"`
Line int `json:"line"`
Comment string `json:"comment"` Comment string `json:"comment"`
StartLine int `json:"start_line,omitempty"`
} `json:"comments"`
}{} }{}
err := params.Unmarshal(&result) err := params.Unmarshal(&result)
if err != nil { if err != nil {
@@ -150,31 +159,134 @@ 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 return types.ActionResult{Result: fmt.Sprintf("Pull request #%d is not open (current state: %s)", result.PRNumber, *pr.State)}, nil
} }
if result.Comment == "" { // Get the list of changed files to verify the files exist in the PR
return types.ActionResult{Result: "No comment provided"}, nil 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 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
}
}
// 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
}
// 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 // Try both PullRequests and Issues API for general comments
var resp *github.Response var resp *github.Response
var err error
// First try PullRequests API // First try PullRequests API
_, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.PullRequestComment{ _, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.PullRequestComment{
Body: &result.Comment, Body: &result.GeneralComment,
}) })
// If that fails with 403, try Issues API // If that fails with 403, try Issues API
if err != nil && resp != nil && resp.StatusCode == 403 { if err != nil && resp != nil && resp.StatusCode == 403 {
_, resp, err = g.client.Issues.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.IssueComment{ _, resp, err = g.client.Issues.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.IssueComment{
Body: &result.Comment, Body: &result.GeneralComment,
}) })
} }
if err != nil { if err != nil {
return types.ActionResult{Result: fmt.Sprintf("Error adding general comment: %s", err.Error())}, 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")
}
} }
return types.ActionResult{ return types.ActionResult{
Result: "Successfully added general comment to pull request", Result: strings.Join(results, "\n"),
}, nil }, nil
} }
@@ -193,12 +305,38 @@ func (g *GithubPRCommenter) Definition() types.ActionDefinition {
Type: jsonschema.Number, Type: jsonschema.Number,
Description: "The number of the pull request to comment on.", Description: "The number of the pull request to comment on.",
}, },
"comment": { "general_comment": {
Type: jsonschema.String, Type: jsonschema.String,
Description: "A general comment to add to the pull request.", 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.",
}, },
Required: []string{"pr_number", "comment"}, "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"},
} }
} }
return types.ActionDefinition{ return types.ActionDefinition{
@@ -217,12 +355,38 @@ func (g *GithubPRCommenter) Definition() types.ActionDefinition {
Type: jsonschema.String, Type: jsonschema.String,
Description: "The owner of the repository.", Description: "The owner of the repository.",
}, },
"comment": { "general_comment": {
Type: jsonschema.String, Type: jsonschema.String,
Description: "A general comment to add to the pull request.", 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.",
}, },
Required: []string{"pr_number", "repository", "owner", "comment"}, "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"},
} }
} }

View File

@@ -64,44 +64,20 @@ func (g *GithubPRReader) Run(ctx context.Context, params types.ActionParams) (ty
return types.ActionResult{Result: fmt.Sprintf("Error fetching pull request files: %s", err.Error())}, err return types.ActionResult{Result: fmt.Sprintf("Error fetching pull request files: %s", err.Error())}, err
} }
// Get CI status information
ciStatus := "\n\nCI Status:\n"
// Get PR status checks
checkRuns, _, err := g.client.Checks.ListCheckRunsForRef(ctx, result.Owner, result.Repository, pr.GetHead().GetSHA(), &github.ListCheckRunsOptions{})
if err == nil && checkRuns != nil {
ciStatus += fmt.Sprintf("\nPR Status Checks:\n")
ciStatus += fmt.Sprintf("Total Checks: %d\n", checkRuns.GetTotal())
for _, check := range checkRuns.CheckRuns {
ciStatus += fmt.Sprintf("- %s: %s (%s)\n",
check.GetName(),
check.GetConclusion(),
check.GetStatus())
}
}
// Build the file changes summary with patches // Build the file changes summary with patches
fileChanges := "\n\nFile Changes:\n" fileChanges := "\n\nFile Changes:\n"
for _, file := range files { for _, file := range files {
fileChanges += fmt.Sprintf("\n--- %s\n+++ %s\n", file.GetFilename(), file.GetFilename()) fileChanges += fmt.Sprintf("\n--- %s\n+++ %s\n", *file.Filename, *file.Filename)
if g.showFullDiff && file.GetPatch() != "" { if g.showFullDiff && file.Patch != nil {
fileChanges += file.GetPatch() fileChanges += *file.Patch
} }
fileChanges += fmt.Sprintf("\n(%d additions, %d deletions)\n", file.GetAdditions(), file.GetDeletions()) fileChanges += fmt.Sprintf("\n(%d additions, %d deletions)\n", *file.Additions, *file.Deletions)
} }
return types.ActionResult{ return types.ActionResult{
Result: fmt.Sprintf( Result: fmt.Sprintf(
"Pull Request %d Repository: %s\nTitle: %s\nBody: %s\nState: %s\nBase: %s\nHead: %s%s%s", "Pull Request %d Repository: %s\nTitle: %s\nBody: %s\nState: %s\nBase: %s\nHead: %s%s",
pr.GetNumber(), *pr.Number, *pr.Base.Repo.FullName, *pr.Title, *pr.Body, *pr.State, *pr.Base.Ref, *pr.Head.Ref, fileChanges)}, nil
pr.GetBase().GetRepo().GetFullName(),
pr.GetTitle(),
pr.GetBody(),
pr.GetState(),
pr.GetBase().GetRef(),
pr.GetHead().GetRef(),
ciStatus,
fileChanges)}, nil
} }
func (g *GithubPRReader) Definition() types.ActionDefinition { func (g *GithubPRReader) Definition() types.ActionDefinition {

View File

@@ -1,286 +0,0 @@
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 := fmt.Sprintf(
"Pull request https://github.com/%s/%s/pull/%d reviewed successfully with status: %s",
result.Owner,
result.Repository,
result.PRNumber,
strings.ToLower(result.ReviewAction),
)
return types.ActionResult{Result: actionResult}, 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",
},
}
}