diff --git a/core/action/goal.go b/core/action/goal.go new file mode 100644 index 0000000..51c8605 --- /dev/null +++ b/core/action/goal.go @@ -0,0 +1,49 @@ +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"}, + } +} diff --git a/core/agent/actions.go b/core/agent/actions.go index d40704d..9f2831a 100644 --- a/core/agent/actions.go +++ b/core/agent/actions.go @@ -79,6 +79,15 @@ func (m Messages) ToOpenAI() []openai.ChatCompletionMessage { 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 { s := "" for _, cc := range m { @@ -358,7 +367,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) { c := messages - xlog.Debug("picking action", "messages", messages) + xlog.Debug("[pickAction] picking action", "messages", messages) if !a.options.forceReasoning { xlog.Debug("not forcing reasoning") @@ -389,7 +398,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai. return chosenAction, thought.actionParams, thought.message, nil } - xlog.Debug("forcing reasoning") + xlog.Debug("[pickAction] forcing reasoning") prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "") if err != nil { @@ -406,71 +415,121 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai. }, 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 - } - - xlog.Debug("thought", "reason", reason) - - // From the thought, get the action call - // Get all the available actions IDs actionsID := []string{} for _, m := range a.availableActions() { actionsID = append(actionsID, m.Definition().Name.String()) } - intentionsTools := action.NewIntention(actionsID...) - // NOTE: we do not give the full conversation here to pick the action - // to avoid hallucinations + // thoughtPromptStringBuilder := strings.Builder{} + // 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, - []openai.ChatCompletionMessage{{ - Role: "assistant", - Content: reason, - }, - }, - types.Actions{intentionsTools}.ToTools(), - intentionsTools.Definition().Name, maxRetries) + []openai.ChatCompletionMessage{ + { + Role: "system", + Content: "Extract an action to perform from the following reasoning: ", + }, + { + Role: "user", + Content: originalReasoning, + }}, + types.Actions{action.NewGoal()}.ToTools(), + action.NewGoal().Definition().Name, maxRetries) if err != nil { return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err) } - actionChoice := action.IntentResponse{} - - if params.actionParams == nil { - return nil, nil, params.message, nil - } - - err = params.actionParams.Unmarshal(&actionChoice) + goalResponse := action.GoalResponse{} + err = params.actionParams.Unmarshal(&goalResponse) if err != nil { return nil, nil, "", err } - if actionChoice.Tool == "" || actionChoice.Tool == "none" { - return nil, nil, "", fmt.Errorf("no intent detected") + if goalResponse.Achieved { + xlog.Debug("[pickAction] goal achieved", "goal", goalResponse.Goal) + 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) + // if the goal is not achieved, pick an action + xlog.Debug("[pickAction] goal not achieved", "goal", goalResponse.Goal) + + xlog.Debug("[pickAction] thought", "conv", c, "originalReasoning", originalReasoning) + + // 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) } - return chosenAction, nil, actionChoice.Reasoning, nil + chosenAction := a.availableActions().Find(params.actioName) + + // 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 } diff --git a/core/agent/agent.go b/core/agent/agent.go index deaf998..69e0ee6 100644 --- a/core/agent/agent.go +++ b/core/agent/agent.go @@ -515,10 +515,21 @@ func (a *Agent) consumeJob(job *types.Job, role string) { //job.Result.Finish(fmt.Errorf("no action to do"))\ xlog.Info("No action to do, just reply", "agent", a.Character.Name, "reasoning", reasoning) - conv = append(conv, openai.ChatCompletionMessage{ - Role: "assistant", - Content: reasoning, - }) + if reasoning != "" { + conv = append(conv, openai.ChatCompletionMessage{ + Role: "assistant", + 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)) job.Result.Conversation = conv @@ -670,6 +681,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) { !chosenAction.Definition().Name.Is(action.ReplyActionName) { xlog.Info("Following action", "action", followingAction.Definition().Name, "agent", a.Character.Name) + job.ConversationHistory = conv // We need to do another action (?) // The agent decided to do another action diff --git a/core/agent/templates.go b/core/agent/templates.go index d08f4c1..5f657ea 100644 --- a/core/agent/templates.go +++ b/core/agent/templates.go @@ -82,11 +82,7 @@ Current State: - Short-term Memory: {{range .CurrentState.Memories}}{{.}} {{end}}{{end}} Current Time: {{.Time}}` -const pickSelfTemplate = `Available Tools: -{{range .Actions -}} -- {{.Name}}: {{.Description }} -{{ end }} - +const pickSelfTemplate = ` 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. @@ -108,40 +104,21 @@ Remember: - Keep track of your progress and state - Be proactive in addressing potential issues -{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}} -` + hudTemplate - -const reSelfEvalTemplate = pickSelfTemplate + ` - -Previous actions have been executed. Evaluate the current situation: - -1. Review the outcomes of previous actions -2. Assess progress toward your goals -3. Identify any issues or challenges -4. Determine if additional actions are needed - -Consider: -- Success of previous actions -- Changes in the situation -- New information or insights -- Potential next steps - -Make a decision about whether to: -- Continue with more actions -- Provide a final response -- Adjust your approach -- Update your goals or state` - -const pickActionTemplate = hudTemplate + ` Available Tools: {{range .Actions -}} - {{.Name}}: {{.Description }} {{ end }} -Task: Analyze the situation and determine the best course of action. +{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}} +` + hudTemplate + +const reSelfEvalTemplate = pickSelfTemplate + +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. Guidelines: -1. Review the current state and context +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 @@ -159,38 +136,11 @@ Decision Process: 4. Explain your reasoning 5. Execute the chosen action +Available Tools: +{{range .Actions -}} +- {{.Name}}: {{.Description }} +{{ end }} + {{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}` -const reEvalTemplate = pickActionTemplate + ` - -Previous actions have been executed. Let's evaluate the current situation: - -1. Review Previous Actions: - - What actions were taken - - What were the results - - Any issues or challenges encountered - -2. Assess Current State: - - Progress toward goals - - Changes in the situation - - New information or insights - - Current challenges or opportunities - -3. Determine Next Steps: - - Additional tools needed - - Final response required - - Error handling needed - - Approach adjustments required - -4. Decision Making: - - If task is complete: Use "reply" tool - - If errors exist: Address them appropriately - - If more actions needed: Explain why and which tools - - If situation changed: Adapt your approach - -Remember to: -- Consider all available information -- Be specific about next steps -- Explain your reasoning clearly -- Handle errors appropriately -- Provide complete responses when done` +const reEvalTemplate = pickActionTemplate diff --git a/services/actions.go b/services/actions.go index b352bb1..1e004fd 100644 --- a/services/actions.go +++ b/services/actions.go @@ -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", diff --git a/services/actions/githubprcommenter.go b/services/actions/githubprcommenter.go index e15a62b..810b6e1 100644 --- a/services/actions/githubprcommenter.go +++ b/services/actions/githubprcommenter.go @@ -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"}, } } diff --git a/services/actions/githubprreader.go b/services/actions/githubprreader.go index 6b547be..767ef47 100644 --- a/services/actions/githubprreader.go +++ b/services/actions/githubprreader.go @@ -64,20 +64,44 @@ 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 } + // 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 fileChanges := "\n\nFile Changes:\n" for _, file := range files { - fileChanges += fmt.Sprintf("\n--- %s\n+++ %s\n", *file.Filename, *file.Filename) - if g.showFullDiff && file.Patch != nil { - fileChanges += *file.Patch + fileChanges += fmt.Sprintf("\n--- %s\n+++ %s\n", file.GetFilename(), file.GetFilename()) + if g.showFullDiff && file.GetPatch() != "" { + fileChanges += file.GetPatch() } - fileChanges += fmt.Sprintf("\n(%d additions, %d deletions)\n", *file.Additions, *file.Deletions) + fileChanges += fmt.Sprintf("\n(%d additions, %d deletions)\n", file.GetAdditions(), file.GetDeletions()) } return types.ActionResult{ Result: fmt.Sprintf( - "Pull Request %d Repository: %s\nTitle: %s\nBody: %s\nState: %s\nBase: %s\nHead: %s%s", - *pr.Number, *pr.Base.Repo.FullName, *pr.Title, *pr.Body, *pr.State, *pr.Base.Ref, *pr.Head.Ref, fileChanges)}, nil + "Pull Request %d Repository: %s\nTitle: %s\nBody: %s\nState: %s\nBase: %s\nHead: %s%s%s", + pr.GetNumber(), + 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 { diff --git a/services/actions/githubprreviewer.go b/services/actions/githubprreviewer.go new file mode 100644 index 0000000..5959e1b --- /dev/null +++ b/services/actions/githubprreviewer.go @@ -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 := 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", + }, + } +}