package actions import ( "context" "fmt" "time" "github.com/google/go-github/v69/github" "github.com/mudler/LocalAGI/core/types" "github.com/mudler/LocalAGI/pkg/config" "github.com/sashabaranov/go-openai/jsonschema" ) const ( // forkCreationRetries is the number of times to retry checking if a fork is ready forkCreationRetries = 30 // forkCreationRetryDelay is the duration to wait between fork creation checks forkCreationRetryDelay = 5 * time.Second ) type GithubPRCreator struct { token, repository, owner, customActionName, defaultBranch string useFork bool client *github.Client } func NewGithubPRCreator(config map[string]string) *GithubPRCreator { client := github.NewClient(nil).WithAuthToken(config["token"]) defaultBranch := config["defaultBranch"] if defaultBranch == "" { defaultBranch = "main" // Default to "main" if not specified } useFork := config["useFork"] == "true" return &GithubPRCreator{ client: client, token: config["token"], repository: config["repository"], owner: config["owner"], customActionName: config["customActionName"], defaultBranch: defaultBranch, useFork: useFork, } } // ensureFork ensures that a fork exists for the given repository func (g *GithubPRCreator) ensureFork(ctx context.Context, owner, repo string) (string, error) { // First check if we already have a fork user, _, err := g.client.Users.Get(ctx, "") if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) } forkOwner := user.GetLogin() // Check if fork already exists _, _, err = g.client.Repositories.Get(ctx, forkOwner, repo) if err == nil { // Fork already exists return forkOwner, nil } // Create fork _, _, err = g.client.Repositories.CreateFork(ctx, owner, repo, &github.RepositoryCreateForkOptions{}) if err != nil { return "", fmt.Errorf("failed to create fork: %w", err) } // Wait for fork to be ready for i := 0; i < forkCreationRetries; i++ { _, _, err = g.client.Repositories.Get(ctx, forkOwner, repo) if err == nil { return forkOwner, nil } // Sleep for a bit before retrying time.Sleep(forkCreationRetryDelay) } return "", fmt.Errorf("fork creation timed out") } func (g *GithubPRCreator) createOrUpdateBranch(ctx context.Context, branchName string, owner string, repository string) error { // Get the latest commit SHA from the default branch ref, _, err := g.client.Git.GetRef(ctx, owner, repository, "refs/heads/"+g.defaultBranch) if err != nil { return fmt.Errorf("failed to get reference for default branch %s: %w", g.defaultBranch, err) } // Try to get the branch if it exists _, resp, err := g.client.Git.GetRef(ctx, owner, repository, "refs/heads/"+branchName) if err != nil { if resp == nil { return fmt.Errorf("failed to check branch existence: %w", err) } // If branch doesn't exist (404), create it if resp.StatusCode == 404 { newRef := &github.Reference{ Ref: github.String("refs/heads/" + branchName), Object: &github.GitObject{SHA: ref.Object.SHA}, } _, _, err = g.client.Git.CreateRef(ctx, owner, repository, newRef) if err != nil { return fmt.Errorf("failed to create branch: %w", err) } return nil } // For other errors, return the error return fmt.Errorf("failed to check branch existence: %w", err) } // Branch exists, update it to the latest commit updateRef := &github.Reference{ Ref: github.String("refs/heads/" + branchName), Object: &github.GitObject{SHA: ref.Object.SHA}, } _, _, err = g.client.Git.UpdateRef(ctx, owner, repository, updateRef, true) if err != nil { return fmt.Errorf("failed to update branch: %w", err) } return nil } func (g *GithubPRCreator) createOrUpdateFile(ctx context.Context, branch string, filePath string, content string, message string, owner string, repository string) error { // Get the current file content if it exists var sha *string fileContent, _, _, err := g.client.Repositories.GetContents(ctx, owner, repository, filePath, &github.RepositoryContentGetOptions{ Ref: branch, }) if err == nil && fileContent != nil { sha = fileContent.SHA } // Create or update the file _, _, err = g.client.Repositories.CreateFile(ctx, owner, repository, filePath, &github.RepositoryContentFileOptions{ Message: &message, Content: []byte(content), Branch: &branch, SHA: sha, }) if err != nil { return fmt.Errorf("failed to create/update file: %w", err) } return nil } func (g *GithubPRCreator) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) { result := struct { Repository string `json:"repository"` Owner string `json:"owner"` Branch string `json:"branch"` Title string `json:"title"` Body string `json:"body"` BaseBranch string `json:"base_branch"` Files []struct { Path string `json:"path"` Content string `json:"content"` } `json:"files"` }{} 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 } if result.BaseBranch == "" { result.BaseBranch = g.defaultBranch } var targetOwner, targetRepo string if g.useFork { // Ensure we have a fork and get the fork owner forkOwner, err := g.ensureFork(ctx, result.Owner, result.Repository) if err != nil { return types.ActionResult{}, fmt.Errorf("failed to ensure fork: %w", err) } targetOwner = forkOwner targetRepo = result.Repository } else { targetOwner = result.Owner targetRepo = result.Repository } // Create or update branch err = g.createOrUpdateBranch(ctx, result.Branch, targetOwner, targetRepo) if err != nil { return types.ActionResult{}, fmt.Errorf("failed to create/update branch: %w", err) } // Create or update files for _, file := range result.Files { err = g.createOrUpdateFile(ctx, result.Branch, file.Path, file.Content, fmt.Sprintf("Update %s", file.Path), targetOwner, targetRepo) if err != nil { return types.ActionResult{}, fmt.Errorf("failed to update file %s: %w", file.Path, err) } } // Check if PR already exists for this branch prs, _, err := g.client.PullRequests.List(ctx, result.Owner, result.Repository, &github.PullRequestListOptions{ State: "open", Head: fmt.Sprintf("%s:%s", targetOwner, result.Branch), }) if err != nil { return types.ActionResult{}, fmt.Errorf("failed to list pull requests: %w", err) } if len(prs) > 0 { // Update existing PR pr := prs[0] update := &github.PullRequest{ Title: &result.Title, Body: &result.Body, } updatedPR, _, err := g.client.PullRequests.Edit(ctx, result.Owner, result.Repository, pr.GetNumber(), update) if err != nil { return types.ActionResult{}, fmt.Errorf("failed to update pull request: %w", err) } return types.ActionResult{ Result: fmt.Sprintf("Updated pull request #%d: %s", updatedPR.GetNumber(), updatedPR.GetHTMLURL()), }, nil } // Create new pull request newPR := &github.NewPullRequest{ Title: &result.Title, Body: &result.Body, Head: &result.Branch, Base: &result.BaseBranch, } // If using a fork, we need to specify the full head reference if g.useFork { head := fmt.Sprintf("%s:%s", targetOwner, result.Branch) newPR.Head = &head } createdPR, _, err := g.client.PullRequests.Create(ctx, result.Owner, result.Repository, newPR) if err != nil { return types.ActionResult{}, fmt.Errorf("failed to create pull request: %w", err) } return types.ActionResult{ Result: fmt.Sprintf("Created pull request #%d: %s", createdPR.GetNumber(), createdPR.GetHTMLURL()), }, nil } func (g *GithubPRCreator) Definition() types.ActionDefinition { actionName := "create_github_pr" if g.customActionName != "" { actionName = g.customActionName } description := "Create a GitHub pull request with file changes" if g.repository != "" && g.owner != "" && g.defaultBranch != "" { return types.ActionDefinition{ Name: types.ActionDefinitionName(actionName), Description: description, Properties: map[string]jsonschema.Definition{ "branch": { Type: jsonschema.String, Description: "The name of the new branch to create", }, "title": { Type: jsonschema.String, Description: "The title of the pull request", }, "body": { Type: jsonschema.String, Description: "The body/description of the pull request", }, "files": { Type: jsonschema.Array, Items: &jsonschema.Definition{ Type: jsonschema.Object, Properties: map[string]jsonschema.Definition{ "path": { Type: jsonschema.String, Description: "The path of the file to create/update", }, "content": { Type: jsonschema.String, Description: "The content of the file", }, }, Required: []string{"path", "content"}, }, Description: "Array of files to create or update", }, }, Required: []string{"branch", "title", "files"}, } } return types.ActionDefinition{ Name: types.ActionDefinitionName(actionName), Description: description, Properties: map[string]jsonschema.Definition{ "branch": { Type: jsonschema.String, Description: "The name of the new branch to create", }, "title": { Type: jsonschema.String, Description: "The title of the pull request", }, "body": { Type: jsonschema.String, Description: "The body/description of the pull request", }, "base_branch": { Type: jsonschema.String, Description: "The base branch to merge into (defaults to configured default branch)", }, "files": { Type: jsonschema.Array, Items: &jsonschema.Definition{ Type: jsonschema.Object, Properties: map[string]jsonschema.Definition{ "path": { Type: jsonschema.String, Description: "The path of the file to create/update", }, "content": { Type: jsonschema.String, Description: "The content of the file", }, }, Required: []string{"path", "content"}, }, Description: "Array of files to create or update", }, "repository": { Type: jsonschema.String, Description: "The repository to create the pull request in", }, "owner": { Type: jsonschema.String, Description: "The owner of the repository", }, }, Required: []string{"branch", "title", "files", "repository", "owner"}, } } func (a *GithubPRCreator) Plannable() bool { return true } // GithubPRCreatorConfigMeta returns the metadata for GitHub PR Creator action configuration fields func GithubPRCreatorConfigMeta() []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", }, { Name: "defaultBranch", Label: "Default Branch", Type: config.FieldTypeText, Required: false, HelpText: "Default branch to create PRs against (defaults to main)", }, { Name: "useFork", Label: "Use Fork", Type: config.FieldTypeCheckbox, Required: false, HelpText: "Whether to create PRs from a fork (useful when you don't have write access to the repository). Set to 'true' to enable.", }, } }