├── .air.toml ├── .github └── workflows │ └── .ci-cd.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── api └── proto │ ├── mule.pb.go │ ├── mule.proto │ └── mule_grpc.pb.go ├── cmd └── mule │ ├── main.go │ ├── static │ ├── css │ │ └── styles.css │ └── js │ │ └── settings.js │ └── templates │ ├── home.html │ ├── layout.html │ ├── local-provider.html │ ├── logs.html │ ├── settings.html │ └── static │ ├── favicon.ico │ └── logo.png ├── go.mod ├── go.sum ├── internal ├── config │ ├── config.go │ └── config_test.go ├── handlers │ ├── github.go │ ├── handlers.go │ ├── handlers_test.go │ ├── local.go │ ├── logs.go │ ├── models.go │ ├── repository.go │ └── settings.go ├── scheduler │ ├── scheduler.go │ └── scheduler_test.go ├── settings │ ├── defaultSettings.go │ └── settings.go └── state │ └── state.go ├── llms.txt ├── pkg ├── agent │ ├── agent.go │ ├── udiff.go │ └── workflow.go ├── auth │ └── ssh.go ├── integration │ ├── api │ │ └── api.go │ ├── discord │ │ ├── discord.go │ │ └── discord_memory.go │ ├── grpc │ │ ├── grpc.go │ │ ├── grpc_test.go │ │ ├── server.go │ │ └── server_test.go │ ├── integration.go │ ├── matrix │ │ ├── formatter.go │ │ ├── matrix.go │ │ └── matrix_memory.go │ ├── memory │ │ ├── formatter.go │ │ ├── inmemory.go │ │ ├── memory.go │ │ └── memory_test.go │ ├── system │ │ ├── handlers.go │ │ ├── system.go │ │ └── types.go │ └── tasks │ │ ├── tasks.go │ │ └── tasks_memory.go ├── log │ └── log.go ├── rag │ ├── rag.go │ └── treesitter.go ├── remote │ ├── github │ │ ├── comments.go │ │ ├── github.go │ │ ├── issue.go │ │ └── provider.go │ ├── local │ │ └── local.go │ ├── remote.go │ └── types │ │ └── types.go ├── repository │ ├── branch.go │ ├── branchChanges.go │ ├── issue.go │ ├── prompts.go │ ├── pullRequest.go │ ├── repository.go │ └── state.go ├── types │ └── types.go └── validation │ ├── functions.go │ └── validation.go └── wiki ├── architecture.md ├── index.md ├── internal-config.md ├── internal-handlers.md ├── internal-scheduler.md ├── internal-settings.md ├── pkg-agent.md ├── pkg-remote-github.md ├── pkg-remote-local.md ├── pkg-remote.md ├── pkg-repository.md └── pkg-validation.md /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./cmd/mule/bin/mule --server" 8 | cmd = "make all" 9 | delay = 1000 10 | exclude_dir = [ 11 | "assets", 12 | "tmp", 13 | "vendor", 14 | "testdata", 15 | "frontend/node_modules", 16 | "frontend/build", 17 | "frontend/dist" 18 | ] 19 | exclude_file = [] 20 | exclude_regex = [] 21 | exclude_unchanged = false 22 | follow_symlink = false 23 | full_bin = "" 24 | include_dir = [] 25 | include_ext = ["go", "tpl", "tmpl", "html"] 26 | include_file = [] 27 | kill_delay = "0s" 28 | log = "build-errors.log" 29 | poll = false 30 | poll_interval = 0 31 | post_cmd = [] 32 | pre_cmd = [] 33 | rerun = false 34 | rerun_delay = 500 35 | send_interrupt = false 36 | stop_on_error = true 37 | 38 | [color] 39 | app = "" 40 | build = "yellow" 41 | main = "magenta" 42 | runner = "green" 43 | watcher = "cyan" 44 | 45 | [log] 46 | main_only = false 47 | time = false 48 | 49 | [misc] 50 | clean_on_exit = false 51 | 52 | [proxy] 53 | app_port = 0 54 | enabled = false 55 | proxy_port = 0 56 | 57 | [screen] 58 | clear_on_rebuild = false 59 | keep_scroll = true 60 | -------------------------------------------------------------------------------- /.github/workflows/.ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Github Action to Lint, test, deploy and release 2 | on: 3 | push: 4 | branches: [main] 5 | tags: ["*"] 6 | pull_request: 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | lint: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version: stable 20 | - name: golangci-lint 21 | uses: golangci/golangci-lint-action@v6 22 | with: 23 | version: v1.64 24 | args: --fast # Enable only fast linters 25 | # Can be replace by '--enable-all' to enable all linters 26 | # Or enable presets of linters by '--presets ' see https://golangci-lint.run/usage/configuration/#linters-configuration 27 | test: 28 | name: test 29 | runs-on: ubuntu-latest 30 | needs: lint 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-go@v5 34 | with: 35 | go-version: stable 36 | - name: install olm 37 | run: sudo apt-get update && sudo apt-get install -y libolm-dev 38 | - name: Download dependencies 39 | run: go mod tidy 40 | - name: Run tests 41 | run: go test ./... -v 42 | build: 43 | name: build 44 | runs-on: ubuntu-latest 45 | needs: test 46 | if: startsWith(github.ref, 'refs/tags/') 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: actions/setup-go@v5 50 | with: 51 | go-version: "stable" 52 | - name: install olm 53 | run: sudo apt-get update && sudo apt-get install -y libolm-dev 54 | - name: Build binary 55 | run: | 56 | cd cmd/mule 57 | CGO_ENABLED=1 GOOS=linux go build -o bin/mule 58 | - name: Upload Artifact 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: mule-binary 62 | path: cmd/mule/bin/mule 63 | 64 | release: 65 | name: Create GitHub Release 66 | runs-on: ubuntu-latest 67 | needs: build 68 | steps: 69 | - uses: actions/checkout@v4 70 | - name: Download artifact 71 | uses: actions/download-artifact@v4 72 | with: 73 | name: mule-binary 74 | - name: Create Release 75 | uses: ncipollo/release-action@v1 76 | with: 77 | artifacts: mule 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | crypto* 8 | *.db 9 | *.db-shm 10 | *.db-wal 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool 16 | *.out 17 | 18 | # Go workspace file 19 | go.work 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # IDE specific files 25 | .idea/ 26 | .vscode/ 27 | *.swp 28 | *.swo 29 | *~ 30 | 31 | # OS specific files 32 | .DS_Store 33 | .DS_Store? 34 | ._* 35 | .Spotlight-V100 36 | .Trashes 37 | ehthumbs.db 38 | Thumbs.db 39 | 40 | # Binary output directories 41 | bin/ 42 | dist/ 43 | 44 | # Environment files 45 | .env 46 | .env.local 47 | *.env 48 | 49 | # Log files 50 | *.log 51 | 52 | # Temporary files 53 | tmp/ 54 | temp/ 55 | 56 | # config files 57 | config.yaml 58 | config.json 59 | config.yml 60 | **/.claude/settings.local.json 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Jeremiah Butler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all clean build 2 | 3 | # Install golangci-lint if it doesn't exist 4 | .PHONY: download-golangci-lint 5 | download-golangci-lint: 6 | ifeq (,$(wildcard ./bin/golangci-lint)) 7 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.64.5 8 | endif 9 | 10 | # Install air if it doesn't exist 11 | .PHONY: download-air 12 | download-air: 13 | ifeq (,$(wildcard ./bin/air)) 14 | curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s 15 | endif 16 | 17 | # Download missing modules 18 | .PHONY: tidy 19 | tidy: 20 | go mod tidy 21 | 22 | # Run go fmt 23 | .PHONY: fmt 24 | fmt: 25 | go fmt ./... 26 | 27 | # Run linting 28 | .PHONY: lint 29 | lint: download-golangci-lint tidy fmt 30 | ./bin/golangci-lint run 31 | 32 | # Run full test 33 | .PHONY: test 34 | test: lint 35 | go test -v ./... 36 | 37 | # Run air for test on save 38 | .PHONY: air 39 | air: download-golangci-lint download-air 40 | ./bin/air 41 | 42 | # Build everything 43 | all: clean fmt test build 44 | 45 | # Clean build artifacts 46 | clean: 47 | rm -f ./cmd/mule/bin/mule 48 | 49 | # Build backend 50 | build: 51 | cd cmd/mule && CGO_ENABLED=1 GOOS=linux go build -o bin/mule 52 | 53 | # Run the application 54 | run: all 55 | ./cmd/mule/bin/mule -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mule 2 | 3 | ### Your AI development team 4 | 5 | mule is an AI Agent that monitors your git repositories and completes issues assigned to it. 6 | 7 | Issues are assigned by giving them the `mule` label. 8 | 9 | After the work is completed, the agent will create a pull request. Additional refinement can be requested by commenting on the pull request. 10 | 11 | When the pull request is closed or merged, no more work will be completed unless the issue is reopened. 12 | 13 | It is intended that the agent will be able to work on multiple issues at once through the creation of multiple pull requests. 14 | 15 | ## Demo 16 | 17 | Below is a quick demo of the agent interaction workflow using the local provider. This same workflow can be done using a GitHub provider and performing these steps in the GitHub UI. 18 | 19 | https://github.com/user-attachments/assets/f891017b-3794-4b8f-b779-63f0d9e97087 20 | 21 | ## Docs 22 | 23 | Documentation is available on [muleai.io](https://muleai.io/docs) 24 | 25 | ## Contributing 26 | 27 | * Find an issue marked `good first issue` 28 | * Open a Pull Request 29 | 30 | ## To Do 31 | 32 | * Perform RAG for better results 33 | * Create multi-agent workflows 34 | * Add the ability to create a new repository 35 | * Implement manager mode to allow spawning multiple agents that track their own repository 36 | -------------------------------------------------------------------------------- /api/proto/mule.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package mule.v1; 4 | 5 | option go_package = "github.com/mule-ai/mule/api/proto"; 6 | 7 | import "google/protobuf/timestamp.proto"; 8 | 9 | // MuleService provides gRPC endpoints for Mule functionality 10 | service MuleService { 11 | // GetHeartbeat returns a simple heartbeat to check if the service is alive 12 | rpc GetHeartbeat(HeartbeatRequest) returns (HeartbeatResponse); 13 | 14 | // ListWorkflows returns all available workflows 15 | rpc ListWorkflows(ListWorkflowsRequest) returns (ListWorkflowsResponse); 16 | 17 | // GetWorkflow returns details about a specific workflow 18 | rpc GetWorkflow(GetWorkflowRequest) returns (GetWorkflowResponse); 19 | 20 | // ListProviders returns all genAI providers 21 | rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); 22 | 23 | // ListAgents returns all available agents 24 | rpc ListAgents(ListAgentsRequest) returns (ListAgentsResponse); 25 | 26 | // GetAgent returns details about a specific agent 27 | rpc GetAgent(GetAgentRequest) returns (GetAgentResponse); 28 | 29 | // ListRunningWorkflows returns currently executing workflows 30 | rpc ListRunningWorkflows(ListRunningWorkflowsRequest) returns (ListRunningWorkflowsResponse); 31 | 32 | // ExecuteWorkflow starts a new workflow execution 33 | rpc ExecuteWorkflow(ExecuteWorkflowRequest) returns (ExecuteWorkflowResponse); 34 | } 35 | 36 | // Heartbeat messages 37 | message HeartbeatRequest {} 38 | 39 | message HeartbeatResponse { 40 | string status = 1; 41 | google.protobuf.Timestamp timestamp = 2; 42 | string version = 3; 43 | } 44 | 45 | // Provider messages 46 | message ListProvidersRequest {} 47 | 48 | message ListProvidersResponse { 49 | repeated Provider providers = 1; 50 | } 51 | 52 | message Provider { 53 | string name = 1; 54 | } 55 | 56 | // Workflow messages 57 | message ListWorkflowsRequest {} 58 | 59 | message ListWorkflowsResponse { 60 | repeated Workflow workflows = 1; 61 | } 62 | 63 | message GetWorkflowRequest { 64 | string name = 1; 65 | } 66 | 67 | message GetWorkflowResponse { 68 | Workflow workflow = 1; 69 | } 70 | 71 | message Workflow { 72 | string id = 1; 73 | string name = 2; 74 | string description = 3; 75 | bool is_default = 4; 76 | repeated WorkflowStep steps = 5; 77 | repeated string validation_functions = 6; 78 | repeated TriggerSettings triggers = 7; 79 | repeated TriggerSettings outputs = 8; 80 | } 81 | 82 | message WorkflowStep { 83 | string id = 1; 84 | int32 agent_id = 2; 85 | string agent_name = 3; 86 | string output_field = 4; 87 | TriggerSettings integration = 5; 88 | } 89 | 90 | message TriggerSettings { 91 | string integration = 1; 92 | string name = 2; 93 | map data = 3; 94 | } 95 | 96 | // Agent messages 97 | message ListAgentsRequest {} 98 | 99 | message ListAgentsResponse { 100 | repeated Agent agents = 1; 101 | } 102 | 103 | message GetAgentRequest { 104 | int32 id = 1; 105 | } 106 | 107 | message GetAgentResponse { 108 | Agent agent = 1; 109 | } 110 | 111 | message Agent { 112 | int32 id = 1; 113 | string name = 2; 114 | string provider_name = 3; 115 | string model = 4; 116 | string prompt_template = 5; 117 | string system_prompt = 6; 118 | repeated string tools = 7; 119 | UDiffSettings udiff_settings = 8; 120 | } 121 | 122 | message UDiffSettings { 123 | bool enabled = 1; 124 | } 125 | 126 | // Running workflows messages 127 | message ListRunningWorkflowsRequest {} 128 | 129 | message ListRunningWorkflowsResponse { 130 | repeated RunningWorkflow running_workflows = 1; 131 | } 132 | 133 | message RunningWorkflow { 134 | string execution_id = 1; 135 | string workflow_name = 2; 136 | string status = 3; 137 | google.protobuf.Timestamp started_at = 4; 138 | repeated WorkflowStepResult step_results = 5; 139 | string current_step = 6; 140 | } 141 | 142 | message WorkflowStepResult { 143 | string step_id = 1; 144 | string status = 2; 145 | string content = 3; 146 | string error_message = 4; 147 | google.protobuf.Timestamp completed_at = 5; 148 | } 149 | 150 | // Execute workflow messages 151 | message ExecuteWorkflowRequest { 152 | string workflow_name = 1; 153 | string prompt = 2; 154 | string path = 3; 155 | } 156 | 157 | message ExecuteWorkflowResponse { 158 | string execution_id = 1; 159 | string status = 2; 160 | string message = 3; 161 | } -------------------------------------------------------------------------------- /cmd/mule/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "flag" 6 | "fmt" 7 | "html/template" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/go-logr/logr" 12 | "github.com/mule-ai/mule/internal/config" 13 | "github.com/mule-ai/mule/internal/handlers" 14 | "github.com/mule-ai/mule/internal/settings" 15 | "github.com/mule-ai/mule/internal/state" 16 | "github.com/mule-ai/mule/pkg/agent" 17 | "github.com/mule-ai/mule/pkg/log" 18 | "github.com/mule-ai/mule/pkg/repository" 19 | 20 | // Import integrations to register them 21 | _ "github.com/mule-ai/mule/pkg/integration/grpc" 22 | 23 | "github.com/rs/cors" 24 | ) 25 | 26 | //go:embed templates 27 | var templatesFS embed.FS 28 | 29 | //go:embed templates/static 30 | var staticFS embed.FS 31 | 32 | var templates *template.Template 33 | 34 | func init() { 35 | var err error 36 | 37 | // Define template functions 38 | funcMap := template.FuncMap{ 39 | "add": func(a, b int) int { 40 | return a + b 41 | }, 42 | } 43 | 44 | templates, err = template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html") 45 | if err != nil { 46 | panic(err) 47 | } 48 | handlers.InitTemplates(templates) 49 | } 50 | 51 | func main() { 52 | // Initialize log 53 | l := log.New("") 54 | 55 | // Create config path 56 | configPath, err := config.GetHomeConfigPath() 57 | if err != nil { 58 | l.Error(err, "Error getting config path") 59 | } 60 | 61 | // Load config 62 | appState, err := config.LoadConfig(configPath, l) 63 | if err != nil { 64 | l.Error(err, "Error loading config") 65 | } 66 | 67 | state.State = appState 68 | 69 | // read flags 70 | serverMode := flag.Bool("server", false, "run in server mode") 71 | workflow := flag.String("workflow", "", "workflow to run") 72 | prompt := flag.String("prompt", "", "prompt to run workflow with") 73 | flag.Parse() 74 | if *serverMode { 75 | server(l) 76 | } else if *workflow != "" { 77 | l = log.NewStdoutLogger() 78 | runWorkflow(l, *workflow, *prompt) 79 | } else { 80 | l.Error(nil, "No server mode or workflow specified") 81 | } 82 | } 83 | 84 | func runWorkflow(l logr.Logger, workflowName string, prompt string) { 85 | if prompt == "" { 86 | l.Error(nil, "No prompt specified") 87 | return 88 | } 89 | 90 | // load workflow 91 | workflow, ok := state.State.Workflows[workflowName] 92 | if !ok { 93 | workflowOptions := make([]string, 0, len(state.State.Workflows)) 94 | for name := range state.State.Workflows { 95 | workflowOptions = append(workflowOptions, name) 96 | } 97 | l.Error(nil, "Workflow not found", "options", workflowOptions) 98 | return 99 | } 100 | 101 | l.Info(fmt.Sprintf("Running workflow: %s with prompt: %s", workflowName, prompt)) 102 | // run workflow 103 | results, err := workflow.ExecuteWorkflow(workflow.Steps, state.State.Agents, agent.PromptInput{ 104 | Message: prompt, 105 | }, "", l, workflow.ValidationFunctions) 106 | if err != nil { 107 | l.Error(err, "Error executing workflow") 108 | } 109 | finalResult, ok := results["final"] 110 | if !ok || finalResult.Error != nil || finalResult.Content == "" { 111 | l.Error(fmt.Errorf("final result not found"), "Final result not found") 112 | finalResult.Content = "An error occurred while executing the workflow, please try again." 113 | } 114 | l.Info(fmt.Sprintf("Workflow result: %s", finalResult.Content)) 115 | } 116 | 117 | func server(l logr.Logger) { 118 | mux := http.NewServeMux() 119 | 120 | // API routes 121 | mux.HandleFunc("/api/repositories", methodsHandler(map[string]http.HandlerFunc{ 122 | http.MethodGet: handlers.HandleListRepositories, 123 | http.MethodPost: handlers.HandleAddRepository, 124 | http.MethodDelete: handlers.HandleDeleteRepository, 125 | })) 126 | 127 | mux.HandleFunc("/api/repositories/clone", methodsHandler(map[string]http.HandlerFunc{ 128 | http.MethodPost: handlers.HandleCloneRepository, 129 | })) 130 | mux.HandleFunc("/api/repositories/update", methodsHandler(map[string]http.HandlerFunc{ 131 | http.MethodPost: handlers.HandleUpdateRepository, 132 | })) 133 | mux.HandleFunc("/api/repositories/sync", methodsHandler(map[string]http.HandlerFunc{ 134 | http.MethodPost: handlers.HandleSyncRepository, 135 | })) 136 | mux.HandleFunc("/api/repositories/provider", methodsHandler(map[string]http.HandlerFunc{ 137 | http.MethodPost: handlers.HandleSwitchProvider, 138 | })) 139 | 140 | mux.HandleFunc("/api/models", methodsHandler(map[string]http.HandlerFunc{ 141 | http.MethodGet: handlers.HandleModels, 142 | })) 143 | mux.HandleFunc("/api/tools", methodsHandler(map[string]http.HandlerFunc{ 144 | http.MethodGet: handlers.HandleTools, 145 | })) 146 | mux.HandleFunc("/api/validation-functions", methodsHandler(map[string]http.HandlerFunc{ 147 | http.MethodGet: handlers.HandleValidationFunctions, 148 | })) 149 | mux.HandleFunc("/api/template-values", methodsHandler(map[string]http.HandlerFunc{ 150 | http.MethodGet: handlers.HandleTemplateValues, 151 | })) 152 | mux.HandleFunc("/api/workflow-output-fields", methodsHandler(map[string]http.HandlerFunc{ 153 | http.MethodGet: handlers.HandleWorkflowOutputFields, 154 | })) 155 | mux.HandleFunc("/api/workflow-input-mappings", methodsHandler(map[string]http.HandlerFunc{ 156 | http.MethodGet: handlers.HandleWorkflowInputMappings, 157 | })) 158 | 159 | // GitHub API routes 160 | mux.HandleFunc("/api/github/repositories", methodsHandler(map[string]http.HandlerFunc{ 161 | http.MethodGet: handlers.HandleGitHubRepositories, 162 | })) 163 | mux.HandleFunc("/api/github/issues", methodsHandler(map[string]http.HandlerFunc{ 164 | http.MethodGet: handlers.HandleGitHubIssues, 165 | })) 166 | 167 | // Local provider routes 168 | mux.HandleFunc("/api/local/issues", methodsHandler(map[string]http.HandlerFunc{ 169 | http.MethodPost: handlers.HandleCreateLocalIssue, 170 | http.MethodDelete: handlers.HandleDeleteLocalIssue, 171 | })) 172 | 173 | mux.HandleFunc("/api/local/issues/update", methodsHandler(map[string]http.HandlerFunc{ 174 | http.MethodPost: handlers.HandleUpdateLocalIssue, 175 | })) 176 | mux.HandleFunc("/api/local/pullrequests", methodsHandler(map[string]http.HandlerFunc{ 177 | http.MethodDelete: handlers.HandleDeleteLocalPullRequest, 178 | })) 179 | mux.HandleFunc("/api/local/comments", methodsHandler(map[string]http.HandlerFunc{ 180 | http.MethodPost: handlers.HandleAddLocalComment, 181 | })) 182 | mux.HandleFunc("/api/local/reactions", methodsHandler(map[string]http.HandlerFunc{ 183 | http.MethodPost: handlers.HandleAddLocalReaction, 184 | })) 185 | mux.HandleFunc("/api/local/diff", methodsHandler(map[string]http.HandlerFunc{ 186 | http.MethodGet: handlers.HandleGetLocalDiff, 187 | })) 188 | mux.HandleFunc("/api/local/labels", methodsHandler(map[string]http.HandlerFunc{ 189 | http.MethodPost: handlers.HandleAddLocalLabel, 190 | })) 191 | mux.HandleFunc("/api/local/issues/state", methodsHandler(map[string]http.HandlerFunc{ 192 | http.MethodPost: handlers.HandleUpdateLocalIssueState, 193 | })) 194 | mux.HandleFunc("/api/local/pullrequests/state", methodsHandler(map[string]http.HandlerFunc{ 195 | http.MethodPost: handlers.HandleUpdateLocalPullRequestState, 196 | })) 197 | 198 | // Settings routes 199 | mux.HandleFunc("/api/settings", methodsHandler(map[string]http.HandlerFunc{ 200 | http.MethodPost: handlers.HandleUpdateSettings, 201 | })) 202 | 203 | // Web routes 204 | mux.HandleFunc("/", handleHome) 205 | mux.HandleFunc("/settings", handleSettingsPage) 206 | mux.HandleFunc("/local-provider", handlers.HandleLocalProviderPage) 207 | mux.HandleFunc("/logs", handlers.HandleLogs) 208 | 209 | // Static files 210 | staticHandler := http.FileServer(http.FS(staticFS)) 211 | mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { 212 | r.URL.Path = "templates/static/" + strings.TrimPrefix(r.URL.Path, "/static/") 213 | staticHandler.ServeHTTP(w, r) 214 | }) 215 | 216 | // CORS 217 | c := cors.New(cors.Options{ 218 | AllowedOrigins: []string{"*"}, 219 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 220 | AllowedHeaders: []string{"Accept", "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization"}, 221 | }) 222 | 223 | // Start the scheduler 224 | state.State.Scheduler.Start() 225 | defer state.State.Scheduler.Stop() 226 | 227 | handler := c.Handler(mux) 228 | 229 | defaultWorkflow := state.State.Workflows["default"] 230 | go func() { 231 | for _, repo := range state.State.Repositories { 232 | err := repo.Sync(state.State.Agents, defaultWorkflow) 233 | if err != nil { 234 | l.Error(err, "Error syncing repo") 235 | } 236 | } 237 | }() 238 | 239 | // Start server 240 | l.Info("Starting server on :8083") 241 | if err := http.ListenAndServe(":8083", handler); err != nil { 242 | l.Error(err, "Error starting server") 243 | } 244 | } 245 | 246 | // Helper function to handle specific HTTP methods 247 | func methodsHandler(handlers map[string]http.HandlerFunc) http.HandlerFunc { 248 | return func(w http.ResponseWriter, r *http.Request) { 249 | 250 | if handler, ok := handlers[r.Method]; ok { 251 | handler(w, r) 252 | return 253 | } 254 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 255 | } 256 | } 257 | 258 | type PageData struct { 259 | Page string 260 | Repositories map[string]*repository.Repository 261 | Settings settings.Settings 262 | } 263 | 264 | func handleHome(w http.ResponseWriter, r *http.Request) { 265 | // Only handle exact "/" path to avoid conflicts with other routes 266 | if r.URL.Path != "/" { 267 | http.NotFound(w, r) 268 | return 269 | } 270 | 271 | state.State.Mu.RLock() 272 | data := PageData{ 273 | Page: "home", 274 | Repositories: state.State.Repositories, 275 | Settings: state.State.Settings, 276 | } 277 | state.State.Mu.RUnlock() 278 | 279 | err := templates.ExecuteTemplate(w, "layout.html", data) 280 | if err != nil { 281 | http.Error(w, err.Error(), http.StatusInternalServerError) 282 | } 283 | } 284 | 285 | func handleSettingsPage(w http.ResponseWriter, r *http.Request) { 286 | state.State.Mu.RLock() 287 | defer state.State.Mu.RUnlock() 288 | 289 | data := PageData{ 290 | Page: "settings", 291 | Settings: state.State.Settings, 292 | } 293 | 294 | err := templates.ExecuteTemplate(w, "layout.html", data) 295 | if err != nil { 296 | http.Error(w, err.Error(), http.StatusInternalServerError) 297 | return 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /cmd/mule/static/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Workflow Diagram Styles */ 2 | .workflow-visualization { 3 | margin-top: 20px; 4 | margin-bottom: 20px; 5 | } 6 | 7 | .workflow-diagram { 8 | background-color: #f5f5f5; 9 | border-radius: 8px; 10 | padding: 20px; 11 | margin-top: 10px; 12 | } 13 | 14 | .workflow-diagram-steps { 15 | display: flex; 16 | flex-direction: row; 17 | align-items: center; 18 | flex-wrap: wrap; 19 | gap: 10px; 20 | } 21 | 22 | .diagram-step { 23 | background-color: white; 24 | border: 1px solid #ddd; 25 | border-radius: 6px; 26 | padding: 10px; 27 | min-width: 200px; 28 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); 29 | display: flex; 30 | align-items: flex-start; 31 | } 32 | 33 | .diagram-step-number { 34 | background-color: var(--primary-color); 35 | color: white; 36 | width: 24px; 37 | height: 24px; 38 | border-radius: 50%; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | font-weight: bold; 43 | margin-right: 10px; 44 | flex-shrink: 0; 45 | } 46 | 47 | .diagram-step-content { 48 | flex-grow: 1; 49 | } 50 | 51 | .diagram-agent-name { 52 | font-weight: bold; 53 | margin-bottom: 5px; 54 | } 55 | 56 | .diagram-step-details { 57 | font-size: 0.85em; 58 | color: #666; 59 | display: flex; 60 | flex-direction: column; 61 | gap: 3px; 62 | } 63 | 64 | .diagram-first-step { 65 | color: var(--primary-color); 66 | font-weight: 500; 67 | } 68 | 69 | .diagram-connector { 70 | display: flex; 71 | align-items: center; 72 | color: var(--primary-color); 73 | } 74 | 75 | .diagram-connector svg { 76 | width: 24px; 77 | height: 24px; 78 | fill: currentColor; 79 | } 80 | 81 | .empty-diagram { 82 | color: #888; 83 | font-style: italic; 84 | padding: 10px; 85 | text-align: center; 86 | } 87 | 88 | @media (max-width: 768px) { 89 | .workflow-diagram-steps { 90 | flex-direction: column; 91 | align-items: stretch; 92 | } 93 | 94 | .diagram-connector { 95 | transform: rotate(90deg); 96 | margin: 5px 0; 97 | } 98 | } -------------------------------------------------------------------------------- /cmd/mule/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mule 8 | 257 | 258 | 259 | 272 |
273 | {{if eq .Page "home"}} 274 | {{template "home" .}} 275 | {{else if eq .Page "logs"}} 276 | {{template "logs" .}} 277 | {{else if eq .Page "local"}} 278 | {{template "local-provider" .}} 279 | {{else}} 280 | {{template "settings" .}} 281 | {{end}} 282 |
283 | 284 | -------------------------------------------------------------------------------- /cmd/mule/templates/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mule-ai/mule/13ca5c2446d9ae2dfcee36077df8b9e743a23861/cmd/mule/templates/static/favicon.ico -------------------------------------------------------------------------------- /cmd/mule/templates/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mule-ai/mule/13ca5c2446d9ae2dfcee36077df8b9e743a23861/cmd/mule/templates/static/logo.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mule-ai/mule 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/bwmarrin/discordgo v0.28.1 7 | github.com/fsnotify/fsnotify v1.8.0 8 | github.com/go-git/go-git/v5 v5.16.0 9 | github.com/go-logr/logr v1.4.2 10 | github.com/go-logr/zapr v1.3.0 11 | github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b 12 | github.com/google/go-github/v60 v60.0.0 13 | github.com/google/uuid v1.6.0 14 | github.com/jbutlerdev/genai v0.0.0-20250528230134-0fc99a526c60 15 | github.com/mitchellh/mapstructure v1.5.0 16 | github.com/philippgille/chromem-go v0.7.0 17 | github.com/robfig/cron/v3 v3.0.1 18 | github.com/rs/cors v1.11.1 19 | github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 20 | github.com/spf13/viper v1.20.1 21 | github.com/stretchr/testify v1.10.0 22 | go.uber.org/zap v1.27.0 23 | golang.org/x/oauth2 v0.28.0 24 | google.golang.org/grpc v1.72.2 25 | google.golang.org/protobuf v1.36.6 26 | maunium.net/go/mautrix v0.23.3 27 | ) 28 | 29 | require ( 30 | cloud.google.com/go v0.120.0 // indirect 31 | cloud.google.com/go/ai v0.10.1 // indirect 32 | cloud.google.com/go/auth v0.15.0 // indirect 33 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 34 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 35 | cloud.google.com/go/longrunning v0.6.6 // indirect 36 | dario.cat/mergo v1.0.1 // indirect 37 | filippo.io/edwards25519 v1.1.0 // indirect 38 | github.com/Microsoft/go-winio v0.6.2 // indirect 39 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 40 | github.com/cloudflare/circl v1.6.1 // indirect 41 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 42 | github.com/davecgh/go-spew v1.1.1 // indirect 43 | github.com/emirpasic/gods v1.18.1 // indirect 44 | github.com/felixge/httpsnoop v1.0.4 // indirect 45 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 46 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 47 | github.com/go-logr/stdr v1.2.2 // indirect 48 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 49 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 50 | github.com/google/generative-ai-go v0.19.0 // indirect 51 | github.com/google/go-querystring v1.1.0 // indirect 52 | github.com/google/s2a-go v0.1.9 // indirect 53 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 54 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 55 | github.com/gorilla/websocket v1.5.0 // indirect 56 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 57 | github.com/kevinburke/ssh_config v1.2.0 // indirect 58 | github.com/mattn/go-colorable v0.1.14 // indirect 59 | github.com/mattn/go-isatty v0.0.20 // indirect 60 | github.com/mattn/go-sqlite3 v1.14.27 // indirect 61 | github.com/ollama/ollama v0.6.2 // indirect 62 | github.com/openai/openai-go v0.1.0-beta.2 // indirect 63 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 64 | github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // indirect 65 | github.com/pjbgf/sha1cd v0.3.2 // indirect 66 | github.com/pmezard/go-difflib v1.0.0 // indirect 67 | github.com/rs/zerolog v1.34.0 // indirect 68 | github.com/sagikazarmark/locafero v0.7.0 // indirect 69 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 70 | github.com/skeema/knownhosts v1.3.1 // indirect 71 | github.com/sourcegraph/conc v0.3.0 // indirect 72 | github.com/spf13/afero v1.12.0 // indirect 73 | github.com/spf13/cast v1.7.1 // indirect 74 | github.com/spf13/pflag v1.0.6 // indirect 75 | github.com/subosito/gotenv v1.6.0 // indirect 76 | github.com/tidwall/gjson v1.18.0 // indirect 77 | github.com/tidwall/match v1.1.1 // indirect 78 | github.com/tidwall/pretty v1.2.1 // indirect 79 | github.com/tidwall/sjson v1.2.5 // indirect 80 | github.com/xanzy/ssh-agent v0.3.3 // indirect 81 | go.mau.fi/util v0.8.6 // indirect 82 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 83 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 84 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 85 | go.opentelemetry.io/otel v1.35.0 // indirect 86 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 87 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 88 | go.uber.org/multierr v1.10.0 // indirect 89 | golang.org/x/crypto v0.37.0 // indirect 90 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 91 | golang.org/x/net v0.39.0 // indirect 92 | golang.org/x/sync v0.13.0 // indirect 93 | golang.org/x/sys v0.32.0 // indirect 94 | golang.org/x/text v0.24.0 // indirect 95 | golang.org/x/time v0.11.0 // indirect 96 | google.golang.org/api v0.228.0 // indirect 97 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect 98 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect 99 | gopkg.in/warnings.v0 v0.1.2 // indirect 100 | gopkg.in/yaml.v3 v3.0.1 // indirect 101 | ) 102 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | 9 | "github.com/go-logr/logr" 10 | "github.com/mitchellh/mapstructure" 11 | "github.com/mule-ai/mule/internal/settings" 12 | "github.com/mule-ai/mule/internal/state" 13 | "github.com/mule-ai/mule/pkg/remote" 14 | "github.com/mule-ai/mule/pkg/repository" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | const DefaultConfigFileName = "config" 19 | const DefaultConfigType = "yaml" 20 | const DefaultConfigDir = ".config/mule" 21 | const DefaultGeneratedConfigFileName = "config-default.yaml" 22 | 23 | type Config struct { 24 | Repositories map[string]*repository.Repository `yaml:"repositories" mapstructure:"repositories"` 25 | Settings settings.Settings `yaml:"settings" mapstructure:"settings"` 26 | } 27 | 28 | func GetHomeConfigPath() (string, error) { 29 | homeDir, err := os.UserHomeDir() 30 | if err != nil { 31 | return "", err 32 | } 33 | return filepath.Join(homeDir, DefaultConfigDir, DefaultConfigFileName+"."+DefaultConfigType), nil 34 | } 35 | 36 | func manageDefaultConfigFile(configDirPath string, l logr.Logger) error { 37 | defaultConfigFilePath := filepath.Join(configDirPath, DefaultGeneratedConfigFileName) 38 | defaultConfig := Config{ 39 | Repositories: make(map[string]*repository.Repository), 40 | Settings: settings.DefaultSettings, 41 | } 42 | v := viper.New() 43 | v.Set("repositories", defaultConfig.Repositories) 44 | v.Set("settings", defaultConfig.Settings) 45 | if err := os.MkdirAll(filepath.Dir(defaultConfigFilePath), 0755); err != nil { 46 | return fmt.Errorf("error creating directory for default config %s: %w", defaultConfigFilePath, err) 47 | } 48 | if err := v.WriteConfigAs(defaultConfigFilePath); err != nil { 49 | return fmt.Errorf("error writing default config file %s with viper: %w", defaultConfigFilePath, err) 50 | } 51 | l.Info("Ensured default configuration is written using viper", "path", defaultConfigFilePath) 52 | return nil 53 | } 54 | 55 | func LoadConfig(path string, l logr.Logger) (*state.AppState, error) { 56 | configDirPath := filepath.Dir(path) 57 | defaultGeneratedConfigPath := filepath.Join(configDirPath, DefaultGeneratedConfigFileName) 58 | 59 | if err := manageDefaultConfigFile(configDirPath, l); err != nil { 60 | return nil, fmt.Errorf("failed to manage default config file at %s: %w", configDirPath, err) 61 | } 62 | 63 | mainViper := viper.New() 64 | mainViper.SetConfigType(DefaultConfigType) 65 | 66 | // Layer 1: Load config-default.yaml 67 | mainViper.SetConfigFile(defaultGeneratedConfigPath) 68 | if err := mainViper.ReadInConfig(); err != nil { 69 | // This is critical because manageDefaultConfigFile should have created it. 70 | return nil, fmt.Errorf("critical: failed to read generated default config file %s: %w", defaultGeneratedConfigPath, err) 71 | } 72 | l.Info("Loaded default config", "path", defaultGeneratedConfigPath) 73 | 74 | // Layer 2: Main user config file (e.g., config.yaml from `path` argument) 75 | // MergeInConfig will not error if the file doesn't exist, which is desired. 76 | if _, err := os.Stat(path); err == nil { 77 | mainViper.SetConfigFile(path) 78 | if err := mainViper.MergeInConfig(); err != nil { 79 | l.Error(err, "Error merging main user config file, continuing with defaults/overrides.", "path", path) 80 | } else { 81 | l.Info("Merged main user config", "path", path) 82 | } 83 | } else if !os.IsNotExist(err) { 84 | // Log if there's an error other than file not existing 85 | l.Error(err, "Error checking main user config file, continuing with defaults/overrides.", "path", path) 86 | } 87 | 88 | // Layer 3: Override files 89 | overrideFiles, err := filepath.Glob(filepath.Join(configDirPath, "config-override*.yaml")) 90 | if err != nil { 91 | l.Error(err, "Error globbing for override files, proceeding without them.", "pattern", filepath.Join(configDirPath, "config-override*.yaml")) 92 | } else { 93 | sort.Strings(overrideFiles) // Ensure deterministic order for overrides 94 | for _, overrideFile := range overrideFiles { 95 | mainViper.SetConfigFile(overrideFile) 96 | if err := mainViper.MergeInConfig(); err != nil { 97 | l.Error(err, "Error merging override config file, skipping this override.", "path", overrideFile) 98 | } else { 99 | l.Info("Merged override config file", "path", overrideFile) 100 | } 101 | } 102 | } 103 | 104 | // Final Step: Unmarshal the fully merged map into the Config struct 105 | var finalConfig Config 106 | decodeHooks := mapstructure.ComposeDecodeHookFunc( 107 | mapstructure.StringToTimeDurationHookFunc(), 108 | mapstructure.StringToSliceHookFunc(","), 109 | ) 110 | if err := mainViper.Unmarshal(&finalConfig, viper.DecodeHook(decodeHooks)); err != nil { 111 | return nil, fmt.Errorf("error unmarshalling final merged config to Config struct: %w", err) 112 | } 113 | 114 | appState := state.NewState(l, finalConfig.Settings) 115 | for repoPathVal, repo := range finalConfig.Repositories { 116 | if repo == nil { 117 | l.Error(fmt.Errorf("nil repository pointer in final config for key %s", repoPathVal), "Skipping repository initialization") 118 | continue 119 | } 120 | 121 | // Default RemoteProvider if not specified in config 122 | if repo.RemoteProvider.Provider == "" { 123 | l.Info("Remote provider not specified, defaulting to local", "repository", repoPathVal, "repo.Path", repo.Path) 124 | repo.RemoteProvider.Provider = remote.ProviderTypeToString(remote.LOCAL) 125 | // When using a local provider, its path is the same as the repository's main path. 126 | // The repo.Path field should already be populated from the config or defaults. 127 | repo.RemoteProvider.Path = repo.Path 128 | } 129 | 130 | rProviderOpts, errRemote := remote.SettingsToOptions(repo.RemoteProvider) 131 | if errRemote != nil { 132 | l.Error(errRemote, "Error setting up remote provider", "path", repoPathVal) 133 | continue 134 | } 135 | rProvider := remote.New(rProviderOpts) 136 | r := repository.NewRepositoryWithRemote(repo.Path, rProvider) 137 | errRAG := appState.RAG.AddRepository(repo.Path) 138 | if errRAG != nil { 139 | l.Error(errRAG, "Error adding repository to RAG") 140 | } else { 141 | l.Info("Added repository to VectorDB", "path", repo.Path) 142 | } 143 | r.Logger = l.WithName("repository").WithValues("path", repo.Path) 144 | r.Schedule = repo.Schedule 145 | r.RemotePath = repo.RemotePath 146 | r.RemoteProvider = repo.RemoteProvider 147 | errStatus := r.UpdateStatus() 148 | if errStatus != nil { 149 | l.Error(errStatus, "Error getting repo status") 150 | } 151 | appState.Repositories[repoPathVal] = r 152 | defaultWorkflow := appState.Workflows["default"] 153 | errTask := appState.Scheduler.AddTask(repoPathVal, repo.Schedule, func() { 154 | errSync := r.Sync(appState.Agents, defaultWorkflow) 155 | if errSync != nil { 156 | l.Error(errSync, "Error syncing repo") 157 | } 158 | }) 159 | if errTask != nil { 160 | l.Error(errTask, "Error setting up schedule for repository", "repository", repoPathVal) 161 | } 162 | } 163 | 164 | return appState, nil 165 | } 166 | 167 | func SaveConfig(path string) error { 168 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 169 | return err 170 | } 171 | state.State.Mu.RLock() 172 | defer state.State.Mu.RUnlock() 173 | 174 | currentConfig := Config{ 175 | Repositories: make(map[string]*repository.Repository), 176 | Settings: state.State.Settings, 177 | } 178 | if state.State.Repositories == nil { 179 | state.State.Repositories = make(map[string]*repository.Repository) 180 | } 181 | for repoPath, repo := range state.State.Repositories { 182 | currentConfig.Repositories[repoPath] = repo 183 | } 184 | 185 | vconfig := viper.New() 186 | vconfig.Set("repositories", currentConfig.Repositories) 187 | vconfig.Set("settings", currentConfig.Settings) 188 | 189 | if err := vconfig.WriteConfigAs(path); err != nil { 190 | return err 191 | } 192 | 193 | return nil 194 | } 195 | -------------------------------------------------------------------------------- /internal/handlers/github.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "path/filepath" 8 | 9 | "github.com/mule-ai/mule/internal/state" 10 | "github.com/mule-ai/mule/pkg/repository" 11 | ) 12 | 13 | func HandleGitHubRepositories(w http.ResponseWriter, r *http.Request) { 14 | state.State.Mu.RLock() 15 | remote := state.State.Remote 16 | state.State.Mu.RUnlock() 17 | 18 | repos, err := remote.GitHub.FetchRepositories() 19 | if err != nil { 20 | http.Error(w, fmt.Sprintf("Error fetching repositories: %v", err), http.StatusInternalServerError) 21 | return 22 | } 23 | 24 | err = json.NewEncoder(w).Encode(repos) 25 | if err != nil { 26 | http.Error(w, err.Error(), http.StatusInternalServerError) 27 | return 28 | } 29 | } 30 | 31 | func HandleGitHubIssues(w http.ResponseWriter, r *http.Request) { 32 | path := r.URL.Query().Get("path") 33 | if path == "" { 34 | http.Error(w, "Path parameter is required", http.StatusBadRequest) 35 | return 36 | } 37 | absPath, err := filepath.Abs(path) 38 | if err != nil { 39 | http.Error(w, "Invalid path", http.StatusBadRequest) 40 | return 41 | } 42 | state.State.Mu.RLock() 43 | repo, exists := state.State.Repositories[absPath] 44 | state.State.Mu.RUnlock() 45 | 46 | if !exists { 47 | http.Error(w, "Repository not found", http.StatusNotFound) 48 | return 49 | } 50 | 51 | state.State.Mu.RLock() 52 | token := state.State.Settings.GitHubToken 53 | state.State.Mu.RUnlock() 54 | 55 | if token == "" { 56 | http.Error(w, "GitHub token not configured", http.StatusBadRequest) 57 | return 58 | } 59 | 60 | err = repo.UpdateIssues() 61 | if err != nil { 62 | http.Error(w, fmt.Sprintf("Error fetching issues: %v", err), http.StatusInternalServerError) 63 | return 64 | } 65 | 66 | issues := make([]repository.Issue, 0, len(repo.Issues)) 67 | for _, issue := range repo.Issues { 68 | issues = append(issues, *issue) 69 | } 70 | 71 | err = json.NewEncoder(w).Encode(issues) 72 | if err != nil { 73 | http.Error(w, err.Error(), http.StatusInternalServerError) 74 | return 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/handlers/handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "html/template" 6 | "net/http" 7 | 8 | "github.com/jbutlerdev/genai/tools" 9 | "github.com/mule-ai/mule/pkg/validation" 10 | ) 11 | 12 | var templates *template.Template 13 | 14 | func InitTemplates(t *template.Template) { 15 | templates = t 16 | } 17 | 18 | func HandleTools(w http.ResponseWriter, r *http.Request) { 19 | // These should match the tools defined in your codebase 20 | tools := tools.Tools() 21 | err := json.NewEncoder(w).Encode(tools) 22 | if err != nil { 23 | http.Error(w, err.Error(), http.StatusInternalServerError) 24 | return 25 | } 26 | } 27 | 28 | func HandleValidationFunctions(w http.ResponseWriter, r *http.Request) { 29 | // Get validation functions from the validation package 30 | validationFuncs := validation.Validations() 31 | 32 | err := json.NewEncoder(w).Encode(validationFuncs) 33 | if err != nil { 34 | http.Error(w, err.Error(), http.StatusInternalServerError) 35 | return 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/handlers/handlers_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "testing" 4 | 5 | func TestHandlers(t *testing.T) { 6 | // TODO: Add tests here 7 | } 8 | -------------------------------------------------------------------------------- /internal/handlers/logs.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "html" 8 | "net/http" 9 | "os" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/mule-ai/mule/pkg/log" 16 | ) 17 | 18 | type LogEntry struct { 19 | Level string `json:"level"` 20 | TimeStamp float64 `json:"ts"` 21 | Time time.Time 22 | Logger string `json:"logger"` 23 | Caller string `json:"caller"` 24 | Message string `json:"msg"` 25 | Content string `json:"content,omitempty"` 26 | Model string `json:"model,omitempty"` 27 | Error string `json:"error,omitempty"` 28 | ID string `json:"id,omitempty"` 29 | } 30 | 31 | type Conversation struct { 32 | ID string 33 | StartTime time.Time 34 | Messages []LogEntry 35 | MessageCount int 36 | Status string // Status based on last message level 37 | } 38 | 39 | type LogsData struct { 40 | Page string 41 | Conversations []Conversation 42 | } 43 | 44 | func HandleLogs(w http.ResponseWriter, r *http.Request) { 45 | search := r.URL.Query().Get("search") 46 | level := r.URL.Query().Get("level") 47 | limitStr := r.URL.Query().Get("limit") 48 | isAjax := r.Header.Get("X-Requested-With") == "XMLHttpRequest" 49 | 50 | // Parse limit parameter, default to 10 if not specified or invalid 51 | limit := 10 52 | if limitStr != "" { 53 | if parsedLimit, err := strconv.Atoi(limitStr); err == nil { 54 | limit = parsedLimit 55 | } 56 | } 57 | 58 | // Read and parse log file 59 | file, err := os.Open(log.LogFile) 60 | if err != nil { 61 | errString := fmt.Sprintf("Error reading log file: %v", err) 62 | if isAjax { 63 | http.Error(w, `{"error": "`+errString+`"}`, http.StatusInternalServerError) 64 | } else { 65 | http.Error(w, errString, http.StatusInternalServerError) 66 | } 67 | return 68 | } 69 | defer file.Close() 70 | 71 | // Map to store conversations by ID 72 | conversations := make(map[string]*Conversation) 73 | reader := bufio.NewReader(file) 74 | 75 | const maxLineLength = 1024 * 1024 // 1MB 76 | 77 | for { 78 | // ReadLine returns line, isPrefix, error 79 | var fullLine []byte 80 | for { 81 | line, isPrefix, err := reader.ReadLine() 82 | if err != nil { 83 | if err.Error() == "EOF" { 84 | break 85 | } 86 | errString := fmt.Sprintf("Error reading line: %v", err) 87 | if isAjax { 88 | http.Error(w, `{"error": "`+errString+`"}`, http.StatusInternalServerError) 89 | } else { 90 | http.Error(w, errString, http.StatusInternalServerError) 91 | } 92 | return 93 | } 94 | 95 | fullLine = append(fullLine, line...) 96 | if !isPrefix { 97 | break 98 | } 99 | } 100 | 101 | // Break the outer loop if we've reached EOF 102 | if len(fullLine) == 0 { 103 | break 104 | } 105 | 106 | var entry LogEntry 107 | if len(fullLine) > maxLineLength { 108 | // Try to parse the JSON we have to get the metadata 109 | if err := json.Unmarshal(fullLine, &entry); err != nil { 110 | continue // Skip if we can't parse the JSON 111 | } 112 | // Only truncate the content field if it exists and is too long 113 | if entry.Content != "" && len(entry.Content) > maxLineLength { 114 | entry.Content = fmt.Sprintf("[Content exceeds %d bytes and has been truncated]", maxLineLength) 115 | } 116 | } else { 117 | if err := json.Unmarshal(fullLine, &entry); err != nil { 118 | continue // Skip invalid JSON entries 119 | } 120 | } 121 | entry.Time = time.Unix(int64(entry.TimeStamp), 0) 122 | 123 | // HTML escape the content and message fields 124 | entry.Message = html.EscapeString(entry.Message) 125 | entry.Content = html.EscapeString(entry.Content) 126 | entry.Error = html.EscapeString(entry.Error) 127 | 128 | // Apply filters 129 | if level != "" && !strings.EqualFold(entry.Level, level) { 130 | continue 131 | } 132 | if search != "" && !strings.Contains(strings.ToLower(entry.Message), strings.ToLower(search)) { 133 | continue 134 | } 135 | 136 | // Group by conversation ID 137 | if entry.ID != "" { 138 | if conv, exists := conversations[entry.ID]; exists { 139 | conv.Messages = append(conv.Messages, entry) 140 | conv.MessageCount++ 141 | } else { 142 | conversations[entry.ID] = &Conversation{ 143 | ID: entry.ID, 144 | StartTime: entry.Time, 145 | Messages: []LogEntry{entry}, 146 | MessageCount: 1, 147 | } 148 | } 149 | } 150 | } 151 | 152 | // Convert map to slice and sort by start time 153 | var sortedConversations []Conversation 154 | for _, conv := range conversations { 155 | // Sort messages within conversation by timestamp 156 | sort.Slice(conv.Messages, func(i, j int) bool { 157 | return conv.Messages[i].Time.Before(conv.Messages[j].Time) 158 | }) 159 | 160 | // Set status based on last message level 161 | if len(conv.Messages) > 0 { 162 | conv.Status = conv.Messages[len(conv.Messages)-1].Level 163 | } 164 | 165 | sortedConversations = append(sortedConversations, *conv) 166 | } 167 | 168 | // Sort conversations by start time, most recent first 169 | sort.Slice(sortedConversations, func(i, j int) bool { 170 | return sortedConversations[i].StartTime.After(sortedConversations[j].StartTime) 171 | }) 172 | 173 | // Apply conversation limit if greater than 0 (0 means no limit) 174 | if limit > 0 && len(sortedConversations) > limit { 175 | sortedConversations = sortedConversations[:limit] 176 | } 177 | 178 | if isAjax { 179 | w.Header().Set("Content-Type", "application/json") 180 | err = json.NewEncoder(w).Encode(sortedConversations) 181 | if err != nil { 182 | http.Error(w, err.Error(), http.StatusInternalServerError) 183 | } 184 | return 185 | } 186 | 187 | data := LogsData{ 188 | Page: "logs", 189 | Conversations: sortedConversations, 190 | } 191 | 192 | err = templates.ExecuteTemplate(w, "layout.html", data) 193 | if err != nil { 194 | http.Error(w, err.Error(), http.StatusInternalServerError) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /internal/handlers/models.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/mule-ai/mule/internal/state" 9 | ) 10 | 11 | func HandleModels(w http.ResponseWriter, r *http.Request) { 12 | providerName := r.URL.Query().Get("provider") 13 | if providerName == "" { 14 | http.Error(w, "provider name parameter is required", http.StatusBadRequest) 15 | return 16 | } 17 | 18 | if state.State.GenAI == nil { 19 | http.Error(w, "AI providers not initialized", http.StatusInternalServerError) 20 | return 21 | } 22 | 23 | providerInstance, ok := state.State.GenAI[providerName] 24 | if !ok || providerInstance == nil { 25 | http.Error(w, fmt.Sprintf("Provider '%s' not found or not initialized", providerName), http.StatusInternalServerError) 26 | return 27 | } 28 | 29 | models := providerInstance.Models() 30 | 31 | err := json.NewEncoder(w).Encode(models) 32 | if err != nil { 33 | http.Error(w, err.Error(), http.StatusInternalServerError) 34 | return 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/handlers/repository.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/go-git/go-git/v5" 13 | 14 | "github.com/mule-ai/mule/internal/config" 15 | "github.com/mule-ai/mule/internal/state" 16 | "github.com/mule-ai/mule/pkg/remote" 17 | "github.com/mule-ai/mule/pkg/repository" 18 | ) 19 | 20 | type RepoAddRequest struct { 21 | RepoURL string `json:"repoUrl"` 22 | BasePath string `json:"path"` 23 | Schedule string `json:"schedule"` 24 | } 25 | 26 | func HandleListRepositories(w http.ResponseWriter, r *http.Request) { 27 | state.State.Mu.RLock() 28 | defer state.State.Mu.RUnlock() 29 | 30 | err := json.NewEncoder(w).Encode(state.State.Repositories) 31 | if err != nil { 32 | http.Error(w, err.Error(), http.StatusInternalServerError) 33 | return 34 | } 35 | } 36 | 37 | func HandleAddRepository(w http.ResponseWriter, r *http.Request) { 38 | var req RepoAddRequest 39 | log.Printf("Adding repository: %v", r.Body) 40 | 41 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 42 | http.Error(w, err.Error(), http.StatusBadRequest) 43 | return 44 | } 45 | 46 | repoName := strings.TrimPrefix(req.RepoURL, "https://github.com/") 47 | repoName = strings.TrimSuffix(repoName, ".git") 48 | repoPath := filepath.Join(req.BasePath, repoName) 49 | absPath, err := filepath.Abs(repoPath) 50 | if err != nil { 51 | http.Error(w, "Invalid path", http.StatusBadRequest) 52 | return 53 | } 54 | repo := repository.NewRepository(absPath) 55 | repo.Schedule = req.Schedule 56 | repo.RemotePath = repoName 57 | 58 | _, err = git.PlainOpen(repo.Path) 59 | if err != nil { 60 | http.Error(w, "Invalid git repository path", http.StatusBadRequest) 61 | return 62 | } 63 | 64 | log.Printf("Getting repo status for %s", repo.Path) 65 | 66 | updateRepo(repo) 67 | 68 | log.Printf("Adding scheduler task for %s", repo.Path) 69 | 70 | // Set up scheduler for the repository 71 | defaultWorkflow := state.State.Workflows["default"] 72 | err = state.State.Scheduler.AddTask(repo.Path, repo.Schedule, func() { 73 | err := repo.Sync(state.State.Agents, defaultWorkflow) 74 | if err != nil { 75 | log.Printf("Error syncing repo: %v", err) 76 | } 77 | state.State.Mu.Lock() 78 | state.State.Repositories[repo.Path] = repo 79 | state.State.Mu.Unlock() 80 | }) 81 | if err != nil { 82 | http.Error(w, fmt.Sprintf("Error setting up schedule: %v", err), http.StatusInternalServerError) 83 | return 84 | } 85 | 86 | log.Printf("Saving config") 87 | // Create config path 88 | configPath, err := config.GetHomeConfigPath() 89 | if err != nil { 90 | log.Printf("Error getting config path: %v", err) 91 | } 92 | err = config.SaveConfig(configPath) 93 | if err != nil { 94 | log.Printf("Error saving config: %v", err) 95 | http.Error(w, fmt.Sprintf("Error saving config: %v", err), http.StatusInternalServerError) 96 | return 97 | } 98 | 99 | w.WriteHeader(http.StatusCreated) 100 | log.Printf("Repository added successfully") 101 | } 102 | 103 | func HandleUpdateRepository(w http.ResponseWriter, r *http.Request) { 104 | var req struct { 105 | Path string `json:"path"` 106 | } 107 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 108 | http.Error(w, err.Error(), http.StatusBadRequest) 109 | return 110 | } 111 | 112 | repo, err := getRepository(req.Path) 113 | if err != nil { 114 | http.Error(w, err.Error(), http.StatusNotFound) 115 | return 116 | } 117 | 118 | // Perform fetch 119 | err = repo.Fetch() 120 | if err != nil && err != git.NoErrAlreadyUpToDate { 121 | log.Printf("Warning: fetch error: %v", err) 122 | } 123 | 124 | updateRepo(repo) 125 | 126 | err = json.NewEncoder(w).Encode(repo.State) 127 | if err != nil { 128 | http.Error(w, err.Error(), http.StatusInternalServerError) 129 | return 130 | } 131 | } 132 | 133 | func HandleCloneRepository(w http.ResponseWriter, r *http.Request) { 134 | var req struct { 135 | RepoURL string `json:"repoUrl"` 136 | BasePath string `json:"basePath"` 137 | } 138 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 139 | http.Error(w, err.Error(), http.StatusBadRequest) 140 | return 141 | } 142 | 143 | if req.RepoURL == "" || req.BasePath == "" { 144 | http.Error(w, "Repository URL and base path are required", http.StatusBadRequest) 145 | return 146 | } 147 | 148 | // Create the base path if it doesn't exist 149 | if err := os.MkdirAll(req.BasePath, 0755); err != nil { 150 | http.Error(w, fmt.Sprintf("Error creating directory: %v", err), http.StatusInternalServerError) 151 | return 152 | } 153 | 154 | // Clone the repository 155 | repoName := strings.TrimPrefix(req.RepoURL, "https://github.com/") 156 | repoName = strings.TrimSuffix(repoName, ".git") 157 | repoPath := filepath.Join(req.BasePath, repoName) 158 | repo := repository.NewRepository(repoPath) 159 | if err := repo.Upsert(req.RepoURL); err != nil { 160 | http.Error(w, fmt.Sprintf("Error cloning repository: %v", err), http.StatusInternalServerError) 161 | return 162 | } 163 | 164 | w.WriteHeader(http.StatusOK) 165 | } 166 | 167 | func HandleDeleteRepository(w http.ResponseWriter, r *http.Request) { 168 | // Get repository path from URL 169 | path := r.URL.Query().Get("path") 170 | if path == "" { 171 | http.Error(w, "Path parameter is required", http.StatusBadRequest) 172 | return 173 | } 174 | 175 | repo, err := getRepository(path) 176 | if err != nil { 177 | http.Error(w, err.Error(), http.StatusNotFound) 178 | return 179 | } 180 | 181 | state.State.Mu.Lock() 182 | delete(state.State.Repositories, repo.Path) 183 | state.State.Scheduler.RemoveTask(repo.Path) 184 | state.State.Mu.Unlock() 185 | 186 | // Create config path 187 | configPath, err := config.GetHomeConfigPath() 188 | if err != nil { 189 | log.Printf("Error getting config path: %v", err) 190 | } 191 | err = config.SaveConfig(configPath) 192 | if err != nil { 193 | http.Error(w, fmt.Sprintf("Error saving config: %v", err), http.StatusInternalServerError) 194 | return 195 | } 196 | 197 | log.Printf("repository deleted %s", repo.Path) 198 | w.WriteHeader(http.StatusOK) 199 | } 200 | 201 | func HandleSyncRepository(w http.ResponseWriter, r *http.Request) { 202 | // Get repository path from URL 203 | path := r.URL.Query().Get("path") 204 | if path == "" { 205 | http.Error(w, "Repository path is required", http.StatusBadRequest) 206 | return 207 | } 208 | 209 | repo, err := getRepository(path) 210 | if err != nil { 211 | http.Error(w, err.Error(), http.StatusNotFound) 212 | return 213 | } 214 | 215 | defaultWorkflow := state.State.Workflows["default"] 216 | err = repo.Sync(state.State.Agents, defaultWorkflow) 217 | if err != nil { 218 | http.Error(w, err.Error(), http.StatusInternalServerError) 219 | return 220 | } 221 | 222 | w.WriteHeader(http.StatusOK) 223 | } 224 | 225 | func HandleSwitchProvider(w http.ResponseWriter, r *http.Request) { 226 | var req struct { 227 | Path string `json:"path"` 228 | Provider string `json:"provider"` 229 | } 230 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 231 | http.Error(w, err.Error(), http.StatusBadRequest) 232 | return 233 | } 234 | 235 | repo, err := getRepository(req.Path) 236 | if err != nil { 237 | http.Error(w, err.Error(), http.StatusNotFound) 238 | return 239 | } 240 | 241 | // Update the provider 242 | repo.RemoteProvider.Provider = req.Provider 243 | repo.RemoteProvider.Path = req.Path 244 | repo.RemoteProvider.Token = state.State.Settings.GitHubToken 245 | 246 | // Create new remote provider 247 | options, err := remote.SettingsToOptions(repo.RemoteProvider) 248 | if err != nil { 249 | http.Error(w, fmt.Sprintf("Error converting settings: %v", err), http.StatusInternalServerError) 250 | return 251 | } 252 | repo.Remote = remote.New(options) 253 | 254 | // Update state 255 | state.State.Mu.Lock() 256 | state.State.Repositories[repo.Path] = repo 257 | state.State.Mu.Unlock() 258 | 259 | // Save config 260 | configPath, err := config.GetHomeConfigPath() 261 | if err != nil { 262 | log.Printf("Error getting config path: %v", err) 263 | http.Error(w, fmt.Sprintf("Error getting config path: %v", err), http.StatusInternalServerError) 264 | return 265 | } 266 | err = config.SaveConfig(configPath) 267 | if err != nil { 268 | log.Printf("Error saving config: %v", err) 269 | http.Error(w, fmt.Sprintf("Error saving config: %v", err), http.StatusInternalServerError) 270 | return 271 | } 272 | 273 | w.WriteHeader(http.StatusOK) 274 | } 275 | 276 | func getRepository(path string) (*repository.Repository, error) { 277 | absPath, err := filepath.Abs(path) 278 | if err != nil { 279 | log.Printf("error getting absolute path: %v", err) 280 | return nil, err 281 | } 282 | 283 | state.State.Mu.RLock() 284 | repo, exists := state.State.Repositories[absPath] 285 | state.State.Mu.RUnlock() 286 | 287 | if !exists { 288 | log.Printf("repository does not exist: %s", absPath) 289 | return nil, fmt.Errorf("repository does not exist") 290 | } 291 | return repo, nil 292 | } 293 | 294 | func updateRepo(repo *repository.Repository) { 295 | // Get updated status 296 | err := repo.UpdateStatus() 297 | if err != nil { 298 | log.Printf("Error getting repo status: %v", err) 299 | return 300 | } 301 | 302 | state.State.Mu.Lock() 303 | state.State.Repositories[repo.Path] = repo 304 | state.State.Mu.Unlock() 305 | } 306 | -------------------------------------------------------------------------------- /internal/handlers/settings.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/mule-ai/mule/internal/config" 9 | "github.com/mule-ai/mule/internal/settings" 10 | "github.com/mule-ai/mule/internal/state" 11 | "github.com/mule-ai/mule/pkg/agent" 12 | ) 13 | 14 | func HandleGetSettings(w http.ResponseWriter, r *http.Request) { 15 | state.State.Mu.RLock() 16 | defer state.State.Mu.RUnlock() 17 | 18 | err := json.NewEncoder(w).Encode(state.State.Settings) 19 | if err != nil { 20 | http.Error(w, err.Error(), http.StatusInternalServerError) 21 | return 22 | } 23 | } 24 | 25 | func HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { 26 | var settings settings.Settings 27 | if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { 28 | http.Error(w, err.Error(), http.StatusBadRequest) 29 | return 30 | } 31 | 32 | if err := handleSettingsChange(settings); err != nil { 33 | http.Error(w, err.Error(), http.StatusInternalServerError) 34 | return 35 | } 36 | 37 | w.WriteHeader(http.StatusOK) 38 | } 39 | 40 | func handleSettingsChange(newSettings settings.Settings) error { 41 | state.State.Mu.Lock() 42 | state.State.Settings = newSettings 43 | state.State.Mu.Unlock() 44 | 45 | // Update agents and workflows after settings are updated. 46 | if err := state.State.UpdateAgents(); err != nil { 47 | return err 48 | } 49 | if err := state.State.UpdateWorkflows(); err != nil { 50 | return err 51 | } 52 | 53 | configPath, err := config.GetHomeConfigPath() 54 | if err != nil { 55 | return fmt.Errorf("error getting config path: %w", err) 56 | } 57 | if err := config.SaveConfig(configPath); err != nil { 58 | return fmt.Errorf("error saving config: %w", err) 59 | } 60 | return nil 61 | } 62 | 63 | func HandleTemplateValues(w http.ResponseWriter, r *http.Request) { 64 | values := agent.GetPromptTemplateValues() 65 | w.Header().Set("Content-Type", "application/json") 66 | if err := json.NewEncoder(w).Encode(values); err != nil { 67 | http.Error(w, err.Error(), http.StatusInternalServerError) 68 | return 69 | } 70 | } 71 | 72 | // HandleWorkflowOutputFields returns the available output fields for workflow steps 73 | func HandleWorkflowOutputFields(w http.ResponseWriter, r *http.Request) { 74 | // These are the fields that can be used as outputs from one agent to another 75 | outputFields := []string{ 76 | "generatedText", // The raw generated text from an agent 77 | "extractedCode", // Code extracted from the generated text 78 | "summary", // A summary of the generated content 79 | "actionItems", // Action items extracted from the content 80 | "suggestedChanges", // Suggested code changes 81 | "reviewComments", // Code review comments 82 | "testCases", // Generated test cases 83 | "documentationText", // Generated documentation 84 | } 85 | 86 | w.Header().Set("Content-Type", "application/json") 87 | if err := json.NewEncoder(w).Encode(outputFields); err != nil { 88 | http.Error(w, err.Error(), http.StatusInternalServerError) 89 | return 90 | } 91 | } 92 | 93 | // HandleWorkflowInputMappings returns the available input mappings for workflow steps 94 | func HandleWorkflowInputMappings(w http.ResponseWriter, r *http.Request) { 95 | // These are the ways to map outputs from previous steps to inputs for the next step 96 | inputMappings := []string{ 97 | "useAsPrompt", // Use the output directly as the prompt 98 | "appendToPrompt", // Append the output to the existing prompt 99 | "useAsContext", // Use the output as context information 100 | "useAsInstructions", // Use the output as instructions for the agent 101 | "useAsCodeInput", // Use the output as code to be processed 102 | "useAsReviewTarget", // Use the output as the target for a review 103 | } 104 | 105 | w.Header().Set("Content-Type", "application/json") 106 | if err := json.NewEncoder(w).Encode(inputMappings); err != nil { 107 | http.Error(w, err.Error(), http.StatusInternalServerError) 108 | return 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/scheduler/scheduler.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/go-logr/logr" 7 | "github.com/robfig/cron/v3" 8 | ) 9 | 10 | type Task struct { 11 | ID cron.EntryID 12 | Schedule string 13 | Action func() 14 | } 15 | 16 | type Scheduler struct { 17 | cron *cron.Cron 18 | tasks map[string]*Task 19 | mu sync.RWMutex 20 | Logger logr.Logger 21 | } 22 | 23 | func NewScheduler(l logr.Logger) *Scheduler { 24 | return &Scheduler{ 25 | cron: cron.New(), 26 | tasks: make(map[string]*Task), 27 | Logger: l, 28 | } 29 | } 30 | 31 | func (s *Scheduler) Start() { 32 | s.cron.Start() 33 | } 34 | 35 | func (s *Scheduler) Stop() { 36 | s.cron.Stop() 37 | } 38 | 39 | func (s *Scheduler) AddTask(key string, schedule string, action func()) error { 40 | s.mu.Lock() 41 | defer s.mu.Unlock() 42 | 43 | // Remove existing task if it exists 44 | if existingTask, exists := s.tasks[key]; exists { 45 | s.cron.Remove(existingTask.ID) 46 | delete(s.tasks, key) 47 | } 48 | 49 | id, err := s.cron.AddFunc(schedule, func() { 50 | s.Logger.Info("Running scheduled task", "key", key) 51 | action() 52 | }) 53 | 54 | if err != nil { 55 | return err 56 | } 57 | 58 | s.tasks[key] = &Task{ 59 | ID: id, 60 | Schedule: schedule, 61 | Action: action, 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (s *Scheduler) RemoveTask(key string) { 68 | s.mu.Lock() 69 | defer s.mu.Unlock() 70 | 71 | if task, exists := s.tasks[key]; exists { 72 | s.cron.Remove(task.ID) 73 | delete(s.tasks, key) 74 | } 75 | } 76 | 77 | func (s *Scheduler) UpdateTask(key string, schedule string, action func()) error { 78 | return s.AddTask(key, schedule, action) 79 | } 80 | -------------------------------------------------------------------------------- /internal/scheduler/scheduler_test.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | 8 | "github.com/go-logr/logr" 9 | ) 10 | 11 | func TestAddTaskWithValidSchedule(t *testing.T) { 12 | logger := logr.Discard() 13 | scheduler := NewScheduler(logger) 14 | taskKey := "test-task" 15 | schedule := "* * * * *" // Every minute 16 | action := func() {} 17 | err := scheduler.AddTask(taskKey, schedule, action) 18 | if err != nil { 19 | t.Errorf("Expected no error when adding a task with a valid schedule but got: %v", err) 20 | } 21 | 22 | if _, exists := scheduler.tasks[taskKey]; !exists { 23 | t.Errorf("Task was not added to the tasks map") 24 | } 25 | } 26 | 27 | func TestAddTaskWithInvalidSchedule(t *testing.T) { 28 | logger := logr.Discard() 29 | scheduler := NewScheduler(logger) 30 | taskKey := "test-task" 31 | schedule := "invalid-cron-expression" // Invalid cron schedule 32 | action := func() {} 33 | err := scheduler.AddTask(taskKey, schedule, action) 34 | if err == nil { 35 | t.Errorf("Expected an error when adding a task with an invalid schedule but got none") 36 | } 37 | } 38 | 39 | func TestAddExistingTaskWithNewSchedule(t *testing.T) { 40 | logger := logr.Discard() 41 | scheduler := NewScheduler(logger) 42 | taskKey := "test-task" 43 | schedule1 := "* * * * *" // Every minute 44 | schedule2 := "0 0 * * *" // Midnight every day 45 | action := func() {} 46 | 47 | err := scheduler.AddTask(taskKey, schedule1, action) 48 | if err != nil { 49 | t.Errorf("Expected no error when adding a task with a valid schedule but got: %v", err) 50 | } 51 | 52 | err = scheduler.AddTask(taskKey, schedule2, action) 53 | if err != nil { 54 | t.Errorf("Expected no error when updating an existing task with a new schedule but got: %v", err) 55 | } 56 | 57 | task := scheduler.tasks[taskKey] 58 | if task.Schedule != schedule2 { 59 | t.Errorf("Task schedule was not updated correctly. Expected %v, got %v", schedule2, task.Schedule) 60 | } 61 | } 62 | 63 | func TestRemoveTask(t *testing.T) { 64 | logger := logr.Discard() 65 | scheduler := NewScheduler(logger) 66 | taskKey := "test-task" 67 | schedule := "* * * * *" // Every minute 68 | action := func() {} 69 | err := scheduler.AddTask(taskKey, schedule, action) 70 | if err != nil { 71 | t.Errorf("Expected no error when adding a task with a valid schedule but got: %v", err) 72 | } 73 | 74 | scheduler.RemoveTask(taskKey) 75 | 76 | if _, exists := scheduler.tasks[taskKey]; exists { 77 | t.Errorf("Task was not removed from the tasks map") 78 | } 79 | } 80 | 81 | func TestUpdateTask(t *testing.T) { 82 | logger := logr.Discard() 83 | scheduler := NewScheduler(logger) 84 | taskKey := "test-task" 85 | schedule1 := "* * * * *" // Every minute 86 | schedule2 := "0 0 * * *" // Midnight every day 87 | action := func() {} 88 | 89 | err := scheduler.AddTask(taskKey, schedule1, action) 90 | if err != nil { 91 | t.Errorf("Expected no error when adding a task with a valid schedule but got: %v", err) 92 | } 93 | 94 | err = scheduler.UpdateTask(taskKey, schedule2, action) 95 | if err != nil { 96 | t.Errorf("Expected no error when updating an existing task with a new schedule but got: %v", err) 97 | } 98 | 99 | task := scheduler.tasks[taskKey] 100 | if task.Schedule != schedule2 { 101 | t.Errorf("Task schedule was not updated correctly. Expected %v, got %v", schedule2, task.Schedule) 102 | } 103 | } 104 | 105 | func TestSchedulerConcurrency(t *testing.T) { 106 | logger := logr.Discard() 107 | scheduler := NewScheduler(logger) 108 | 109 | var wg sync.WaitGroup 110 | 111 | for i := 0; i < 10; i++ { 112 | wg.Add(1) 113 | go func(i int) { 114 | defer wg.Done() 115 | 116 | taskKey := "test-task-" + string(rune('a'+i)) 117 | schedule := "* * * * *" // Every minute 118 | action := func() {} 119 | 120 | err := scheduler.AddTask(taskKey, schedule, action) 121 | if err != nil { 122 | t.Errorf("Expected no error when adding a task with a valid schedule but got: %v", err) 123 | } 124 | 125 | scheduler.RemoveTask(taskKey) 126 | }(i) 127 | } 128 | 129 | wg.Wait() 130 | 131 | time.Sleep(1 * time.Second) // Ensure all tasks have been processed 132 | } 133 | -------------------------------------------------------------------------------- /internal/settings/defaultSettings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/mule-ai/mule/pkg/agent" 5 | "github.com/mule-ai/mule/pkg/integration" 6 | "github.com/mule-ai/mule/pkg/integration/grpc" 7 | "github.com/mule-ai/mule/pkg/integration/matrix" 8 | "github.com/mule-ai/mule/pkg/types" 9 | ) 10 | 11 | var DefaultSettings = Settings{ 12 | Environment: []EnvironmentVariable{}, 13 | GitHubToken: "", 14 | AIProviders: []AIProviderSettings{ 15 | { 16 | Provider: "ollama", 17 | APIKey: "", 18 | Server: "http://localhost:11434", 19 | }, 20 | }, 21 | Agents: []agent.AgentOptions{ 22 | { 23 | ID: 10, 24 | ProviderName: "ollama", 25 | Name: "code", 26 | Model: "qwen2.5-coder:32b", 27 | PromptTemplate: "Your software team has been assigned the following issue.\n\n{{ .IssueTitle }}:\n{{ .IssueBody }}\n\n\n{{ if .IsPRComment }}\n\nYou generated the following diffs when solving the issue above.\n\n{{ .Diff }}\n\nA user has provided you the following comment:\n\n{{ .PRComment }}\n\non the following lines:\n\n{{ .PRCommentDiffHunk }}\n\n{{ end }}\n\n\nYour software architect has provided the context above. Be sure to use that while implementing your solution.\n\n", 28 | SystemPrompt: "Act as an expert software developer.\nYou are diligent and tireless!\nYou NEVER leave comments describing code without implementing it!\nYou always COMPLETELY IMPLEMENT the needed code!\nAlways use best practices when coding.\nRespect and use existing conventions, libraries, etc that are already present in the code base.\n\nTake requests for changes to the supplied code.\nIf the request is ambiguous, ask questions.\n\n\nFor each file that needs to be changed, write out the changes similar to a unified diff like `diff -U0` would produce.\n\n1. Add an imports of sympy.\n2. Remove the is_prime() function.\n3. Replace the existing call to is_prime() with a call to sympy.isprime().\n\nHere are the diffs for those changes:\n\n```diff\n--- mathweb/flask/app.py\n+++ mathweb/flask/app.py\n@@ ... @@\n-class MathWeb:\n+import sympy\n+\n+class MathWeb:\n@@ ... @@\n-def is_prime(x):\n- if x \u003c 2:\n- return False\n- for i in range(2, int(math.sqrt(x)) + 1):\n- if x % i == 0:\n- return False\n- return True\n@@ ... @@\n-@app.route('/prime/\u003cint:n\u003e')\n-def nth_prime(n):\n- count = 0\n- num = 1\n- while count \u003c n:\n- num += 1\n- if is_prime(num):\n- count += 1\n- return str(num)\n+@app.route('/prime/\u003cint:n\u003e')\n+def nth_prime(n):\n- count = 0\n- num = 1\n- while count \u003c n:\n- num += 1\n- if sympy.isprime(num):\n- count += 1\n- return str(num)\n+ count = 0\n+ num = 1\n+ while count \u003c n:\n+ num += 1\n+ if sympy.isprime(num):\n+ count += 1\n+ return str(num)\n```", 29 | Tools: []string{ 30 | "revertFile", 31 | "tree", 32 | "readFile", 33 | }, 34 | UDiffSettings: agent.UDiffSettings{ 35 | Enabled: true, 36 | }, 37 | }, 38 | { 39 | ID: 11, 40 | ProviderName: "ollama", 41 | Name: "architect", 42 | Model: "qwq:32b-q8_0", 43 | PromptTemplate: "You have been assigned the following issue.\n\n{{ .IssueTitle }}:\n{{ .IssueBody }}\n\n{{ if .IsPRComment }}\n\nYou generated the following diffs when solving the issue above.\n\n{{ .Diff }}\n\nA user has provided you the following comment:\n\n{{ .PRComment }}\n\non the following lines:\n\n{{ .PRCommentDiffHunk }}\n\n{{ end }}\n\nHelp your team address the content above. Break it down into workable steps so that your software engineering team can complete it. Perform any software architecture work that will aid in a better solution. Make sure that your approach includes tested software.\n\nYou can use the tools provided to learn more about the codebase.", 44 | SystemPrompt: "Act as an expert architect engineer and provide direction to your editor engineer.\nStudy the change request and the current code.\nDescribe how to modify the code to complete the request.\nThe editor engineer will rely solely on your instructions, so make them unambiguous and complete.\nExplain all needed code changes clearly and completely, but concisely.\nJust show the changes needed.\n\nDO NOT show the entire updated function/file/etc!", 45 | Tools: []string{ 46 | "tree", 47 | "readFile", 48 | }, 49 | }, 50 | }, 51 | SystemAgent: SystemAgentSettings{ 52 | ProviderName: "ollama", 53 | Model: "gemma3:27b", 54 | CommitTemplate: "You were given the following issue to complete:\n\n{{ .IssueTitle }}\n{{ .IssueBody }}\n\nGenerate a concise commit message for the following changes\n\n{{ .Diff }}\n\nno placeholders, explanation, or other text should be provided. Limit the message to 72 characters", 55 | PRTitleTemplate: "You were given the following issue to complete:\n\n{{ .IssueTitle }}\n{{ .IssueBody }}\n\nGenerate a concise pull request title for the following changes\n\n{{ .Diff }}\n\nno placeholders, explanation, or other text should be provided. Limit the message to 72 characters", 56 | PRBodyTemplate: "You were given the following issue to complete:\n\n{{ .IssueTitle }}\n{{ .IssueBody }}\n\nGenerate a detailed pull request description for the following changes:\n\n{{ .Diff }}\n\nThe description should include:\n1. A summary of the changes\n2. The motivation for the changes\n3. Any potential impact or breaking changes\n4. Testing instructions if applicable\n\nFormat the response in markdown, but do not put it in a code block.\nDo not include any other text in the response.\nDo not include any placeholders in the response. It is expected to be a complete description.", 57 | SystemPrompt: "", 58 | }, 59 | Workflows: []agent.WorkflowSettings{ 60 | { 61 | ID: "workflow_code_generation", 62 | Name: "Code Generation", 63 | Description: "This is a simple code generation workflow", 64 | IsDefault: true, 65 | Outputs: []types.TriggerSettings{}, 66 | Steps: []agent.WorkflowStep{ 67 | { 68 | ID: "step_architect", 69 | AgentID: 11, 70 | AgentName: "architect", 71 | OutputField: "generatedText", 72 | }, 73 | { 74 | ID: "step_code_generation", 75 | AgentID: 10, 76 | AgentName: "code", 77 | OutputField: "generatedText", 78 | }, 79 | }, 80 | Triggers: []types.TriggerSettings{}, 81 | ValidationFunctions: []string{ 82 | "goFmt", 83 | "goModTidy", 84 | "golangciLint", 85 | "goTest", 86 | "getDeps", 87 | }, 88 | }, 89 | }, 90 | Integration: integration.Settings{ 91 | Matrix: map[string]*matrix.Config{ 92 | "default": { 93 | Enabled: false, 94 | }, 95 | }, 96 | GRPC: &grpc.Config{ 97 | Enabled: true, 98 | Host: "0.0.0.0", 99 | Port: 9090, 100 | }, 101 | }, 102 | } 103 | -------------------------------------------------------------------------------- /internal/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/mule-ai/mule/pkg/agent" 5 | "github.com/mule-ai/mule/pkg/integration" 6 | ) 7 | 8 | const ( 9 | CommitAgent = 0 10 | PRTitleAgent = 1 11 | PRBodyAgent = 2 12 | StartingAgent = 3 13 | ) 14 | 15 | type Settings struct { 16 | Environment []EnvironmentVariable `json:"environment"` 17 | GitHubToken string `json:"githubToken"` 18 | AIProviders []AIProviderSettings `json:"aiProviders"` 19 | Agents []agent.AgentOptions `json:"agents"` 20 | SystemAgent SystemAgentSettings `json:"systemAgent"` 21 | Workflows []agent.WorkflowSettings `json:"workflows"` 22 | Integration integration.Settings `json:"integration"` 23 | } 24 | 25 | type EnvironmentVariable struct { 26 | Name string `json:"name"` 27 | Value string `json:"value"` 28 | } 29 | 30 | type TriggerSettings struct { 31 | Integration string `json:"integration"` 32 | Event string `json:"event"` 33 | Data any `json:"data"` 34 | } 35 | 36 | type AIProviderSettings struct { 37 | Name string `json:"name"` 38 | Provider string `json:"provider"` 39 | APIKey string `json:"apiKey"` 40 | Server string `json:"server"` 41 | } 42 | 43 | type SystemAgentSettings struct { 44 | ProviderName string `json:"providerName"` 45 | Model string `json:"model"` 46 | CommitTemplate string `json:"commitTemplate"` 47 | PRTitleTemplate string `json:"prTitleTemplate"` 48 | PRBodyTemplate string `json:"prBodyTemplate"` 49 | SystemPrompt string `json:"systemPrompt"` 50 | } 51 | -------------------------------------------------------------------------------- /internal/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sync" 7 | 8 | "github.com/go-logr/logr" 9 | "github.com/jbutlerdev/genai" 10 | "github.com/mule-ai/mule/internal/scheduler" 11 | "github.com/mule-ai/mule/internal/settings" 12 | "github.com/mule-ai/mule/pkg/agent" 13 | "github.com/mule-ai/mule/pkg/integration" 14 | "github.com/mule-ai/mule/pkg/rag" 15 | "github.com/mule-ai/mule/pkg/remote" 16 | "github.com/mule-ai/mule/pkg/repository" 17 | "github.com/mule-ai/mule/pkg/types" 18 | ) 19 | 20 | var State *AppState 21 | 22 | type AppState struct { 23 | Repositories map[string]*repository.Repository `json:"repositories"` 24 | Settings settings.Settings `json:"settings"` 25 | Scheduler *scheduler.Scheduler 26 | Mu sync.RWMutex 27 | Logger logr.Logger 28 | GenAI map[string]*genai.Provider 29 | Remote *RemoteProviders 30 | Agents map[int]*agent.Agent 31 | RAG *rag.Store 32 | Workflows map[string]*agent.Workflow 33 | Integrations map[string]types.Integration 34 | } 35 | 36 | type RemoteProviders struct { 37 | GitHub remote.Provider 38 | Local remote.Provider 39 | } 40 | 41 | func NewState(logger logr.Logger, settings settings.Settings) *AppState { 42 | rag := rag.NewStore(logger.WithName("rag")) 43 | initializeEnvironmentVariables(settings.Environment) 44 | genaiProviders := initializeGenAIProviders(logger, settings) 45 | systemAgents := initializeSystemAgents(logger, settings, genaiProviders) 46 | agents := initializeAgents(logger, settings, genaiProviders, rag) 47 | agents = mergeAgents(agents, systemAgents) 48 | integrations := integration.LoadIntegrations(integration.IntegrationInput{ 49 | Settings: &settings.Integration, 50 | Logger: logger, 51 | }) 52 | workflows := initializeWorkflows(settings, agents, logger, integrations) 53 | integrations = integration.UpdateSystemPointers(integrations, integration.IntegrationInput{ 54 | Agents: agents, 55 | Workflows: workflows, 56 | Providers: genaiProviders, 57 | }) 58 | return &AppState{ 59 | Repositories: make(map[string]*repository.Repository), 60 | Settings: settings, 61 | Scheduler: scheduler.NewScheduler(logger.WithName("scheduler")), 62 | Logger: logger, 63 | GenAI: genaiProviders, 64 | Remote: &RemoteProviders{ 65 | GitHub: remote.New(remote.ProviderOptions{ 66 | Type: remote.GITHUB, 67 | GitHubToken: settings.GitHubToken, 68 | }), 69 | Local: remote.New(remote.ProviderOptions{ 70 | Type: remote.LOCAL, 71 | Path: "/", 72 | }), 73 | }, 74 | Agents: agents, 75 | RAG: rag, 76 | Workflows: workflows, 77 | Integrations: integrations, 78 | } 79 | } 80 | 81 | func initializeEnvironmentVariables(environmentVariables []settings.EnvironmentVariable) { 82 | for _, environmentVariable := range environmentVariables { 83 | os.Setenv(environmentVariable.Name, environmentVariable.Value) 84 | } 85 | } 86 | 87 | func initializeGenAIProviders(logger logr.Logger, settings settings.Settings) map[string]*genai.Provider { 88 | providers := make(map[string]*genai.Provider) 89 | for _, providerConfig := range settings.AIProviders { 90 | genaiProvider, err := genai.NewProviderWithLog(providerConfig.Provider, genai.ProviderOptions{ 91 | APIKey: providerConfig.APIKey, 92 | BaseURL: providerConfig.Server, 93 | Name: providerConfig.Name, 94 | Log: logger.WithName(providerConfig.Provider), 95 | }) 96 | if err != nil { 97 | logger.Error(err, "Error creating provider", "providerName", providerConfig.Name, "providerType", providerConfig.Provider) 98 | continue 99 | } 100 | if providerConfig.Name == "" { 101 | logger.Error(fmt.Errorf("provider name cannot be empty"), "Error initializing provider", "providerType", providerConfig.Provider) 102 | continue 103 | } 104 | providers[providerConfig.Name] = genaiProvider 105 | } 106 | return providers 107 | } 108 | 109 | func initializeAgents(logger logr.Logger, settingsInput settings.Settings, genaiProviders map[string]*genai.Provider, rag *rag.Store) map[int]*agent.Agent { 110 | agents := make(map[int]*agent.Agent) 111 | for _, agentOpts := range settingsInput.Agents { 112 | if provider, ok := genaiProviders[agentOpts.ProviderName]; ok { 113 | agentOpts.Provider = provider 114 | } else { 115 | logger.Error(fmt.Errorf("provider instance not found for name: %s", agentOpts.ProviderName), "provider not found") 116 | continue 117 | } 118 | agentOpts.Logger = logger.WithName("agent").WithValues("model", agentOpts.Model, "providerName", agentOpts.ProviderName) 119 | agentOpts.RAG = rag 120 | agents[agentOpts.ID] = agent.NewAgent(agentOpts) 121 | } 122 | return agents 123 | } 124 | 125 | func initializeSystemAgents(logger logr.Logger, settingsInput settings.Settings, genaiProviders map[string]*genai.Provider) map[int]*agent.Agent { 126 | agents := make(map[int]*agent.Agent) 127 | 128 | providerInstance, ok := genaiProviders[settingsInput.SystemAgent.ProviderName] 129 | if !ok { 130 | logger.Error(fmt.Errorf("system agent provider instance not found for name: %s", settingsInput.SystemAgent.ProviderName), "system agent provider not found") 131 | } 132 | 133 | systemAgentOptsBase := agent.AgentOptions{ 134 | ProviderName: settingsInput.SystemAgent.ProviderName, 135 | Provider: providerInstance, 136 | Model: settingsInput.SystemAgent.Model, 137 | SystemPrompt: settingsInput.SystemAgent.SystemPrompt, 138 | Logger: logger.WithName("system-agent").WithValues("providerName", settingsInput.SystemAgent.ProviderName), 139 | } 140 | 141 | commitAgentOpts := systemAgentOptsBase 142 | commitAgentOpts.PromptTemplate = settingsInput.SystemAgent.CommitTemplate 143 | agents[settings.CommitAgent] = agent.NewAgent(commitAgentOpts) 144 | 145 | prTitleAgentOpts := systemAgentOptsBase 146 | prTitleAgentOpts.PromptTemplate = settingsInput.SystemAgent.PRTitleTemplate 147 | agents[settings.PRTitleAgent] = agent.NewAgent(prTitleAgentOpts) 148 | 149 | prBodyAgentOpts := systemAgentOptsBase 150 | prBodyAgentOpts.PromptTemplate = settingsInput.SystemAgent.PRBodyTemplate 151 | agents[settings.PRBodyAgent] = agent.NewAgent(prBodyAgentOpts) 152 | 153 | return agents 154 | } 155 | 156 | func mergeAgents(agents map[int]*agent.Agent, systemAgents map[int]*agent.Agent) map[int]*agent.Agent { 157 | for id, agent := range systemAgents { 158 | agents[id] = agent 159 | } 160 | return agents 161 | } 162 | 163 | func initializeWorkflows(settingsInput settings.Settings, agents map[int]*agent.Agent, logger logr.Logger, integrations map[string]types.Integration) map[string]*agent.Workflow { 164 | workflows := make(map[string]*agent.Workflow) 165 | 166 | for _, workflow := range settingsInput.Workflows { 167 | workflows[workflow.Name] = agent.NewWorkflow(workflow, agents, integrations, logger.WithName("workflow").WithValues("name", workflow.Name)) 168 | if workflow.IsDefault { 169 | workflows["default"] = workflows[workflow.Name] 170 | } 171 | err := workflows[workflow.Name].RegisterTriggers(integrations) 172 | if err != nil { 173 | logger.Error(err, "Error registering triggers for workflow", "workflowName", workflow.Name) 174 | } 175 | } 176 | 177 | return workflows 178 | } 179 | 180 | // UpdateAgents re-initializes the agents based on the new settings. 181 | func (s *AppState) UpdateAgents() error { 182 | s.Mu.Lock() 183 | defer s.Mu.Unlock() 184 | 185 | // Re-initialize GenAI providers 186 | genaiProviders := initializeGenAIProviders(s.Logger, s.Settings) 187 | 188 | // Re-initialize system agents 189 | systemAgents := initializeSystemAgents(s.Logger, s.Settings, genaiProviders) 190 | 191 | // Re-initialize agents 192 | agents := initializeAgents(s.Logger, s.Settings, genaiProviders, s.RAG) 193 | 194 | // Merge agents 195 | agents = mergeAgents(agents, systemAgents) 196 | 197 | // Update the AppState's agents 198 | s.Agents = agents 199 | 200 | // Update any references to agents in workflows or other parts of the application. 201 | for workflowName, workflow := range s.Workflows { 202 | for i, step := range workflow.Steps { 203 | if agent, ok := s.Agents[step.AgentID]; ok { 204 | workflow.Steps[i].AgentName = agent.Name 205 | } else { 206 | s.Logger.Error(fmt.Errorf("agent not found"), "agent not found", "agentID", step.AgentID) 207 | } 208 | } 209 | s.Workflows[workflowName] = workflow 210 | } 211 | return nil 212 | } 213 | 214 | // UpdateWorkflows re-initializes the workflows based on the new settings. 215 | func (s *AppState) UpdateWorkflows() error { 216 | s.Mu.Lock() 217 | defer s.Mu.Unlock() 218 | 219 | workflows := initializeWorkflows(s.Settings, s.Agents, s.Logger, s.Integrations) 220 | s.Workflows = workflows 221 | 222 | // Update the scheduler with the new workflows. 223 | for repoPath, repo := range s.Repositories { 224 | s.Scheduler.RemoveTask(repoPath) 225 | 226 | defaultWorkflow := s.Workflows["default"] 227 | err := s.Scheduler.AddTask(repoPath, repo.Schedule, func() { 228 | err := repo.Sync(s.Agents, defaultWorkflow) 229 | if err != nil { 230 | s.Logger.Error(err, "Error syncing repo") 231 | } 232 | }) 233 | if err != nil { 234 | s.Logger.Error(err, "Error adding task to scheduler", "repoPath", repoPath) 235 | } 236 | } 237 | return nil 238 | } 239 | 240 | // ReloadSettings reloads settings, agents and workflows 241 | func (s *AppState) ReloadSettings(newSettings settings.Settings) error { 242 | s.Mu.Lock() 243 | defer s.Mu.Unlock() 244 | s.Settings = newSettings 245 | 246 | if err := s.UpdateAgents(); err != nil { 247 | return err 248 | } 249 | 250 | if err := s.UpdateWorkflows(); err != nil { 251 | return err 252 | } 253 | 254 | return nil 255 | } 256 | -------------------------------------------------------------------------------- /llms.txt: -------------------------------------------------------------------------------- 1 | # Mule - AI Development Team 2 | 3 | Mule is an AI agent that monitors Git repositories and completes issues assigned to it. Issues are assigned by giving them the `mule` label. After work is completed, the agent creates a pull request for review. 4 | 5 | ## Project Structure 6 | 7 | - `cmd/mule/` - Main application entry point with CLI and web server 8 | - `internal/` - Internal application packages 9 | - `config/` - Configuration management 10 | - `handlers/` - HTTP request handlers for web interface 11 | - `scheduler/` - Task scheduling functionality 12 | - `settings/` - Application settings management 13 | - `state/` - Global application state 14 | - `pkg/` - Public packages that can be imported 15 | - `agent/` - Core AI agent functionality and workflow execution 16 | - `auth/` - SSH authentication utilities 17 | - `integration/` - Platform integrations (Discord, Matrix, API, gRPC, etc.) 18 | - `grpc/` - gRPC server integration for external API access 19 | - `log/` - Logging utilities 20 | - `rag/` - Retrieval Augmented Generation for better context 21 | - `remote/` - Git remote providers (GitHub, local) 22 | - `repository/` - Repository management and operations 23 | - `validation/` - Input validation functions 24 | - `api/proto/` - Protocol buffer definitions and generated gRPC code 25 | 26 | ## Key Features 27 | 28 | - Multi-agent workflows with configurable steps 29 | - Integration with GitHub for issue and PR management 30 | - Local provider for testing without GitHub 31 | - RAG (Retrieval Augmented Generation) for better code understanding 32 | - Web interface for repository and settings management 33 | - Discord and Matrix bot integrations 34 | - **gRPC API server** for external system integration and automation 35 | - Configurable AI models and providers (supports OpenAI, Anthropic, local models) 36 | - Automated issue assignment and PR creation 37 | 38 | ## Technology Stack 39 | 40 | - **Language**: Go 1.24 41 | - **AI Integration**: Custom genai library for LLM providers 42 | - **Version Control**: go-git for Git operations 43 | - **Web Framework**: Standard library HTTP with embedded templates 44 | - **gRPC**: Protocol Buffers v3 with gRPC-Go for API services 45 | - **Database**: SQLite for chat history and memory 46 | - **Configuration**: Viper for YAML configuration management 47 | - **Logging**: Structured logging with logr and zap 48 | 49 | ## Running Modes 50 | 51 | 1. **Server Mode**: Web interface for managing repositories and settings 52 | 2. **CLI Mode**: Direct workflow execution with prompts 53 | 3. **Integration Mode**: Bot integrations for platforms like Discord/Matrix 54 | 55 | ## Configuration 56 | 57 | The application uses YAML configuration files stored in the user's home directory. Settings include AI model configurations, repository definitions, workflow specifications, and integration credentials. 58 | 59 | ## gRPC API Server Integration 60 | 61 | ### Overview 62 | 63 | Mule includes a built-in gRPC server that provides external API access to core functionality. This enables automation, monitoring, and integration with external systems through a robust, type-safe API. 64 | 65 | ### Configuration 66 | 67 | The gRPC integration is configured in the main settings file: 68 | 69 | ```yaml 70 | integration: 71 | grpc: 72 | enabled: true 73 | host: "0.0.0.0" 74 | port: 9090 75 | ``` 76 | 77 | Configuration options: 78 | - `enabled`: Whether to start the gRPC server (default: false) 79 | - `host`: Host to bind to (default: "localhost") 80 | - `port`: Port to listen on (default: 9090) 81 | 82 | ### API Endpoints 83 | 84 | The gRPC service provides the following endpoints: 85 | 86 | #### Core Operations 87 | - `GetHeartbeat()` - Health check endpoint returning status and version 88 | - `ListProviders()` - List all configured AI providers (OpenAI, Anthropic, etc.) 89 | 90 | #### Workflow Management 91 | - `ListWorkflows()` - Get all available workflows 92 | - `GetWorkflow(name)` - Get details about a specific workflow 93 | - `ExecuteWorkflow(workflow, prompt, path)` - Start workflow execution 94 | - `ListRunningWorkflows()` - Get currently executing workflows 95 | 96 | #### Agent Management 97 | - `ListAgents()` - Get all configured agents 98 | - `GetAgent(id)` - Get details about a specific agent 99 | 100 | ### Protocol Buffer Schema 101 | 102 | Located in `api/proto/mule.proto`, the schema defines: 103 | 104 | - **Service Definition**: `MuleService` with all RPC methods 105 | - **Message Types**: Request/response pairs for each endpoint 106 | - **Data Models**: Agent, Workflow, Provider, and execution status structures 107 | - **Nested Types**: Workflow steps, triggers, validation functions, etc. 108 | 109 | ### Code Generation 110 | 111 | Generated files (auto-generated, do not edit manually): 112 | - `api/proto/mule.pb.go` - Protocol buffer message definitions 113 | - `api/proto/mule_grpc.pb.go` - gRPC service client/server code 114 | 115 | Regenerate protobuf code when schema changes: 116 | ```bash 117 | protoc --go_out=. --go-grpc_out=. api/proto/mule.proto 118 | ``` 119 | 120 | ### Integration Architecture 121 | 122 | The gRPC integration follows Mule's plugin architecture: 123 | 124 | 1. **Registration**: Auto-registers during application startup via `init()` function 125 | 2. **Configuration**: Loaded through the standard integration settings system 126 | 3. **State Access**: Direct access to agents, workflows, and providers from global state 127 | 4. **Execution**: Async workflow execution with progress tracking 128 | 5. **Memory Management**: Automatic cleanup of completed executions 129 | 130 | ### Server Implementation 131 | 132 | Key components: 133 | 134 | - **Server Struct**: Main gRPC server with access to agents, workflows, providers 135 | - **Async Execution**: Background goroutines for workflow execution 136 | - **State Tracking**: In-memory tracking of running workflows with unique IDs 137 | - **Type Conversion**: Conversion between internal types and protobuf messages 138 | - **Error Handling**: Proper gRPC error codes and messages 139 | 140 | ### Use Cases 141 | 142 | - **External Automation**: Trigger workflows from CI/CD pipelines 143 | - **Monitoring Dashboard**: Real-time view of workflow executions 144 | - **Multi-System Integration**: Connect Mule with other development tools 145 | - **API Gateway**: Expose Mule functionality through REST-to-gRPC proxies 146 | - **Microservices**: Use Mule as a service in larger architectures 147 | 148 | ### Security Considerations 149 | 150 | - gRPC server runs on configurable host/port 151 | - No built-in authentication (implement via gRPC interceptors if needed) 152 | - Defaults to localhost binding for security 153 | - Consider network-level security for production deployments 154 | 155 | ### Testing 156 | 157 | Comprehensive test suite covers: 158 | - All gRPC endpoints and error conditions 159 | - Type conversion between internal and protobuf types 160 | - Integration with Mule's configuration system 161 | - Async workflow execution and tracking 162 | 163 | ### Example Usage 164 | 165 | ```go 166 | // Connect to gRPC server 167 | conn, err := grpc.Dial("localhost:9090", grpc.WithInsecure()) 168 | client := proto.NewMuleServiceClient(conn) 169 | 170 | // Execute a workflow 171 | resp, err := client.ExecuteWorkflow(ctx, &proto.ExecuteWorkflowRequest{ 172 | WorkflowName: "Code Generation", 173 | Prompt: "Add error handling to the user authentication module", 174 | Path: "/path/to/repo", 175 | }) 176 | 177 | // Monitor execution 178 | runningResp, err := client.ListRunningWorkflows(ctx, &proto.ListRunningWorkflowsRequest{}) 179 | ``` 180 | 181 | ## Development Notes 182 | 183 | - Uses embedded templates and static files for the web interface 184 | - Supports hot-reloading of configurations 185 | - Modular design allows easy addition of new integrations 186 | - Comprehensive test coverage for core functionality 187 | - Follows Go best practices with proper error handling and logging 188 | - gRPC integration uses registry pattern to avoid import cycles 189 | - Protocol buffer schema is versioned and backward-compatible -------------------------------------------------------------------------------- /pkg/agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "reflect" 8 | "slices" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-logr/logr" 13 | "github.com/jbutlerdev/genai" 14 | "github.com/jbutlerdev/genai/tools" 15 | "github.com/mule-ai/mule/pkg/rag" 16 | ) 17 | 18 | const ( 19 | RAG_N_RESULTS = 10 20 | ) 21 | 22 | type Agent struct { 23 | id int 24 | provider *genai.Provider 25 | providerName string 26 | model string 27 | promptTemplate string 28 | promptContext string 29 | systemPrompt string 30 | tools []*tools.Tool 31 | logger logr.Logger 32 | Name string 33 | path string 34 | rag *rag.Store 35 | udiffSettings UDiffSettings 36 | } 37 | 38 | type AgentOptions struct { 39 | ID int `json:"id"` 40 | Provider *genai.Provider `json:"-"` 41 | ProviderName string `json:"providerName"` 42 | Name string `json:"name"` 43 | Model string `json:"model"` 44 | PromptTemplate string `json:"promptTemplate"` 45 | SystemPrompt string `json:"systemPrompt"` 46 | Logger logr.Logger `json:"-"` 47 | Tools []string `json:"tools"` 48 | Path string `json:"-"` 49 | RAG *rag.Store `json:"-"` 50 | UDiffSettings UDiffSettings `json:"udiffSettings"` 51 | } 52 | 53 | type PromptInput struct { 54 | IssueTitle string `json:"issueTitle"` 55 | IssueBody string `json:"issueBody"` 56 | Commits string `json:"commits"` 57 | Diff string `json:"diff"` 58 | IsPRComment bool `json:"isPRComment"` 59 | PRComment string `json:"prComment"` 60 | PRCommentDiffHunk string `json:"prCommentDiffHunk"` 61 | Message string `json:"message"` 62 | } 63 | 64 | func NewAgent(opts AgentOptions) *Agent { 65 | agent := &Agent{ 66 | id: opts.ID, 67 | provider: opts.Provider, 68 | providerName: opts.ProviderName, 69 | model: opts.Model, 70 | promptTemplate: opts.PromptTemplate, 71 | systemPrompt: opts.SystemPrompt, 72 | logger: opts.Logger, 73 | Name: opts.Name, 74 | // I don't like this, but it's a hack to get the path to the repository 75 | path: opts.Path, 76 | rag: opts.RAG, 77 | udiffSettings: opts.UDiffSettings, 78 | } 79 | err := agent.SetTools(opts.Tools) 80 | if err != nil { 81 | opts.Logger.Error(err, "Error setting tools") 82 | } 83 | return agent 84 | } 85 | 86 | func (a *Agent) GetID() int { 87 | return a.id 88 | } 89 | 90 | func (a *Agent) SetModel(model string) error { 91 | models := a.provider.Models() 92 | if slices.Contains(models, model) { 93 | a.model = model 94 | return nil 95 | } 96 | return fmt.Errorf("model %s not found", model) 97 | } 98 | 99 | func (a *Agent) SetTools(toolList []string) error { 100 | for _, toolName := range toolList { 101 | tool, err := tools.GetTool(toolName) 102 | if err != nil { 103 | return fmt.Errorf("tool %s not found", toolName) 104 | } 105 | a.tools = append(a.tools, tool) 106 | } 107 | return nil 108 | } 109 | 110 | func (a *Agent) SetPromptTemplate(promptTemplate string) { 111 | a.promptTemplate = promptTemplate 112 | } 113 | 114 | func (a *Agent) SetPromptContext(promptContext string) { 115 | a.promptContext = promptContext 116 | } 117 | 118 | func (a *Agent) SetSystemPrompt(systemPrompt string) { 119 | a.systemPrompt = systemPrompt 120 | } 121 | 122 | func (a *Agent) SetUDiffSettings(settings UDiffSettings) { 123 | a.udiffSettings = settings 124 | } 125 | 126 | func (a *Agent) GetUDiffSettings() UDiffSettings { 127 | return a.udiffSettings 128 | } 129 | 130 | func (a *Agent) GetModel() string { 131 | return a.model 132 | } 133 | 134 | func (a *Agent) GetPromptTemplate() string { 135 | return a.promptTemplate 136 | } 137 | 138 | func (a *Agent) GetSystemPrompt() string { 139 | return a.systemPrompt 140 | } 141 | 142 | func (a *Agent) GetTools() []string { 143 | var toolNames []string 144 | for _, tool := range a.tools { 145 | toolNames = append(toolNames, tool.Name) 146 | } 147 | return toolNames 148 | } 149 | 150 | func (a *Agent) GetProviderName() string { 151 | return a.providerName 152 | } 153 | 154 | func (a *Agent) Run(input PromptInput) error { 155 | if a.provider == nil { 156 | return fmt.Errorf("provider not set") 157 | } 158 | chat := a.provider.Chat(genai.ModelOptions{ 159 | ModelName: a.model, 160 | SystemPrompt: a.systemPrompt, 161 | }, a.tools) 162 | 163 | defer func() { 164 | chat.Done <- true 165 | }() 166 | 167 | go func() { 168 | for response := range chat.Recv { 169 | a.logger.Info("Response", "response", response) 170 | } 171 | }() 172 | 173 | prompt, err := a.renderPromptTemplate(input) 174 | if err != nil { 175 | return err 176 | } 177 | prompt = a.promptContext + "\n\n" + prompt 178 | a.logger.Info("Starting RAG") 179 | prompt, err = a.AddRAGContext(prompt) 180 | if err != nil { 181 | return err 182 | } 183 | a.logger.Info("RAG Completed, sending first message") 184 | chat.Send <- prompt 185 | 186 | // block until generation is complete 187 | <-chat.GenerationComplete 188 | 189 | return nil 190 | } 191 | 192 | func (a *Agent) RunInPath(path string, input PromptInput) error { 193 | a.path = path 194 | for _, tool := range a.tools { 195 | tool.Options["basePath"] = path 196 | } 197 | return a.Run(input) 198 | } 199 | 200 | func (a *Agent) Generate(path string, input PromptInput) (string, error) { 201 | prompt, err := a.renderPromptTemplate(input) 202 | if err != nil { 203 | return "", err 204 | } 205 | if path != "" { 206 | a.path = path 207 | prompt, err = a.AddRAGContext(prompt) 208 | if err != nil { 209 | return "", err 210 | } 211 | } 212 | return a.provider.Generate(genai.ModelOptions{ 213 | ModelName: a.model, 214 | SystemPrompt: a.systemPrompt, 215 | }, prompt) 216 | } 217 | 218 | // ProcessUDiffs checks if a message contains udiffs and applies them if udiff setting is enabled 219 | func (a *Agent) ProcessUDiffs(message string, logger logr.Logger) error { 220 | if !a.udiffSettings.Enabled { 221 | return nil 222 | } 223 | 224 | // Parse udiffs from the message 225 | diffs, err := ParseUDiffs(message) 226 | if err != nil { 227 | logger.Error(err, "failed to parse udiffs") 228 | return fmt.Errorf("failed to parse udiffs: %w", err) 229 | } 230 | 231 | // If no diffs found, nothing to do 232 | if len(diffs) == 0 { 233 | logger.Info("No udiffs found, skipping") 234 | return nil 235 | } 236 | 237 | // Apply the udiffs 238 | return ApplyUDiffs(diffs, a.path, logger) 239 | } 240 | 241 | // GenerateWithTools has been moved to the workflow package, so we can simplify here 242 | func (a *Agent) GenerateWithTools(path string, input PromptInput) (string, error) { 243 | if a.provider == nil { 244 | return "", fmt.Errorf("provider not set") 245 | } 246 | a.path = path 247 | for _, tool := range a.tools { 248 | tool.Options["basePath"] = path 249 | } 250 | // message for return 251 | message := "" 252 | 253 | chat := a.provider.Chat(genai.ModelOptions{ 254 | ModelName: a.model, 255 | SystemPrompt: a.systemPrompt, 256 | }, a.tools) 257 | 258 | defer func() { 259 | chat.Done <- true 260 | }() 261 | 262 | go func() { 263 | for response := range chat.Recv { 264 | a.logger.Info("Response", "response", response) 265 | message = response 266 | } 267 | }() 268 | 269 | prompt, err := a.renderPromptTemplate(input) 270 | if err != nil { 271 | return "", err 272 | } 273 | prompt = a.promptContext + "\n\n" + prompt 274 | chat.Logger.Info("Starting RAG") 275 | prompt, err = a.AddRAGContext(prompt) 276 | if err != nil { 277 | return "", err 278 | } 279 | chat.Logger.Info("RAG Completed, sending first message") 280 | chat.Send <- prompt 281 | 282 | // block until generation is complete 283 | <-chat.GenerationComplete 284 | 285 | for i := 0; i < 30; i++ { 286 | if message != "" { 287 | break 288 | } 289 | chat.Logger.Info("Waiting for message") 290 | time.Sleep(1 * time.Second) 291 | } 292 | 293 | // Process udiffs in the response if enabled 294 | if a.udiffSettings.Enabled { 295 | err = a.ProcessUDiffs(message, chat.Logger) 296 | if err != nil { 297 | a.logger.Error(err, "Error processing udiffs", "message", message) 298 | // Don't return the error so that the message still gets returned 299 | } 300 | } 301 | 302 | return message, nil 303 | } 304 | 305 | func (a *Agent) renderPromptTemplate(input PromptInput) (string, error) { 306 | // use golang template to render prompt template 307 | tmpl, err := template.New("prompt").Parse(a.promptTemplate) 308 | if err != nil { 309 | return "", err 310 | } 311 | var rendered bytes.Buffer 312 | err = tmpl.Execute(&rendered, input) 313 | if err != nil { 314 | return "", err 315 | } 316 | return rendered.String(), nil 317 | } 318 | 319 | func (a *Agent) AddRAGContext(prompt string) (string, error) { 320 | if a.rag == nil { 321 | a.logger.Info("RAG not initialized, skipping") 322 | return prompt, nil 323 | } 324 | 325 | // Get key files first 326 | if a.path == "" { 327 | a.logger.Info("No path set, skipping RAG") 328 | return prompt, nil 329 | } 330 | keyFiles, err := a.rag.GetNResults(a.path, prompt, RAG_N_RESULTS) 331 | if err != nil { 332 | return "", err 333 | } 334 | 335 | // Generate repomap with the key files 336 | repomap, err := a.rag.GenerateRepoMap(a.path, keyFiles) 337 | if err != nil { 338 | a.logger.Error(err, "Error generating repomap") 339 | return prompt, nil 340 | } 341 | 342 | // Combine repomap and prompt 343 | context := fmt.Sprintf("\n%s\n\n\n%s", 344 | repomap, 345 | prompt) 346 | 347 | return context, nil 348 | } 349 | 350 | func GetPromptTemplateValues() string { 351 | templates := []string{} 352 | s := &PromptInput{} 353 | val := reflect.ValueOf(s).Elem() 354 | for i := 0; i < val.NumField(); i++ { 355 | templates = append(templates, fmt.Sprintf("{{ .%s }}", val.Type().Field(i).Name)) 356 | } 357 | return strings.Join(templates, ", ") 358 | } 359 | -------------------------------------------------------------------------------- /pkg/auth/ssh.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/go-git/go-git/v5/plumbing/transport/ssh" 9 | ) 10 | 11 | func GetSSHAuth() (*ssh.PublicKeys, error) { 12 | sshPath := os.Getenv("SSH_KEY_PATH") 13 | if sshPath == "" { 14 | // Default to standard SSH key location 15 | homeDir, err := os.UserHomeDir() 16 | if err != nil { 17 | return nil, err 18 | } 19 | sshPath = filepath.Join(homeDir, ".ssh", "id_rsa") 20 | } 21 | 22 | publicKeys, err := ssh.NewPublicKeysFromFile("git", sshPath, "") 23 | if err != nil { 24 | return nil, fmt.Errorf("error loading SSH key: %v", err) 25 | } 26 | return publicKeys, nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/integration/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/go-logr/logr" 9 | "github.com/mule-ai/mule/pkg/types" 10 | ) 11 | 12 | type Config struct { 13 | Port int `json:"port"` 14 | } 15 | 16 | type API struct { 17 | Config *Config 18 | logger logr.Logger 19 | channel chan any 20 | responseChan chan string 21 | triggers map[string]chan any 22 | } 23 | 24 | func New(config *Config, logger logr.Logger) *API { 25 | api := &API{ 26 | Config: config, 27 | logger: logger, 28 | channel: make(chan any), 29 | triggers: make(map[string]chan any), 30 | responseChan: make(chan string), 31 | } 32 | go api.start() 33 | go api.receiveOutputs() 34 | return api 35 | } 36 | 37 | func (a *API) start() { 38 | http.HandleFunc("/api/", a.handleAPI) 39 | a.logger.Info("Starting API", "port", a.Config.Port) 40 | err := http.ListenAndServe(fmt.Sprintf(":%d", a.Config.Port), nil) 41 | if err != nil { 42 | a.logger.Error(err, "Failed to start API") 43 | } 44 | } 45 | 46 | func (a *API) Call(name string, data any) (any, error) { 47 | return nil, nil 48 | } 49 | 50 | func (a *API) GetChannel() chan any { 51 | return a.channel 52 | } 53 | 54 | func (a *API) Name() string { 55 | return "api" 56 | } 57 | 58 | func (a *API) RegisterTrigger(trigger string, data any, channel chan any) { 59 | endpoint, ok := data.(string) 60 | if !ok { 61 | a.logger.Error(fmt.Errorf("data is not a string"), "Data is not a string") 62 | return 63 | } 64 | triggerName := trigger + "." + endpoint 65 | a.triggers[triggerName] = channel 66 | a.logger.Info("Registered trigger", "trigger", triggerName) 67 | } 68 | 69 | func (a *API) GetChatHistory(channelID string, limit int) (string, error) { 70 | return "", nil 71 | } 72 | 73 | func (a *API) ClearChatHistory(channelID string) error { 74 | return nil 75 | } 76 | 77 | // create an api that accepts any path, query params, and body 78 | // and returns a response 79 | func (a *API) handleAPI(w http.ResponseWriter, r *http.Request) { 80 | // get the path, query params, and body 81 | path := r.URL.Path 82 | queryParams := r.URL.Query() 83 | method := r.Method 84 | 85 | triggerName := method + "." + path 86 | trigger, ok := a.triggers[triggerName] 87 | if !ok { 88 | a.logger.Error(fmt.Errorf("api path received but no trigger registered"), "Trigger not found") 89 | http.Error(w, "unregistered endpoint", http.StatusNotFound) 90 | return 91 | } 92 | 93 | prompt := "" 94 | switch method { 95 | case "GET": 96 | prompt = queryParams.Get("data") 97 | case "POST": 98 | body, err := io.ReadAll(r.Body) 99 | if err != nil { 100 | a.logger.Error(err, "Failed to read body") 101 | http.Error(w, "failed to read body", http.StatusBadRequest) 102 | return 103 | } 104 | prompt = string(body) 105 | } 106 | trigger <- prompt 107 | 108 | response := <-a.responseChan 109 | _, err := w.Write([]byte(response + "\n")) 110 | if err != nil { 111 | a.logger.Error(err, "Failed to write response") 112 | } 113 | } 114 | 115 | func (a *API) receiveOutputs() { 116 | for trigger := range a.channel { 117 | triggerSettings, ok := trigger.(*types.TriggerSettings) 118 | if !ok { 119 | a.logger.Error(fmt.Errorf("trigger is not a Trigger"), "Trigger is not a Trigger") 120 | continue 121 | } 122 | if triggerSettings.Integration != "api" { 123 | a.logger.Error(fmt.Errorf("trigger integration is not api"), "Trigger integration is not api") 124 | continue 125 | } 126 | switch triggerSettings.Event { 127 | case "response": 128 | message, ok := triggerSettings.Data.(string) 129 | if !ok { 130 | a.logger.Error(fmt.Errorf("trigger data is not a string"), "Trigger data is not a string") 131 | continue 132 | } 133 | a.responseChan <- message 134 | default: 135 | a.logger.Error(fmt.Errorf("trigger event not supported: %s", triggerSettings.Event), "Unsupported trigger event") 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /pkg/integration/discord/discord_memory.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mule-ai/mule/pkg/integration/memory" 7 | ) 8 | 9 | // SetMemory assigns a memory manager to the Discord integration 10 | func (d *Discord) SetMemory(mem *memory.Memory) { 11 | d.memory = mem 12 | } 13 | 14 | // GetChatHistory retrieves formatted chat history for the specified channel 15 | func (d *Discord) GetChatHistory(channelID string, limit int) (string, error) { 16 | if d.memory == nil { 17 | return "", fmt.Errorf("memory manager not initialized") 18 | } 19 | 20 | // If no specific channel ID is provided, use the default channel ID 21 | if channelID == "" { 22 | channelID = d.config.ChannelID 23 | } 24 | 25 | return d.memory.GetFormattedHistory(d.Name(), channelID, limit) 26 | } 27 | 28 | // ClearChatHistory removes chat history for the specified channel 29 | func (d *Discord) ClearChatHistory(channelID string) error { 30 | if d.memory == nil { 31 | return fmt.Errorf("memory manager not initialized") 32 | } 33 | 34 | // If no specific channel ID is provided, use the default channel ID 35 | if channelID == "" { 36 | channelID = d.config.ChannelID 37 | } 38 | 39 | return d.memory.ClearMessages(d.Name(), channelID) 40 | } 41 | 42 | // LogBotMessage logs a message sent by the bot to the memory system 43 | func (d *Discord) LogBotMessage(channelID, message string) { 44 | if d.memory == nil { 45 | return 46 | } 47 | 48 | if channelID == "" { 49 | channelID = d.config.ChannelID 50 | } 51 | 52 | botID := "bot" // Discord bot ID 53 | botName := "Mule" 54 | 55 | // Check if we have a session to get the actual bot ID 56 | if d.session != nil && d.session.State != nil && d.session.State.User != nil { 57 | botID = d.session.State.User.ID 58 | botName = d.session.State.User.Username 59 | } 60 | 61 | if err := d.memory.SaveMessage(d.Name(), channelID, botID, botName, message, true); err != nil { 62 | d.l.Error(err, "Failed to log bot message") 63 | } 64 | } 65 | 66 | // addHistoryToMessage adds chat history context to a message 67 | func (d *Discord) addHistoryToMessage(message, channelID string) any { 68 | if d.memory == nil { 69 | return message 70 | } 71 | 72 | if channelID == "" { 73 | channelID = d.config.ChannelID 74 | } 75 | 76 | // Get chat history and format it with the current message 77 | history, err := d.memory.GetFormattedHistory(d.Name(), channelID, 10) 78 | if err != nil { 79 | d.l.Error(err, "Failed to get chat history") 80 | return message 81 | } 82 | 83 | // If no history, just return the message 84 | if history == "" { 85 | return message 86 | } 87 | 88 | // Format with history 89 | messageWithHistory := fmt.Sprintf("=== Previous Messages ===\n%s\n=== Current Message ===\n%s", 90 | history, message) 91 | 92 | return messageWithHistory 93 | } 94 | -------------------------------------------------------------------------------- /pkg/integration/grpc/grpc.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | "github.com/go-logr/logr" 8 | "github.com/jbutlerdev/genai" 9 | "github.com/mule-ai/mule/pkg/agent" 10 | "google.golang.org/grpc" 11 | ) 12 | 13 | // Config represents the gRPC integration configuration 14 | type Config struct { 15 | Enabled bool `json:"enabled"` 16 | Port int `json:"port"` 17 | Host string `json:"host"` 18 | } 19 | 20 | type GRPCInput struct { 21 | Config *Config 22 | Agents map[int]*agent.Agent 23 | Workflows map[string]*agent.Workflow 24 | Providers map[string]*genai.Provider 25 | Logger logr.Logger 26 | } 27 | 28 | // Integration implements the Integration interface for gRPC 29 | type GRPC struct { 30 | agents map[int]*agent.Agent 31 | workflows map[string]*agent.Workflow 32 | providers map[string]*genai.Provider 33 | config *Config 34 | logger logr.Logger 35 | server *grpc.Server 36 | muleServer *Server 37 | channel chan any 38 | } 39 | 40 | // New creates a new gRPC integration 41 | func New(input GRPCInput) *GRPC { 42 | config := input.Config 43 | if config == nil { 44 | config = &Config{ 45 | Enabled: false, 46 | Port: 9090, 47 | Host: "localhost", 48 | } 49 | } 50 | 51 | if input.Agents == nil { 52 | input.Agents = map[int]*agent.Agent{} 53 | } 54 | if input.Workflows == nil { 55 | input.Workflows = map[string]*agent.Workflow{} 56 | } 57 | if input.Providers == nil { 58 | input.Providers = map[string]*genai.Provider{} 59 | } 60 | 61 | muleServer := NewServer(input.Logger, input.Agents, input.Workflows, input.Providers) 62 | grpcServer := grpc.NewServer() 63 | muleServer.RegisterWithGRPCServer(grpcServer) 64 | 65 | grpc := &GRPC{ 66 | agents: input.Agents, 67 | config: config, 68 | workflows: input.Workflows, 69 | providers: input.Providers, 70 | logger: input.Logger, 71 | server: grpcServer, 72 | muleServer: muleServer, 73 | channel: make(chan any, 100), 74 | } 75 | 76 | err := grpc.startServer() 77 | if err != nil { 78 | input.Logger.Error(err, "Failed to start gRPC server") 79 | return nil 80 | } 81 | 82 | input.Logger.Info("GRPC integration initialized") 83 | return grpc 84 | } 85 | 86 | func (g *GRPC) SetSystemPointers(agents map[int]*agent.Agent, workflows map[string]*agent.Workflow, providers map[string]*genai.Provider) { 87 | g.muleServer.SetAgents(agents) 88 | g.muleServer.SetWorkflows(workflows) 89 | g.muleServer.SetProviders(providers) 90 | } 91 | 92 | // Call implements the Integration interface 93 | func (g *GRPC) Call(name string, data any) (any, error) { 94 | g.logger.Info("Call method invoked", "name", name, "data", data) 95 | 96 | switch name { 97 | case "status": 98 | return g.getStatus() 99 | default: 100 | return nil, fmt.Errorf("unknown method: %s", name) 101 | } 102 | } 103 | 104 | // GetChannel implements the Integration interface 105 | func (g *GRPC) GetChannel() chan any { 106 | return g.channel 107 | } 108 | 109 | // Name implements the Integration interface 110 | func (g *GRPC) Name() string { 111 | return "grpc" 112 | } 113 | 114 | // RegisterTrigger implements the Integration interface 115 | func (g *GRPC) RegisterTrigger(trigger string, data any, channel chan any) { 116 | g.logger.Info("RegisterTrigger called", "trigger", trigger, "data", data) 117 | // gRPC integration doesn't use triggers in the traditional sense 118 | // as it's a server-based integration, but we implement this for interface compliance 119 | } 120 | 121 | // GetChatHistory implements the Integration interface for chat memory 122 | func (g *GRPC) GetChatHistory(channelID string, limit int) (string, error) { 123 | // gRPC integration doesn't maintain chat history 124 | return "", nil 125 | } 126 | 127 | // ClearChatHistory implements the Integration interface for chat memory 128 | func (g *GRPC) ClearChatHistory(channelID string) error { 129 | // gRPC integration doesn't maintain chat history 130 | return nil 131 | } 132 | 133 | // startServer starts the gRPC server 134 | func (g *GRPC) startServer() error { 135 | if !g.config.Enabled { 136 | return nil 137 | } 138 | 139 | address := fmt.Sprintf("%s:%d", g.config.Host, g.config.Port) 140 | 141 | listener, err := net.Listen("tcp", address) 142 | if err != nil { 143 | g.logger.Error(err, "Failed to listen on address", "address", address) 144 | return fmt.Errorf("failed to listen on %s: %w", address, err) 145 | } 146 | 147 | g.logger.Info("Starting gRPC server", "address", address) 148 | 149 | go func() { 150 | if err := g.server.Serve(listener); err != nil { 151 | g.logger.Error(err, "gRPC server failed") 152 | g.channel <- fmt.Sprintf("gRPC server error: %v", err) 153 | } 154 | }() 155 | 156 | return nil 157 | } 158 | 159 | // getStatus returns the current status of the gRPC server 160 | func (g *GRPC) getStatus() (any, error) { 161 | status := map[string]interface{}{ 162 | "enabled": g.config.Enabled, 163 | "host": g.config.Host, 164 | "port": g.config.Port, 165 | } 166 | return status, nil 167 | } 168 | -------------------------------------------------------------------------------- /pkg/integration/grpc/grpc_test.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/mule-ai/mule/pkg/log" 10 | ) 11 | 12 | func TestNew(t *testing.T) { 13 | logger := log.NewStdoutLogger() 14 | 15 | // Test with nil config 16 | integration := New(GRPCInput{ 17 | Logger: logger, 18 | }) 19 | assert.NotNil(t, integration) 20 | assert.Equal(t, "grpc", integration.Name()) 21 | assert.NotNil(t, integration.GetChannel()) 22 | assert.False(t, integration.config.Enabled) 23 | assert.Equal(t, 9090, integration.config.Port) 24 | assert.Equal(t, "localhost", integration.config.Host) 25 | 26 | // Test with custom config 27 | customConfig := &Config{ 28 | Enabled: false, 29 | Port: 8080, 30 | Host: "0.0.0.0", 31 | } 32 | 33 | integration2 := New(GRPCInput{ 34 | Config: customConfig, 35 | Logger: logger, 36 | }) 37 | assert.NotNil(t, integration2) 38 | assert.False(t, integration2.config.Enabled) 39 | assert.Equal(t, 8080, integration2.config.Port) 40 | assert.Equal(t, "0.0.0.0", integration2.config.Host) 41 | } 42 | 43 | func TestIntegrationInterface(t *testing.T) { 44 | logger := log.NewStdoutLogger() 45 | config := &Config{ 46 | Enabled: false, 47 | Port: 9090, 48 | Host: "localhost", 49 | } 50 | 51 | integration := New(GRPCInput{ 52 | Config: config, 53 | Logger: logger, 54 | }) 55 | 56 | // Test Name method 57 | assert.Equal(t, "grpc", integration.Name()) 58 | 59 | // Test GetChannel method 60 | channel := integration.GetChannel() 61 | assert.NotNil(t, channel) 62 | 63 | // Test RegisterTrigger method (should not panic) 64 | integration.RegisterTrigger("test-trigger", "test-data", make(chan any)) 65 | 66 | // Test GetChatHistory method 67 | history, err := integration.GetChatHistory("test-channel", 10) 68 | assert.NoError(t, err) 69 | assert.Empty(t, history) 70 | 71 | // Test ClearChatHistory method 72 | err = integration.ClearChatHistory("test-channel") 73 | assert.NoError(t, err) 74 | } 75 | 76 | func TestCallMethods(t *testing.T) { 77 | logger := log.NewStdoutLogger() 78 | config := &Config{ 79 | Enabled: false, 80 | Port: 9090, 81 | Host: "localhost", 82 | } 83 | 84 | integration := New(GRPCInput{ 85 | Config: config, 86 | Logger: logger, 87 | }) 88 | 89 | // Test status call 90 | result, err := integration.Call("status", nil) 91 | require.NoError(t, err) 92 | 93 | status, ok := result.(map[string]interface{}) 94 | require.True(t, ok) 95 | assert.False(t, status["enabled"].(bool)) 96 | assert.Equal(t, "localhost", status["host"].(string)) 97 | assert.Equal(t, 9090, status["port"].(int)) 98 | 99 | // Test unknown method 100 | _, err = integration.Call("unknown", nil) 101 | require.Error(t, err) 102 | assert.Contains(t, err.Error(), "unknown method") 103 | } 104 | -------------------------------------------------------------------------------- /pkg/integration/grpc/server_test.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/jbutlerdev/genai" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | pb "github.com/mule-ai/mule/api/proto" 13 | "github.com/mule-ai/mule/pkg/agent" 14 | "github.com/mule-ai/mule/pkg/log" 15 | ) 16 | 17 | func setupTestServer() *Server { 18 | logger := log.NewStdoutLogger() 19 | return NewServer(logger, map[int]*agent.Agent{}, map[string]*agent.Workflow{}, map[string]*genai.Provider{}) 20 | } 21 | 22 | func TestGetHeartbeat(t *testing.T) { 23 | server := setupTestServer() 24 | 25 | req := &pb.HeartbeatRequest{} 26 | resp, err := server.GetHeartbeat(context.Background(), req) 27 | 28 | require.NoError(t, err) 29 | assert.Equal(t, "healthy", resp.Status) 30 | assert.Equal(t, "1.0.0", resp.Version) 31 | assert.NotNil(t, resp.Timestamp) 32 | 33 | // Check that timestamp is recent (within last 5 seconds) 34 | timeDiff := time.Since(resp.Timestamp.AsTime()) 35 | assert.True(t, timeDiff < 5*time.Second) 36 | } 37 | 38 | func TestListWorkflows(t *testing.T) { 39 | server := setupTestServer() 40 | 41 | req := &pb.ListWorkflowsRequest{} 42 | resp, err := server.ListWorkflows(context.Background(), req) 43 | 44 | require.NoError(t, err) 45 | assert.Empty(t, resp.Workflows) // Empty because setupTestServer provides no workflows 46 | } 47 | 48 | func TestGetWorkflow(t *testing.T) { 49 | server := setupTestServer() 50 | 51 | req := &pb.GetWorkflowRequest{Name: "test-workflow"} 52 | _, err := server.GetWorkflow(context.Background(), req) 53 | 54 | require.Error(t, err) // Should error because workflow doesn't exist 55 | assert.Contains(t, err.Error(), "workflow not found") 56 | } 57 | 58 | func TestGetWorkflowNotFound(t *testing.T) { 59 | server := setupTestServer() 60 | 61 | req := &pb.GetWorkflowRequest{Name: "non-existent-workflow"} 62 | _, err := server.GetWorkflow(context.Background(), req) 63 | 64 | require.Error(t, err) 65 | assert.Contains(t, err.Error(), "workflow not found") 66 | } 67 | 68 | func TestListAgents(t *testing.T) { 69 | server := setupTestServer() 70 | 71 | req := &pb.ListAgentsRequest{} 72 | resp, err := server.ListAgents(context.Background(), req) 73 | 74 | require.NoError(t, err) 75 | assert.Empty(t, resp.Agents) // Empty because setupTestServer provides no agents 76 | } 77 | 78 | func TestGetAgent(t *testing.T) { 79 | server := setupTestServer() 80 | 81 | req := &pb.GetAgentRequest{Id: 10} 82 | _, err := server.GetAgent(context.Background(), req) 83 | 84 | require.Error(t, err) // Should error because agent doesn't exist 85 | assert.Contains(t, err.Error(), "agent not found") 86 | } 87 | 88 | func TestGetAgentNotFound(t *testing.T) { 89 | server := setupTestServer() 90 | 91 | req := &pb.GetAgentRequest{Id: 999} 92 | _, err := server.GetAgent(context.Background(), req) 93 | 94 | require.Error(t, err) 95 | assert.Contains(t, err.Error(), "agent not found") 96 | } 97 | 98 | func TestListRunningWorkflows(t *testing.T) { 99 | server := setupTestServer() 100 | 101 | // Initially should be empty 102 | req := &pb.ListRunningWorkflowsRequest{} 103 | resp, err := server.ListRunningWorkflows(context.Background(), req) 104 | 105 | require.NoError(t, err) 106 | assert.Empty(t, resp.RunningWorkflows) 107 | } 108 | 109 | func TestExecuteWorkflow(t *testing.T) { 110 | server := setupTestServer() 111 | 112 | req := &pb.ExecuteWorkflowRequest{ 113 | WorkflowName: "test-workflow", 114 | Prompt: "Test prompt", 115 | Path: "/test/path", 116 | } 117 | _, err := server.ExecuteWorkflow(context.Background(), req) 118 | 119 | require.Error(t, err) // Should error because workflow doesn't exist 120 | assert.Contains(t, err.Error(), "workflow not found") 121 | } 122 | 123 | func TestExecuteWorkflowNotFound(t *testing.T) { 124 | server := setupTestServer() 125 | 126 | req := &pb.ExecuteWorkflowRequest{ 127 | WorkflowName: "non-existent-workflow", 128 | Prompt: "Test prompt", 129 | Path: "/test/path", 130 | } 131 | _, err := server.ExecuteWorkflow(context.Background(), req) 132 | 133 | require.Error(t, err) 134 | assert.Contains(t, err.Error(), "workflow not found") 135 | } 136 | 137 | func TestConvertWorkflowToPB(t *testing.T) { 138 | server := setupTestServer() 139 | 140 | // Verify that no workflows exist in test setup 141 | assert.Empty(t, server.workflows) 142 | 143 | // Test conversion with empty workflow would require creating a mock workflow 144 | // For now, just verify the server has no workflows 145 | } 146 | 147 | func TestConvertAgentToPB(t *testing.T) { 148 | server := setupTestServer() 149 | 150 | // Create a test agent directly instead of relying on the state 151 | testAgent := agent.NewAgent(agent.AgentOptions{ 152 | ID: 1, 153 | Name: "test-agent", 154 | ProviderName: "test-provider", 155 | Model: "test-model", 156 | PromptTemplate: "Test prompt template", 157 | SystemPrompt: "Test system prompt", 158 | Tools: []string{}, 159 | UDiffSettings: agent.UDiffSettings{ 160 | Enabled: true, 161 | }, 162 | }) 163 | 164 | pbAgent := server.convertAgentToPB(testAgent) 165 | 166 | assert.Equal(t, int32(1), pbAgent.Id) 167 | assert.Equal(t, "test-agent", pbAgent.Name) 168 | assert.Equal(t, "test-provider", pbAgent.ProviderName) 169 | assert.Equal(t, "test-model", pbAgent.Model) 170 | assert.Equal(t, "Test prompt template", pbAgent.PromptTemplate) 171 | assert.Equal(t, "Test system prompt", pbAgent.SystemPrompt) 172 | assert.Empty(t, pbAgent.Tools) 173 | assert.True(t, pbAgent.UdiffSettings.Enabled) 174 | } 175 | -------------------------------------------------------------------------------- /pkg/integration/integration.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "github.com/go-logr/logr" 5 | "github.com/jbutlerdev/genai" 6 | "github.com/mule-ai/mule/pkg/agent" 7 | "github.com/mule-ai/mule/pkg/integration/api" 8 | "github.com/mule-ai/mule/pkg/integration/discord" 9 | "github.com/mule-ai/mule/pkg/integration/grpc" 10 | "github.com/mule-ai/mule/pkg/integration/matrix" 11 | "github.com/mule-ai/mule/pkg/integration/memory" 12 | "github.com/mule-ai/mule/pkg/integration/system" 13 | "github.com/mule-ai/mule/pkg/integration/tasks" 14 | "github.com/mule-ai/mule/pkg/types" 15 | ) 16 | 17 | type Settings struct { 18 | Matrix map[string]*matrix.Config `json:"matrix,omitempty"` 19 | Tasks *tasks.Config `json:"tasks,omitempty"` 20 | Discord *discord.Config `json:"discord,omitempty"` 21 | Memory *memory.Config `json:"memory,omitempty"` 22 | API *api.Config `json:"api,omitempty"` 23 | System *system.Config `json:"system,omitempty"` 24 | GRPC *grpc.Config `json:"grpc,omitempty"` // Generic config to avoid import cycles 25 | } 26 | 27 | type IntegrationInput struct { 28 | Settings *Settings 29 | Providers map[string]*genai.Provider 30 | Agents map[int]*agent.Agent 31 | Workflows map[string]*agent.Workflow 32 | Logger logr.Logger 33 | } 34 | 35 | func LoadIntegrations(input IntegrationInput) map[string]types.Integration { 36 | integrations := map[string]types.Integration{} 37 | settings := input.Settings 38 | l := input.Logger 39 | providers := input.Providers 40 | 41 | // Initialize memory store if enabled 42 | var memoryManager *memory.Memory 43 | if settings.Memory != nil && settings.Memory.Enabled { 44 | store := memory.NewInMemoryStore(settings.Memory.MaxMessages) 45 | memoryManager = memory.New(settings.Memory, store) 46 | l.Info("Chat memory initialized", "max_messages", settings.Memory.MaxMessages) 47 | } else { 48 | // Create a default memory manager with minimal settings if not explicitly configured 49 | defaultConfig := memory.DefaultConfig() 50 | store := memory.NewInMemoryStore(defaultConfig.MaxMessages) 51 | memoryManager = memory.New(defaultConfig, store) 52 | l.Info("Default chat memory initialized", "max_messages", defaultConfig.MaxMessages) 53 | } 54 | 55 | if settings.Matrix != nil { 56 | for name, matrixConfig := range settings.Matrix { 57 | matrixLogger := l.WithName(name + "-matrix-integration") 58 | matrixInteg := matrix.New(name, matrixConfig, matrixLogger) 59 | 60 | // Wrap with memory support if matrix integration was created 61 | if matrixInteg != nil { 62 | matrixInteg.SetMemory(memoryManager) 63 | memoryManager.RegisterIntegration(name, name) 64 | } 65 | 66 | integrations[name] = matrixInteg 67 | } 68 | } 69 | 70 | if settings.Discord != nil { 71 | discordLogger := l.WithName("discord-integration") 72 | discordInteg := discord.New(settings.Discord, discordLogger) 73 | 74 | // Wrap with memory support if discord integration was created 75 | if discordInteg != nil { 76 | discordInteg.SetMemory(memoryManager) 77 | memoryManager.RegisterIntegration("discord", "discord") 78 | } 79 | 80 | integrations["discord"] = discordInteg 81 | } 82 | 83 | if settings.Tasks != nil { 84 | integrations["tasks"] = tasks.New(settings.Tasks, l.WithName("tasks-integration")) 85 | } 86 | 87 | if settings.API != nil { 88 | integrations["api"] = api.New(settings.API, l.WithName("api-integration")) 89 | } 90 | 91 | if settings.GRPC != nil { 92 | integrations["grpc"] = grpc.New( 93 | grpc.GRPCInput{ 94 | Config: settings.GRPC, 95 | Logger: l.WithName("grpc-integration"), 96 | Agents: input.Agents, 97 | Workflows: input.Workflows, 98 | Providers: providers, 99 | }, 100 | ) 101 | } 102 | 103 | // always start the system integration 104 | integrations["system"] = system.New(settings.System, providers, l.WithName("system-integration")) 105 | 106 | return integrations 107 | } 108 | 109 | func UpdateSystemPointers(integrations map[string]types.Integration, input IntegrationInput) map[string]types.Integration { 110 | newIntegrations := map[string]types.Integration{} 111 | if input.Workflows == nil { 112 | input.Workflows = map[string]*agent.Workflow{} 113 | } 114 | if input.Agents == nil { 115 | input.Agents = map[int]*agent.Agent{} 116 | } 117 | if input.Providers == nil { 118 | input.Providers = map[string]*genai.Provider{} 119 | } 120 | for name, integration := range integrations { 121 | switch name { 122 | case "grpc": 123 | i, ok := integration.(*grpc.GRPC) 124 | if !ok || i == nil { 125 | continue 126 | } 127 | i.SetSystemPointers(input.Agents, input.Workflows, input.Providers) 128 | newIntegrations[integration.Name()] = i 129 | default: 130 | newIntegrations[integration.Name()] = integration 131 | } 132 | } 133 | return newIntegrations 134 | } 135 | -------------------------------------------------------------------------------- /pkg/integration/matrix/formatter.go: -------------------------------------------------------------------------------- 1 | package matrix 2 | 3 | import ( 4 | "github.com/gomarkdown/markdown" 5 | "github.com/gomarkdown/markdown/html" 6 | "github.com/gomarkdown/markdown/parser" 7 | ) 8 | 9 | func FormatMessage(message string) string { 10 | // create markdown parser with extensions 11 | extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock 12 | p := parser.NewWithExtensions(extensions) 13 | doc := p.Parse([]byte(message)) 14 | 15 | // create HTML renderer with extensions 16 | htmlFlags := html.CommonFlags | html.HrefTargetBlank 17 | opts := html.RendererOptions{Flags: htmlFlags} 18 | renderer := html.NewRenderer(opts) 19 | 20 | return string(markdown.Render(doc, renderer)) 21 | } 22 | -------------------------------------------------------------------------------- /pkg/integration/matrix/matrix_memory.go: -------------------------------------------------------------------------------- 1 | package matrix 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mule-ai/mule/pkg/integration/memory" 7 | ) 8 | 9 | // SetMemory assigns a memory manager to the Matrix integration 10 | func (m *Matrix) SetMemory(mem *memory.Memory) { 11 | m.memory = mem 12 | } 13 | 14 | // GetChatHistory retrieves formatted chat history for the default room 15 | func (m *Matrix) GetChatHistory(channelID string, limit int) (string, error) { 16 | if m.memory == nil { 17 | return "", fmt.Errorf("memory manager not initialized") 18 | } 19 | 20 | // If no specific channel ID is provided, use the default room ID 21 | if channelID == "" { 22 | channelID = m.config.RoomID 23 | } 24 | 25 | return m.memory.GetFormattedHistory(m.Name(), channelID, limit) 26 | } 27 | 28 | // ClearChatHistory removes chat history for the specified channel 29 | func (m *Matrix) ClearChatHistory(channelID string) error { 30 | if m.memory == nil { 31 | return fmt.Errorf("memory manager not initialized") 32 | } 33 | 34 | // If no specific channel ID is provided, use the default room ID 35 | if channelID == "" { 36 | channelID = m.config.RoomID 37 | } 38 | 39 | return m.memory.ClearMessages(m.Name(), channelID) 40 | } 41 | 42 | // LogBotMessage logs a message sent by the bot to the memory system 43 | func (m *Matrix) LogBotMessage(message string) { 44 | if m.memory == nil { 45 | return 46 | } 47 | 48 | botID := m.config.UserID 49 | botName := "Mule" 50 | 51 | if err := m.memory.SaveMessage(m.Name(), m.config.RoomID, botID, botName, message, true); err != nil { 52 | m.l.Error(err, "Failed to log bot message") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/integration/memory/formatter.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // FormatMessagesForLLM formats a list of messages for LLM context 9 | func FormatMessagesForLLM(messages []Message) string { 10 | if len(messages) == 0 { 11 | return "" 12 | } 13 | 14 | var builder strings.Builder 15 | 16 | for i, msg := range messages { 17 | // Add a prefix based on whether it's a bot message 18 | prefix := "User" 19 | if msg.IsBot { 20 | prefix = "Assistant" 21 | } 22 | 23 | // Include username if available 24 | if msg.Username != "" && !msg.IsBot { 25 | prefix = fmt.Sprintf("%s (%s)", prefix, msg.Username) 26 | } 27 | 28 | // Format timestamp 29 | timestamp := msg.Timestamp.Format("2006-01-02 15:04:05") 30 | 31 | // Build the message line 32 | builder.WriteString(fmt.Sprintf("[%s] %s: %s\n", timestamp, prefix, msg.Content)) 33 | 34 | // Add a separator line except for the last message 35 | if i < len(messages)-1 { 36 | builder.WriteString("---\n") 37 | } 38 | } 39 | 40 | return builder.String() 41 | } 42 | 43 | // FormatMessagesForMarkdown formats a list of messages for markdown display 44 | func FormatMessagesForMarkdown(messages []Message) string { 45 | if len(messages) == 0 { 46 | return "*No previous messages*" 47 | } 48 | 49 | var builder strings.Builder 50 | builder.WriteString("### Previous Messages\n\n") 51 | 52 | for _, msg := range messages { 53 | // Add a prefix based on whether it's a bot message 54 | prefix := "**User" 55 | if msg.IsBot { 56 | prefix = "**Assistant" 57 | } 58 | 59 | // Include username if available 60 | if msg.Username != "" && !msg.IsBot { 61 | prefix = fmt.Sprintf("%s (%s)", prefix, msg.Username) 62 | } 63 | 64 | // Format timestamp 65 | timestamp := msg.Timestamp.Format("2006-01-02 15:04:05") 66 | 67 | // Build the message line 68 | builder.WriteString(fmt.Sprintf("*%s* %s**: %s\n\n", timestamp, prefix, msg.Content)) 69 | } 70 | 71 | return builder.String() 72 | } 73 | 74 | // FormatMessagesAsContext formats messages as a compact context for an LLM prompt 75 | func FormatMessagesAsContext(messages []Message) string { 76 | if len(messages) == 0 { 77 | return "" 78 | } 79 | 80 | var builder strings.Builder 81 | builder.WriteString("Chat History:\n") 82 | 83 | for _, msg := range messages { 84 | role := "User" 85 | if msg.IsBot { 86 | role = "Assistant" 87 | } 88 | 89 | // Add username for non-bot messages if available 90 | if !msg.IsBot && msg.Username != "" { 91 | builder.WriteString(fmt.Sprintf("%s (%s): %s\n", role, msg.Username, msg.Content)) 92 | } else { 93 | builder.WriteString(fmt.Sprintf("%s: %s\n", role, msg.Content)) 94 | } 95 | } 96 | 97 | return builder.String() 98 | } 99 | -------------------------------------------------------------------------------- /pkg/integration/memory/inmemory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "sync" 7 | ) 8 | 9 | // InMemoryStore implements a simple in-memory store for chat messages 10 | type InMemoryStore struct { 11 | messages map[string]map[string][]Message // integrationID -> channelID -> []Message 12 | maxPerChannel int 13 | mu sync.RWMutex 14 | } 15 | 16 | // NewInMemoryStore creates a new in-memory message store 17 | func NewInMemoryStore(maxPerChannel int) *InMemoryStore { 18 | if maxPerChannel <= 0 { 19 | maxPerChannel = 100 // reasonable default 20 | } 21 | 22 | return &InMemoryStore{ 23 | messages: make(map[string]map[string][]Message), 24 | maxPerChannel: maxPerChannel, 25 | mu: sync.RWMutex{}, 26 | } 27 | } 28 | 29 | // channelKey returns the key for a specific channel in an integration 30 | func (s *InMemoryStore) getChannelMessages(integID, channelID string) []Message { 31 | s.mu.RLock() 32 | defer s.mu.RUnlock() 33 | 34 | channels, ok := s.messages[integID] 35 | if !ok { 36 | return []Message{} 37 | } 38 | 39 | messages, ok := channels[channelID] 40 | if !ok { 41 | return []Message{} 42 | } 43 | 44 | return messages 45 | } 46 | 47 | // SaveMessage stores a message in memory 48 | func (s *InMemoryStore) SaveMessage(msg Message) error { 49 | s.mu.Lock() 50 | defer s.mu.Unlock() 51 | 52 | // Initialize maps if needed 53 | if _, ok := s.messages[msg.IntegrationID]; !ok { 54 | s.messages[msg.IntegrationID] = make(map[string][]Message) 55 | } 56 | 57 | if _, ok := s.messages[msg.IntegrationID][msg.ChannelID]; !ok { 58 | s.messages[msg.IntegrationID][msg.ChannelID] = []Message{} 59 | } 60 | 61 | // Add the new message 62 | s.messages[msg.IntegrationID][msg.ChannelID] = append( 63 | s.messages[msg.IntegrationID][msg.ChannelID], 64 | msg, 65 | ) 66 | 67 | // Trim if exceeding max messages per channel 68 | if len(s.messages[msg.IntegrationID][msg.ChannelID]) > s.maxPerChannel { 69 | s.messages[msg.IntegrationID][msg.ChannelID] = s.messages[msg.IntegrationID][msg.ChannelID][1:] 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // GetRecentMessages retrieves the most recent messages for a channel 76 | func (s *InMemoryStore) GetRecentMessages(integID, channelID string, limit int) ([]Message, error) { 77 | messages := s.getChannelMessages(integID, channelID) 78 | 79 | // Sort messages by timestamp (newest first) 80 | sort.Slice(messages, func(i, j int) bool { 81 | return messages[i].Timestamp.After(messages[j].Timestamp) 82 | }) 83 | 84 | // Apply limit 85 | if limit > 0 && limit < len(messages) { 86 | messages = messages[:limit] 87 | } 88 | 89 | // Reverse the order so oldest messages come first 90 | for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { 91 | messages[i], messages[j] = messages[j], messages[i] 92 | } 93 | 94 | return messages, nil 95 | } 96 | 97 | // ClearMessages removes all messages for a channel 98 | func (s *InMemoryStore) ClearMessages(integID, channelID string) error { 99 | s.mu.Lock() 100 | defer s.mu.Unlock() 101 | 102 | if channels, ok := s.messages[integID]; ok { 103 | if _, ok := channels[channelID]; ok { 104 | channels[channelID] = []Message{} 105 | return nil 106 | } 107 | return fmt.Errorf("channel %s not found for integration %s", channelID, integID) 108 | } 109 | 110 | return fmt.Errorf("integration %s not found", integID) 111 | } 112 | -------------------------------------------------------------------------------- /pkg/integration/memory/memory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mule-ai/mule/pkg/types" 7 | ) 8 | 9 | // Message represents a chat message 10 | type Message struct { 11 | ID string `json:"id"` 12 | IntegrationID string `json:"integration_id"` // "matrix", "discord", etc. 13 | ChannelID string `json:"channel_id"` // Room ID or Discord channel 14 | UserID string `json:"user_id"` // Who sent the message 15 | Username string `json:"username"` // Display name of the user 16 | Content string `json:"content"` // Message content 17 | Timestamp time.Time `json:"timestamp"` // When the message was received 18 | IsBot bool `json:"is_bot"` // Whether the message is from the bot 19 | } 20 | 21 | // MemoryStore interface defines methods for storing and retrieving messages 22 | type MemoryStore interface { 23 | // Save a message to the store 24 | SaveMessage(msg Message) error 25 | 26 | // Retrieve recent messages for a specific channel 27 | GetRecentMessages(integrationID, channelID string, limit int) ([]Message, error) 28 | 29 | // Clear messages for a specific channel 30 | ClearMessages(integrationID, channelID string) error 31 | } 32 | 33 | // Config holds configuration for the memory store 34 | type Config struct { 35 | Enabled bool `json:"enabled,omitempty"` 36 | MaxMessages int `json:"maxMessages,omitempty"` // Maximum number of messages to store per channel 37 | DefaultLimit int `json:"defaultLimit,omitempty"` // Default number of messages to retrieve 38 | } 39 | 40 | // DefaultConfig returns a default configuration 41 | func DefaultConfig() *Config { 42 | return &Config{ 43 | Enabled: true, 44 | MaxMessages: 100, 45 | DefaultLimit: 10, 46 | } 47 | } 48 | 49 | // Memory manages chat history for integrations 50 | type Memory struct { 51 | config *Config 52 | store MemoryStore 53 | integTypes map[string]string // Map of integration to its type 54 | } 55 | 56 | // New creates a new Memory manager 57 | func New(config *Config, store MemoryStore) *Memory { 58 | if config == nil { 59 | config = DefaultConfig() 60 | } 61 | 62 | return &Memory{ 63 | config: config, 64 | store: store, 65 | integTypes: make(map[string]string), 66 | } 67 | } 68 | 69 | // RegisterIntegration adds an integration type to the memory manager 70 | func (m *Memory) RegisterIntegration(integID, integType string) { 71 | m.integTypes[integID] = integType 72 | } 73 | 74 | // SaveMessage stores a message in the memory store 75 | func (m *Memory) SaveMessage(integID, channelID, userID, username, content string, isBot bool) error { 76 | if !m.config.Enabled { 77 | return nil 78 | } 79 | 80 | msg := Message{ 81 | ID: GenerateID(), 82 | IntegrationID: integID, 83 | ChannelID: channelID, 84 | UserID: userID, 85 | Username: username, 86 | Content: content, 87 | Timestamp: time.Now(), 88 | IsBot: isBot, 89 | } 90 | 91 | return m.store.SaveMessage(msg) 92 | } 93 | 94 | // GetRecentMessages retrieves recent messages for a channel 95 | func (m *Memory) GetRecentMessages(integID, channelID string, limit int) ([]Message, error) { 96 | if !m.config.Enabled { 97 | return []Message{}, nil 98 | } 99 | 100 | if limit <= 0 { 101 | limit = m.config.DefaultLimit 102 | } 103 | 104 | return m.store.GetRecentMessages(integID, channelID, limit) 105 | } 106 | 107 | // ClearMessages removes all messages for a channel 108 | func (m *Memory) ClearMessages(integID, channelID string) error { 109 | return m.store.ClearMessages(integID, channelID) 110 | } 111 | 112 | // GenerateID creates a unique ID for a message 113 | func GenerateID() string { 114 | return time.Now().Format("20060102150405.000000000") 115 | } 116 | 117 | // GetFormattedHistory returns a formatted string of recent messages suitable for LLM context 118 | func (m *Memory) GetFormattedHistory(integID, channelID string, limit int) (string, error) { 119 | messages, err := m.GetRecentMessages(integID, channelID, limit) 120 | if err != nil { 121 | return "", err 122 | } 123 | 124 | return FormatMessagesForLLM(messages), nil 125 | } 126 | 127 | // AddTriggerSettings adds a message history to trigger settings 128 | func (m *Memory) AddTriggerSettings(ts *types.TriggerSettings, integID, channelID string, limit int) error { 129 | if !m.config.Enabled { 130 | return nil 131 | } 132 | 133 | history, err := m.GetFormattedHistory(integID, channelID, limit) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | // Add the history to the trigger data 139 | switch data := ts.Data.(type) { 140 | case string: 141 | messageWithHistory := "=== Previous Messages ===\n" + history + "\n=== Current Message ===\n" + data 142 | ts.Data = messageWithHistory 143 | case map[string]interface{}: 144 | data["history"] = history 145 | ts.Data = data 146 | default: 147 | // If we can't directly add the history, we create a new map 148 | newData := map[string]interface{}{ 149 | "message": ts.Data, 150 | "history": history, 151 | } 152 | ts.Data = newData 153 | } 154 | 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /pkg/integration/memory/memory_test.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/mule-ai/mule/pkg/types" 9 | ) 10 | 11 | func TestInMemoryStore(t *testing.T) { 12 | // Create a new store with a max of 5 messages per channel 13 | store := NewInMemoryStore(5) 14 | 15 | // Test saving and retrieving messages 16 | t.Run("SaveAndRetrieveMessages", func(t *testing.T) { 17 | // Save some test messages 18 | for i := 0; i < 3; i++ { 19 | msg := Message{ 20 | ID: GenerateID(), 21 | IntegrationID: "test-integration", 22 | ChannelID: "test-channel", 23 | UserID: "user1", 24 | Username: "Test User", 25 | Content: "Test message " + GenerateID(), 26 | Timestamp: time.Now().Add(time.Duration(-i) * time.Minute), 27 | IsBot: i%2 == 0, // Even messages are from bot 28 | } 29 | 30 | err := store.SaveMessage(msg) 31 | if err != nil { 32 | t.Fatalf("Failed to save message: %v", err) 33 | } 34 | } 35 | 36 | // Retrieve messages 37 | messages, err := store.GetRecentMessages("test-integration", "test-channel", 10) 38 | if err != nil { 39 | t.Fatalf("Failed to retrieve messages: %v", err) 40 | } 41 | 42 | // Check if we have the right number of messages 43 | if len(messages) != 3 { 44 | t.Fatalf("Expected 3 messages, got %d", len(messages)) 45 | } 46 | 47 | // Check if messages are in chronological order (oldest first) 48 | for i := 0; i < len(messages)-1; i++ { 49 | if messages[i].Timestamp.After(messages[i+1].Timestamp) { 50 | t.Errorf("Messages not in chronological order: %v is after %v", 51 | messages[i].Timestamp, messages[i+1].Timestamp) 52 | } 53 | } 54 | 55 | // Check if message properties are preserved 56 | for _, msg := range messages { 57 | if msg.IntegrationID != "test-integration" { 58 | t.Errorf("Integration ID mismatch: expected 'test-integration', got '%s'", msg.IntegrationID) 59 | } 60 | if msg.ChannelID != "test-channel" { 61 | t.Errorf("Channel ID mismatch: expected 'test-channel', got '%s'", msg.ChannelID) 62 | } 63 | } 64 | }) 65 | 66 | // Test limit enforcement 67 | t.Run("EnforceMessageLimit", func(t *testing.T) { 68 | // Create a new store with a max of 3 messages 69 | limitedStore := NewInMemoryStore(3) 70 | 71 | // Save 5 messages (exceeding the limit) 72 | for i := 0; i < 5; i++ { 73 | msg := Message{ 74 | ID: GenerateID(), 75 | IntegrationID: "test-integration", 76 | ChannelID: "test-channel", 77 | UserID: "user1", 78 | Username: "Test User", 79 | Content: "Message " + GenerateID(), 80 | Timestamp: time.Now().Add(time.Duration(i) * time.Second), 81 | IsBot: false, 82 | } 83 | 84 | err := limitedStore.SaveMessage(msg) 85 | if err != nil { 86 | t.Fatalf("Failed to save message: %v", err) 87 | } 88 | } 89 | 90 | // Retrieve messages - should only get the most recent 3 91 | messages, err := limitedStore.GetRecentMessages("test-integration", "test-channel", 10) 92 | if err != nil { 93 | t.Fatalf("Failed to retrieve messages: %v", err) 94 | } 95 | 96 | // Check if we have only 3 messages (the limit) 97 | if len(messages) != 3 { 98 | t.Fatalf("Expected 3 messages after limit enforcement, got %d", len(messages)) 99 | } 100 | }) 101 | 102 | // Test clear functionality 103 | t.Run("ClearMessages", func(t *testing.T) { 104 | // Create a new store 105 | clearStore := NewInMemoryStore(10) 106 | 107 | // Save a few messages 108 | for i := 0; i < 3; i++ { 109 | msg := Message{ 110 | ID: GenerateID(), 111 | IntegrationID: "test-integration", 112 | ChannelID: "test-channel", 113 | UserID: "user1", 114 | Username: "Test User", 115 | Content: "Clear test message " + GenerateID(), 116 | Timestamp: time.Now(), 117 | IsBot: false, 118 | } 119 | 120 | err := clearStore.SaveMessage(msg) 121 | if err != nil { 122 | t.Fatalf("Failed to save message: %v", err) 123 | } 124 | } 125 | 126 | // Clear messages 127 | err := clearStore.ClearMessages("test-integration", "test-channel") 128 | if err != nil { 129 | t.Fatalf("Failed to clear messages: %v", err) 130 | } 131 | 132 | // Try to retrieve messages - should be empty 133 | messages, err := clearStore.GetRecentMessages("test-integration", "test-channel", 10) 134 | if err != nil { 135 | t.Fatalf("Failed to retrieve messages after clearing: %v", err) 136 | } 137 | 138 | // Check if the channel is empty 139 | if len(messages) != 0 { 140 | t.Fatalf("Expected 0 messages after clearing, got %d", len(messages)) 141 | } 142 | }) 143 | } 144 | 145 | func TestMemory(t *testing.T) { 146 | // Create a memory manager with default config 147 | config := DefaultConfig() 148 | store := NewInMemoryStore(config.MaxMessages) 149 | memory := New(config, store) 150 | 151 | // Test saving and retrieving messages through the Memory manager 152 | t.Run("SaveAndRetrieveMessages", func(t *testing.T) { 153 | // Register an integration 154 | memory.RegisterIntegration("test-matrix-instance", "test-matrix-instance") 155 | 156 | // Save some messages 157 | for i := 0; i < 3; i++ { 158 | err := memory.SaveMessage("test-matrix-instance", "room123", "user1", "User One", 159 | "Test message "+GenerateID(), i%2 == 0) 160 | if err != nil { 161 | t.Fatalf("Failed to save message: %v", err) 162 | } 163 | } 164 | 165 | // Get formatted history 166 | history, err := memory.GetFormattedHistory("test-matrix-instance", "room123", 10) 167 | if err != nil { 168 | t.Fatalf("Failed to get formatted history: %v", err) 169 | } 170 | 171 | // Check if we got any history 172 | if history == "" { 173 | t.Fatal("Expected non-empty history, got empty string") 174 | } 175 | }) 176 | 177 | // Test adding history to trigger settings 178 | t.Run("AddHistoryToTriggerSettings", func(t *testing.T) { 179 | // Create a trigger setting with string data 180 | ts := &types.TriggerSettings{ 181 | Integration: "test-matrix-instance", 182 | Event: "newMessage", 183 | Data: "Hello world", 184 | } 185 | 186 | // Add history to the trigger settings 187 | err := memory.AddTriggerSettings(ts, "test-matrix-instance", "room123", 10) 188 | if err != nil { 189 | t.Fatalf("Failed to add history to trigger settings: %v", err) 190 | } 191 | 192 | // Check if data was modified 193 | data, ok := ts.Data.(string) 194 | if !ok { 195 | t.Fatalf("Expected data to be string, got %T", ts.Data) 196 | } 197 | 198 | // Should start with our history header 199 | if len(data) < 10 || data[:10] != "=== Previo" { 200 | t.Fatalf("Expected data to start with history header, got: %s", data[:10]) 201 | } 202 | }) 203 | } 204 | 205 | func TestFormatter(t *testing.T) { 206 | // Create some test messages 207 | now := time.Now() 208 | messages := []Message{ 209 | { 210 | ID: "1", 211 | IntegrationID: "test", 212 | ChannelID: "channel1", 213 | UserID: "user1", 214 | Username: "Alice", 215 | Content: "Hello world", 216 | Timestamp: now.Add(-5 * time.Minute), 217 | IsBot: false, 218 | }, 219 | { 220 | ID: "2", 221 | IntegrationID: "test", 222 | ChannelID: "channel1", 223 | UserID: "bot1", 224 | Username: "Bot", 225 | Content: "How can I help you?", 226 | Timestamp: now.Add(-4 * time.Minute), 227 | IsBot: true, 228 | }, 229 | { 230 | ID: "3", 231 | IntegrationID: "test", 232 | ChannelID: "channel1", 233 | UserID: "user1", 234 | Username: "Alice", 235 | Content: "I need assistance", 236 | Timestamp: now.Add(-3 * time.Minute), 237 | IsBot: false, 238 | }, 239 | } 240 | 241 | // Test different formatting options 242 | t.Run("FormatForLLM", func(t *testing.T) { 243 | formatted := FormatMessagesForLLM(messages) 244 | if formatted == "" { 245 | t.Fatal("Expected non-empty formatted messages") 246 | } 247 | 248 | // Quick check to make sure it contains expected content 249 | for _, needle := range []string{"User (Alice)", "Assistant", "Hello world", "How can I help you?"} { 250 | if !contains(formatted, needle) { 251 | t.Errorf("Expected formatted output to contain '%s', but didn't find it", needle) 252 | } 253 | } 254 | }) 255 | 256 | t.Run("FormatForMarkdown", func(t *testing.T) { 257 | formatted := FormatMessagesForMarkdown(messages) 258 | if formatted == "" { 259 | t.Fatal("Expected non-empty formatted messages") 260 | } 261 | 262 | // Check for markdown formatting indicators 263 | for _, needle := range []string{"###", "**User", "**Assistant"} { 264 | if !contains(formatted, needle) { 265 | t.Errorf("Expected markdown to contain '%s', but didn't find it", needle) 266 | } 267 | } 268 | }) 269 | 270 | t.Run("FormatAsContext", func(t *testing.T) { 271 | formatted := FormatMessagesAsContext(messages) 272 | if formatted == "" { 273 | t.Fatal("Expected non-empty formatted messages") 274 | } 275 | 276 | // Check for expected format 277 | if !contains(formatted, "Chat History:") { 278 | t.Error("Expected context to start with 'Chat History:'") 279 | } 280 | }) 281 | } 282 | 283 | // Helper function to check if a string contains a substring 284 | func contains(s, substr string) bool { 285 | if s == "" || substr == "" { 286 | return false 287 | } 288 | return strings.Contains(s, substr) 289 | } 290 | -------------------------------------------------------------------------------- /pkg/integration/system/handlers.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | func (s *System) getModels(data any) (any, error) { 10 | provider, ok := data.(string) 11 | if !ok { 12 | provider = "" 13 | } 14 | response := ModelsResponse{ 15 | Providers: []ProviderModels{}, 16 | } 17 | 18 | if provider != "" { 19 | models, err := s.getModelsForProvider(provider) 20 | if err != nil { 21 | return nil, err 22 | } 23 | response.Providers = append(response.Providers, ProviderModels{ 24 | Name: provider, 25 | Models: models, 26 | }) 27 | } else { 28 | for _, provider := range s.providers { 29 | response.Providers = append(response.Providers, ProviderModels{ 30 | Name: provider.Name, 31 | Models: provider.Models(), 32 | }) 33 | } 34 | } 35 | responseString, err := json.MarshalIndent(response, "", " ") 36 | if err != nil { 37 | return nil, err 38 | } 39 | return string(responseString), nil 40 | } 41 | 42 | func (s *System) getModelsForProvider(provider string) ([]string, error) { 43 | providerString := strings.ToLower(provider) 44 | for _, provider := range s.providers { 45 | if provider.Name == providerString { 46 | return provider.Models(), nil 47 | } 48 | } 49 | return nil, fmt.Errorf("provider not found") 50 | } 51 | 52 | func (s *System) getProviders(data any) (any, error) { 53 | response := ProvidersResponse{ 54 | Providers: []string{}, 55 | } 56 | for _, provider := range s.providers { 57 | response.Providers = append(response.Providers, provider.Name) 58 | } 59 | responseString, err := json.MarshalIndent(response, "", " ") 60 | if err != nil { 61 | return nil, err 62 | } 63 | return string(responseString), nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/integration/system/system.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-logr/logr" 7 | "github.com/google/uuid" 8 | "github.com/jbutlerdev/genai" 9 | "github.com/mule-ai/mule/internal/scheduler" 10 | "github.com/mule-ai/mule/pkg/types" 11 | ) 12 | 13 | type Config struct { 14 | Timers map[string]string `json:"timers"` 15 | } 16 | 17 | type System struct { 18 | logger logr.Logger 19 | scheduler *scheduler.Scheduler 20 | channel chan any 21 | config *Config 22 | handlerMap map[string]func(data any) (any, error) 23 | providers map[string]*genai.Provider 24 | } 25 | 26 | func New(config *Config, providers map[string]*genai.Provider, logger logr.Logger) *System { 27 | system := &System{ 28 | logger: logger, 29 | scheduler: scheduler.NewScheduler(logger.WithName("system-integration-scheduler")), 30 | channel: make(chan any), 31 | config: config, 32 | handlerMap: make(map[string]func(data any) (any, error)), 33 | providers: providers, 34 | } 35 | 36 | system.handlerMap["models"] = system.getModels 37 | system.handlerMap["providers"] = system.getProviders 38 | 39 | system.scheduler.Start() 40 | go system.receiveOutputs() 41 | logger.Info("System integration initialized") 42 | return system 43 | } 44 | 45 | func (s *System) Call(name string, data any) (any, error) { 46 | handler, ok := s.handlerMap[name] 47 | if !ok { 48 | return nil, fmt.Errorf("handler not found: %s", name) 49 | } 50 | return handler(data) 51 | } 52 | 53 | func (s *System) Name() string { 54 | return "system" 55 | } 56 | 57 | func (s *System) GetChannel() chan any { 58 | return s.channel 59 | } 60 | 61 | func (s *System) RegisterTrigger(trigger string, data any, channel chan any) { 62 | timer, ok := s.config.Timers[trigger] 63 | if !ok { 64 | s.logger.Error(fmt.Errorf("timer %s not found", trigger), "Timer not found") 65 | return 66 | } 67 | err := s.scheduler.AddTask(uuid.New().String(), timer, func() { 68 | channel <- data 69 | }) 70 | if err != nil { 71 | s.logger.Error(err, "Failed to add trigger") 72 | } 73 | s.logger.Info("Registered trigger", "trigger", trigger, "timer", timer) 74 | } 75 | 76 | func (s *System) GetChatHistory(channelID string, limit int) (string, error) { 77 | return "", nil 78 | } 79 | 80 | func (s *System) ClearChatHistory(channelID string) error { 81 | return nil 82 | } 83 | 84 | func (s *System) receiveOutputs() { 85 | for trigger := range s.channel { 86 | triggerSettings, ok := trigger.(*types.TriggerSettings) 87 | if !ok { 88 | s.logger.Error(fmt.Errorf("trigger is not a Trigger"), "Trigger is not a Trigger") 89 | continue 90 | } 91 | switch triggerSettings.Event { 92 | case "models": 93 | response, err := s.getModels(triggerSettings.Data) 94 | if err != nil { 95 | s.logger.Error(err, "Failed to get models") 96 | continue 97 | } 98 | s.channel <- response 99 | default: 100 | s.logger.Error(fmt.Errorf("trigger event not supported: %s", triggerSettings.Event), "Unsupported trigger event") 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /pkg/integration/system/types.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | type ModelsResponse struct { 4 | Providers []ProviderModels `json:"providers"` 5 | } 6 | 7 | type ProviderModels struct { 8 | Name string `json:"name"` 9 | Models []string `json:"models"` 10 | } 11 | 12 | type ProvidersResponse struct { 13 | Providers []string `json:"providers"` 14 | } 15 | -------------------------------------------------------------------------------- /pkg/integration/tasks/tasks.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-logr/logr" 10 | ) 11 | 12 | // Note defines the structure for a note. 13 | type Note struct { 14 | Content string `json:"content"` 15 | CreatedAt time.Time `json:"created_at"` 16 | ID string `json:"id"` 17 | UpdatedAt time.Time `json:"updated_at"` 18 | } 19 | 20 | // Task defines the structure for a task. 21 | type Task struct { 22 | CreatedAt time.Time `json:"created_at"` 23 | Description string `json:"description,omitempty"` 24 | DueDate *time.Time `json:"due_date,omitempty"` 25 | ID string `json:"id"` 26 | ListID string `json:"list_id"` 27 | Notes []Note `json:"notes,omitempty"` 28 | State string `json:"state"` 29 | StateTime time.Time `json:"state_time"` 30 | SubTasks []Task `json:"sub_tasks,omitempty"` 31 | Title string `json:"title"` 32 | UpdatedAt time.Time `json:"updated_at"` 33 | } 34 | 35 | // TaskList defines the structure for a task list. 36 | type TaskList struct { 37 | CreatedAt time.Time `json:"created_at"` 38 | Description string `json:"description,omitempty"` 39 | ID string `json:"id"` 40 | Name string `json:"name"` 41 | UpdatedAt time.Time `json:"updated_at"` 42 | } 43 | 44 | // Config holds the configuration for the Tasks integration. 45 | type Config struct { 46 | Enabled bool `json:"enabled,omitempty"` 47 | APIURL string `json:"apiUrl,omitempty"` // Base URL for the tasks API 48 | } 49 | 50 | // Tasks integration implementation. 51 | type Tasks struct { 52 | config *Config 53 | l logr.Logger 54 | channel chan any 55 | client *http.Client 56 | handlerMap map[string]func(data any) (any, error) 57 | } 58 | 59 | // New creates a new Tasks integration. 60 | func New(config *Config, l logr.Logger) *Tasks { 61 | t := &Tasks{ 62 | config: config, 63 | l: l, 64 | channel: make(chan any), 65 | client: &http.Client{Timeout: 10 * time.Second}, 66 | handlerMap: make(map[string]func(data any) (any, error)), 67 | } 68 | 69 | t.handlerMap["getTasks"] = t.GetAllTasks 70 | 71 | t.init() 72 | return t 73 | } 74 | 75 | func (t *Tasks) init() { 76 | if !t.config.Enabled { 77 | t.l.Info("Tasks integration is disabled") 78 | return 79 | } 80 | if t.config.APIURL == "" { 81 | t.l.Error(fmt.Errorf("APIURL is not set for Tasks integration"), "APIURL is not set") 82 | // Potentially disable the integration or handle this error more gracefully 83 | return 84 | } 85 | t.l.Info("Tasks integration initialized", "apiUrl", t.config.APIURL) 86 | } 87 | 88 | func (t *Tasks) Call(name string, data any) (any, error) { 89 | handler, ok := t.handlerMap[name] 90 | if !ok { 91 | return nil, fmt.Errorf("handler not found for %s", name) 92 | } 93 | return handler(data) 94 | } 95 | 96 | // Name returns the name of the integration. 97 | func (t *Tasks) Name() string { 98 | return "tasks" 99 | } 100 | 101 | // GetChannel returns the communication channel for the integration. 102 | func (t *Tasks) GetChannel() chan any { 103 | return t.channel 104 | } 105 | 106 | // RegisterTrigger is a placeholder for registering triggers. 107 | func (t *Tasks) RegisterTrigger(trigger string, data any, channel chan any) { 108 | t.l.Info("RegisterTrigger method called, but not implemented for tasks integration", "trigger", trigger) 109 | } 110 | 111 | // GetAllTasks fetches all tasks from the API. 112 | func (t *Tasks) GetAllTasks(data any) (any, error) { 113 | if !t.config.Enabled || t.config.APIURL == "" { 114 | return nil, fmt.Errorf("tasks integration is disabled or APIURL is not configured") 115 | } 116 | 117 | req, err := http.NewRequest("GET", fmt.Sprintf("%s/tasks", t.config.APIURL), nil) 118 | if err != nil { 119 | return nil, fmt.Errorf("failed to create request: %w", err) 120 | } 121 | 122 | resp, err := t.client.Do(req) 123 | if err != nil { 124 | return nil, fmt.Errorf("failed to execute request: %w", err) 125 | } 126 | defer resp.Body.Close() 127 | 128 | if resp.StatusCode != http.StatusOK { 129 | return nil, fmt.Errorf("failed to get tasks, status code: %d", resp.StatusCode) 130 | } 131 | 132 | var tasks []Task 133 | if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil { 134 | return nil, fmt.Errorf("failed to decode tasks response: %w", err) 135 | } 136 | 137 | t.l.Info("Successfully fetched all tasks", "count", len(tasks)) 138 | // return should be any but castable to string 139 | jsonTasks, err := json.Marshal(tasks) 140 | if err != nil { 141 | return nil, fmt.Errorf("failed to marshal tasks: %w", err) 142 | } 143 | 144 | // set data 145 | dataString, ok := data.(string) 146 | if !ok { 147 | return nil, fmt.Errorf("data is not a string") 148 | } 149 | if dataString == "" { 150 | dataString = "Show me all tasks as a markdown table.\n" + 151 | "Do not include any tasks where the state is done. Do not show the ID in the table, make sure to include the Title, Description, State, and Due Date. \n" + 152 | "Do not include any tasks where the due date is in the past. \n" + 153 | "Make the Due Date as human readable as possible." 154 | } 155 | return string(jsonTasks) + "\n" + dataString, nil 156 | } 157 | -------------------------------------------------------------------------------- /pkg/integration/tasks/tasks_memory.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // GetChatHistory returns an error as the tasks integration doesn't support chat history 8 | func (t *Tasks) GetChatHistory(channelID string, limit int) (string, error) { 9 | return "", fmt.Errorf("chat history not supported by tasks integration") 10 | } 11 | 12 | // ClearChatHistory returns an error as the tasks integration doesn't support chat history 13 | func (t *Tasks) ClearChatHistory(channelID string) error { 14 | return fmt.Errorf("chat history not supported by tasks integration") 15 | } 16 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/go-logr/logr" 5 | "github.com/go-logr/zapr" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | const LogFile = "mule.log" 10 | 11 | func New(file string) logr.Logger { 12 | zc := zap.NewProductionConfig() 13 | zc.Level = zap.NewAtomicLevelAt(zap.DebugLevel) 14 | zc.DisableStacktrace = true 15 | // if you want logs on stdout us this line instead of the one below it 16 | // zc.OutputPaths = []string{"stdout", "tradestax.log"} 17 | if file == "" { 18 | file = LogFile 19 | } 20 | zc.OutputPaths = []string{file} 21 | z, err := zc.Build() 22 | if err != nil { 23 | panic(err) 24 | } 25 | return zapr.NewLogger(z) 26 | } 27 | 28 | func NewStdoutLogger() logr.Logger { 29 | zc := zap.NewProductionConfig() 30 | zc.Level = zap.NewAtomicLevelAt(zap.DebugLevel) 31 | zc.DisableStacktrace = true 32 | zc.OutputPaths = []string{"stdout"} 33 | z, err := zc.Build() 34 | if err != nil { 35 | panic(err) 36 | } 37 | return zapr.NewLogger(z) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/remote/github/comments.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/google/go-github/v60/github" 8 | "github.com/mule-ai/mule/pkg/remote/types" 9 | ) 10 | 11 | func (p *Provider) FetchComments(owner, repo string, prNumber int) ([]*types.Comment, error) { 12 | opt := &github.PullRequestListCommentsOptions{ 13 | ListOptions: github.ListOptions{ 14 | PerPage: 100, 15 | }, 16 | } 17 | 18 | ghComments, _, err := p.Client.PullRequests.ListComments(p.ctx, owner, repo, prNumber, opt) 19 | if err != nil { 20 | return nil, fmt.Errorf("error fetching comments: %v", err) 21 | } 22 | 23 | var comments []*types.Comment 24 | for _, comment := range ghComments { 25 | c := &types.Comment{ 26 | ID: comment.GetID(), 27 | Body: comment.GetBody(), 28 | DiffHunk: comment.GetDiffHunk(), 29 | HTMLURL: comment.GetHTMLURL(), 30 | URL: comment.GetURL(), 31 | UserID: comment.GetUser().GetID(), 32 | } 33 | reactions, err := p.FetchPullRequestCommentReactions(owner, repo, comment.GetID()) 34 | if err != nil { 35 | return nil, fmt.Errorf("error fetching reactions: %v", err) 36 | } 37 | c.Reactions = reactions 38 | comments = append(comments, c) 39 | } 40 | 41 | return comments, nil 42 | } 43 | 44 | func (p *Provider) FetchPullRequestCommentReactions(owner, repo string, commentID int64) (types.Reactions, error) { 45 | opt := &github.ListCommentReactionOptions{ 46 | ListOptions: github.ListOptions{ 47 | PerPage: 100, 48 | }, 49 | } 50 | // add pulls to repo for proper URL 51 | repo = repo + "/pulls" 52 | ghReactions, _, err := p.Client.Reactions.ListCommentReactions(p.ctx, owner, repo, commentID, opt) 53 | if err != nil { 54 | return types.Reactions{}, fmt.Errorf("error fetching reactions: %v", err) 55 | } 56 | 57 | reactions := types.Reactions{} 58 | for _, ghReaction := range ghReactions { 59 | reactions.TotalCount++ 60 | switch ghReaction.GetContent() { 61 | case "+1": 62 | reactions.PlusOne++ 63 | case "-1": 64 | reactions.MinusOne++ 65 | case "laugh": 66 | reactions.Laugh++ 67 | case "confused": 68 | reactions.Confused++ 69 | case "heart": 70 | reactions.Heart++ 71 | case "hooray": 72 | reactions.Hooray++ 73 | case "rocket": 74 | reactions.Rocket++ 75 | case "eyes": 76 | reactions.Eyes++ 77 | } 78 | } 79 | return reactions, nil 80 | } 81 | 82 | func (p *Provider) AddCommentReaction(repoPath, reaction string, commentID int64) error { 83 | parts := strings.Split(repoPath, "/") 84 | if len(parts) < 2 { 85 | return fmt.Errorf("invalid repo path format") 86 | } 87 | owner := parts[0] 88 | repo := parts[1] 89 | 90 | _, _, err := p.Client.Reactions.CreateCommentReaction(p.ctx, owner, repo, commentID, reaction) 91 | if err != nil { 92 | return fmt.Errorf("error adding reaction: %v", err) 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /pkg/remote/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/go-git/go-git/v5" 11 | "github.com/google/go-github/v60/github" 12 | "github.com/mule-ai/mule/pkg/remote/types" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | type GitHubPRResponse struct { 17 | Number int `json:"number"` 18 | } 19 | 20 | var re = regexp.MustCompile(``) 21 | 22 | func newGitHubClient(ctx context.Context, token string) *github.Client { 23 | ts := oauth2.StaticTokenSource( 24 | &oauth2.Token{AccessToken: token}, 25 | ) 26 | tc := oauth2.NewClient(ctx, ts) 27 | return github.NewClient(tc) 28 | } 29 | 30 | func (p *Provider) CreateDraftPR(path string, input types.PullRequestInput) error { 31 | repo, err := git.PlainOpen(path) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | remote, err := repo.Remote("origin") 37 | if err != nil { 38 | return fmt.Errorf("error getting remote: %v", err) 39 | } 40 | 41 | remoteURL := remote.Config().URLs[0] 42 | var owner, repoName string 43 | if strings.Contains(remoteURL, "git@github.com:") { 44 | parts := strings.Split(strings.TrimPrefix(remoteURL, "git@github.com:"), "/") 45 | owner = parts[0] 46 | repoName = strings.TrimSuffix(parts[1], ".git") 47 | } else { 48 | parts := strings.Split(strings.TrimPrefix(remoteURL, "https://github.com/"), "/") 49 | owner = parts[0] 50 | repoName = strings.TrimSuffix(parts[1], ".git") 51 | } 52 | 53 | newPR := &github.NewPullRequest{ 54 | Title: github.String(input.Title), 55 | Head: github.String(input.Branch), 56 | Base: github.String(input.Base), 57 | Body: github.String(input.Description), 58 | Draft: github.Bool(input.Draft), 59 | MaintainerCanModify: github.Bool(input.MaintainerCanModify), 60 | } 61 | 62 | pr, _, err := p.Client.PullRequests.Create(p.ctx, owner, repoName, newPR) 63 | if err != nil { 64 | return fmt.Errorf("error creating PR: %v", err) 65 | } 66 | 67 | prLink := pr.GetHTMLURL() 68 | log.Printf("PR created successfully: %s", prLink) 69 | 70 | return nil 71 | } 72 | 73 | func (p *Provider) FetchRepositories() ([]types.Repository, error) { 74 | opt := &github.RepositoryListByAuthenticatedUserOptions{ 75 | Sort: "updated", 76 | ListOptions: github.ListOptions{ 77 | PerPage: 100, 78 | }, 79 | } 80 | 81 | repos, _, err := p.Client.Repositories.ListByAuthenticatedUser(p.ctx, opt) 82 | if err != nil { 83 | return nil, fmt.Errorf("error fetching repositories: %v", err) 84 | } 85 | 86 | var result []types.Repository 87 | for _, repo := range repos { 88 | result = append(result, types.Repository{ 89 | Name: repo.GetName(), 90 | FullName: repo.GetFullName(), 91 | Description: repo.GetDescription(), 92 | CloneURL: repo.GetCloneURL(), 93 | SSHURL: repo.GetSSHURL(), 94 | }) 95 | } 96 | 97 | return result, nil 98 | } 99 | 100 | func (p *Provider) FetchPullRequests(remotePath, label string) ([]types.PullRequest, error) { 101 | parts := strings.Split(remotePath, "/") 102 | if len(parts) < 2 { 103 | return nil, fmt.Errorf("invalid remote path format") 104 | } 105 | owner := parts[0] 106 | repo := parts[1] 107 | 108 | opt := &github.PullRequestListOptions{ 109 | State: "open", 110 | ListOptions: github.ListOptions{ 111 | PerPage: 100, 112 | }, 113 | } 114 | 115 | ghPullRequests, _, err := p.Client.PullRequests.List(p.ctx, owner, repo, opt) 116 | if err != nil { 117 | log.Printf("Error fetching pull requests: %v, request: %v", err, remotePath) 118 | return nil, fmt.Errorf("error fetching pull requests: %v", err) 119 | } 120 | 121 | var pullRequests []types.PullRequest 122 | for _, pullRequest := range ghPullRequests { 123 | pr := types.PullRequest{ 124 | Number: pullRequest.GetNumber(), 125 | Title: pullRequest.GetTitle(), 126 | Body: pullRequest.GetBody(), 127 | State: pullRequest.GetState(), 128 | Labels: make([]string, 0, len(pullRequest.Labels)), 129 | HTMLURL: pullRequest.GetHTMLURL(), 130 | IssueURL: pullRequest.GetIssueURL(), 131 | CreatedAt: pullRequest.GetCreatedAt().String(), 132 | UpdatedAt: pullRequest.GetUpdatedAt().String(), 133 | LinkedIssueURLs: getLinkedIssueURLs(pullRequest.GetBody()), 134 | Comments: make([]*types.Comment, 0), 135 | } 136 | for i, label := range pullRequest.Labels { 137 | pr.Labels[i] = label.GetName() 138 | } 139 | 140 | // Fetch comments for the pull request 141 | comments, err := p.FetchComments(owner, repo, pullRequest.GetNumber()) 142 | if err != nil { 143 | log.Printf("Error fetching comments for PR %d: %v", pullRequest.GetNumber(), err) 144 | // Don't return, just log the error and continue 145 | } 146 | pr.Comments = comments 147 | 148 | diff, err := p.FetchDiffs(owner, repo, pullRequest.GetNumber()) 149 | if err != nil { 150 | log.Printf("Error fetching diffs for PR %d: %v", pullRequest.GetNumber(), err) 151 | // Don't return, just log the error and continue 152 | } 153 | pr.Diff = diff 154 | 155 | pullRequests = append(pullRequests, pr) 156 | } 157 | 158 | return pullRequests, nil 159 | } 160 | 161 | func (p *Provider) UpdatePullRequestState(remotePath string, prNumber int, state string) error { 162 | return nil 163 | } 164 | 165 | func (p *Provider) CreatePRComment(remotePath string, prNumber int, comment types.Comment) error { 166 | return nil 167 | } 168 | 169 | func (p *Provider) FetchDiffs(owner, repo string, resourceID int) (string, error) { 170 | diff, _, err := p.Client.PullRequests.GetRaw(p.ctx, owner, repo, resourceID, github.RawOptions{Type: github.Diff}) 171 | if err != nil { 172 | return "", fmt.Errorf("failed to get pull request diff: %w", err) 173 | } 174 | return diff, nil 175 | } 176 | 177 | func getLinkedIssueURLs(body string) []string { 178 | // URLs are in HTML comments 179 | matches := re.FindAllString(body, -1) 180 | urls := make([]string, len(matches)) 181 | for i, match := range matches { 182 | match = strings.TrimPrefix(match, "") 184 | urls[i] = match 185 | } 186 | return urls 187 | } 188 | -------------------------------------------------------------------------------- /pkg/remote/github/issue.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/google/go-github/v60/github" 9 | "github.com/mule-ai/mule/pkg/remote/types" 10 | ) 11 | 12 | func (p *Provider) CreateIssue(issue types.Issue) (int, error) { 13 | return 0, nil 14 | } 15 | 16 | func (p *Provider) FetchIssues(remotePath string, options types.IssueFilterOptions) ([]types.Issue, error) { 17 | parts := strings.Split(remotePath, "/") 18 | if len(parts) < 2 { 19 | return nil, fmt.Errorf("invalid remote path format") 20 | } 21 | owner := parts[0] 22 | repo := parts[1] 23 | 24 | labelsFilter := []string{} 25 | if options.Label != "" { 26 | labelsFilter = append(labelsFilter, options.Label) 27 | } 28 | 29 | stateFilter := "open" 30 | if options.State != "" { 31 | stateFilter = options.State 32 | } 33 | 34 | opt := &github.IssueListByRepoOptions{ 35 | Labels: labelsFilter, 36 | State: stateFilter, 37 | ListOptions: github.ListOptions{ 38 | PerPage: 100, 39 | }, 40 | } 41 | 42 | ghIssues, _, err := p.Client.Issues.ListByRepo(p.ctx, owner, repo, opt) 43 | if err != nil { 44 | log.Printf("Error fetching issues: %v, request: %v", err, remotePath) 45 | return nil, fmt.Errorf("error fetching issues: %v", err) 46 | } 47 | 48 | var issues []types.Issue 49 | for _, issue := range ghIssues { 50 | i := types.Issue{ 51 | Number: issue.GetNumber(), 52 | Title: issue.GetTitle(), 53 | Body: issue.GetBody(), 54 | State: issue.GetState(), 55 | HTMLURL: issue.GetHTMLURL(), 56 | SourceURL: issue.GetHTMLURL(), 57 | Labels: []string{}, 58 | CreatedAt: issue.GetCreatedAt().String(), 59 | UpdatedAt: issue.GetUpdatedAt().String(), 60 | } 61 | for _, label := range issue.Labels { 62 | i.Labels = append(i.Labels, label.GetName()) 63 | } 64 | issues = append(issues, i) 65 | } 66 | 67 | return issues, nil 68 | } 69 | 70 | func (p *Provider) AddLabelToIssue(issueNumber int, label string) error { 71 | return nil 72 | } 73 | 74 | func (p *Provider) UpdateIssueState(issueNumber int, state string) error { 75 | return nil 76 | } 77 | 78 | func (p *Provider) UpdateIssue(issueNumber int, title, body string) error { 79 | // For now, we'll implement a stub that returns nil 80 | // When GitHub integration is needed, implement the actual update logic 81 | return nil 82 | } 83 | 84 | func (p *Provider) CreateIssueComment(remotePath string, issueNumber int, comment types.Comment) error { 85 | return nil 86 | } 87 | 88 | func (p *Provider) DeleteIssue(repoPath string, issueNumber int) error { 89 | return nil 90 | } 91 | 92 | func (p *Provider) DeletePullRequest(repoPath string, prNumber int) error { 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /pkg/remote/github/provider.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/google/go-github/v60/github" 8 | ) 9 | 10 | type Provider struct { 11 | Client *github.Client 12 | ctx context.Context 13 | owner string 14 | repo string 15 | } 16 | 17 | func NewProvider(path, token string) *Provider { 18 | parts := strings.Split(path, "/") 19 | var owner, repo string 20 | if len(parts) < 2 { 21 | owner = "" 22 | repo = "" 23 | } else { 24 | owner = parts[0] 25 | repo = parts[1] 26 | } 27 | return &Provider{ 28 | Client: newGitHubClient(context.Background(), token), 29 | ctx: context.Background(), 30 | owner: owner, 31 | repo: repo, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/remote/remote.go: -------------------------------------------------------------------------------- 1 | package remote 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mule-ai/mule/pkg/remote/github" 7 | "github.com/mule-ai/mule/pkg/remote/local" 8 | "github.com/mule-ai/mule/pkg/remote/types" 9 | ) 10 | 11 | const ( 12 | LOCAL = 0 13 | GITHUB = 1 14 | ) 15 | 16 | var stringToIntMap = map[string]int{ 17 | "local": LOCAL, 18 | "github": GITHUB, 19 | } 20 | 21 | var intToStringMap = map[int]string{ 22 | LOCAL: "local", 23 | GITHUB: "github", 24 | } 25 | 26 | type Provider interface { 27 | CreateDraftPR(path string, input types.PullRequestInput) error 28 | CreateIssue(issue types.Issue) (int, error) 29 | CreateIssueComment(path string, issueNumber int, comment types.Comment) error 30 | CreatePRComment(path string, prNumber int, comment types.Comment) error 31 | DeleteIssue(repoPath string, issueNumber int) error 32 | DeletePullRequest(repoPath string, prNumber int) error 33 | UpdateIssueState(issueNumber int, state string) error 34 | UpdateIssue(issueNumber int, title, body string) error 35 | AddLabelToIssue(issueNumber int, label string) error 36 | FetchRepositories() ([]types.Repository, error) 37 | FetchIssues(remotePath string, options types.IssueFilterOptions) ([]types.Issue, error) 38 | FetchPullRequests(remotePath, label string) ([]types.PullRequest, error) 39 | UpdatePullRequestState(remotePath string, prNumber int, state string) error 40 | FetchDiffs(owner, repo string, resourceID int) (string, error) 41 | FetchComments(owner, repo string, prNumber int) ([]*types.Comment, error) 42 | AddCommentReaction(repoPath, reaction string, commentID int64) error 43 | } 44 | 45 | type ProviderSettings struct { 46 | Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` 47 | Path string `json:"path,omitempty" yaml:"path,omitempty"` 48 | Token string `json:"token,omitempty" yaml:"token,omitempty"` 49 | } 50 | 51 | type ProviderOptions struct { 52 | Type int 53 | GitHubToken string 54 | Path string 55 | } 56 | 57 | func New(options ProviderOptions) Provider { 58 | switch options.Type { 59 | case LOCAL: 60 | return local.NewProvider(options.Path) 61 | case GITHUB: 62 | return github.NewProvider(options.Path, options.GitHubToken) 63 | } 64 | return nil 65 | } 66 | 67 | func SettingsToOptions(settings ProviderSettings) (ProviderOptions, error) { 68 | provider, ok := stringToIntMap[settings.Provider] 69 | if !ok { 70 | return ProviderOptions{}, fmt.Errorf("invalid provider: %s", settings.Provider) 71 | } 72 | return ProviderOptions{ 73 | Type: provider, 74 | GitHubToken: settings.Token, 75 | Path: settings.Path, 76 | }, nil 77 | } 78 | 79 | func ProviderTypeToString(providerType int) string { 80 | return intToStringMap[providerType] 81 | } 82 | -------------------------------------------------------------------------------- /pkg/remote/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Repository struct { 4 | Name string `json:"name"` 5 | FullName string `json:"full_name"` 6 | Description string `json:"description"` 7 | CloneURL string `json:"clone_url"` 8 | SSHURL string `json:"ssh_url"` 9 | } 10 | 11 | type Issue struct { 12 | Number int `json:"number"` 13 | Title string `json:"title"` 14 | Body string `json:"body"` 15 | State string `json:"state"` 16 | HTMLURL string `json:"html_url"` 17 | SourceURL string `json:"source_url"` 18 | CreatedAt string `json:"created_at"` 19 | UpdatedAt string `json:"updated_at"` 20 | Labels []string `json:"labels"` 21 | Comments []*Comment `json:"comments"` 22 | } 23 | 24 | type IssueFilterOptions struct { 25 | State string `json:"state"` 26 | Label string `json:"label"` 27 | } 28 | 29 | type PullRequestInput struct { 30 | Title string `json:"title"` 31 | Description string `json:"description"` 32 | Branch string `json:"branch"` 33 | Base string `json:"base"` 34 | Draft bool `json:"draft"` 35 | MaintainerCanModify bool `json:"maintainer_can_modify"` 36 | } 37 | 38 | type PullRequest struct { 39 | Number int `json:"number"` 40 | Title string `json:"title"` 41 | Body string `json:"body"` 42 | State string `json:"state"` 43 | HTMLURL string `json:"html_url"` 44 | Labels []string `json:"labels"` 45 | IssueURL string `json:"issue_url"` 46 | CreatedAt string `json:"created_at"` 47 | UpdatedAt string `json:"updated_at"` 48 | Branch string `json:"branch"` 49 | BaseBranch string `json:"base_branch"` 50 | LinkedIssueURLs []string `json:"linked_issue_urls"` 51 | Diff string `json:"diff"` 52 | Comments []*Comment `json:"comments"` 53 | } 54 | 55 | type Comment struct { 56 | ID int64 `json:"id"` 57 | Body string `json:"body"` 58 | DiffHunk string `json:"diff_hunk,omitempty"` 59 | HTMLURL string `json:"html_url"` 60 | URL string `json:"url"` 61 | UserID int64 `json:"user_id"` 62 | Reactions Reactions `json:"reactions,omitempty"` 63 | } 64 | 65 | type Reaction struct { 66 | ID int64 `json:"id,omitempty"` 67 | Content string `json:"content,omitempty"` 68 | } 69 | 70 | type Reactions struct { 71 | TotalCount int `json:"total_count,omitempty"` 72 | PlusOne int `json:"+1,omitempty"` 73 | MinusOne int `json:"-1,omitempty"` 74 | Laugh int `json:"laugh,omitempty"` 75 | Confused int `json:"confused,omitempty"` 76 | Heart int `json:"heart,omitempty"` 77 | Hooray int `json:"hooray,omitempty"` 78 | Rocket int `json:"rocket,omitempty"` 79 | Eyes int `json:"eyes,omitempty"` 80 | } 81 | -------------------------------------------------------------------------------- /pkg/repository/branch.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/go-git/go-git/v5" 8 | "github.com/go-git/go-git/v5/plumbing" 9 | ) 10 | 11 | func (r *Repository) CreateBranch(branchName string) error { 12 | repo, err := git.PlainOpen(r.Path) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | head, err := repo.Head() 18 | if err != nil { 19 | return err 20 | } 21 | // check if branch exists 22 | _, err = repo.Reference(plumbing.NewBranchReferenceName(branchName), true) 23 | if err == plumbing.ErrReferenceNotFound { 24 | // branch doesn't exist, create it 25 | ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), head.Hash()) 26 | return repo.Storer.SetReference(ref) 27 | } else if err != nil { 28 | return err 29 | } 30 | // branch exists, nothing to do 31 | return nil 32 | } 33 | 34 | func (r *Repository) CheckoutBranch(branchName string) error { 35 | repo, err := git.PlainOpen(r.Path) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | w, err := repo.Worktree() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return w.Checkout(&git.CheckoutOptions{ 46 | Branch: plumbing.NewBranchReferenceName(branchName), 47 | }) 48 | } 49 | 50 | func (r *Repository) createIssueBranch(issueTitle string) (string, error) { 51 | // Convert to lowercase and replace special characters with hyphens 52 | branchName := strings.ToLower(issueTitle) 53 | // Replace any character that isn't alphanumeric or hyphen with a hyphen 54 | branchName = strings.Map(func(r rune) rune { 55 | if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { 56 | return r 57 | } 58 | return '-' 59 | }, branchName) 60 | 61 | // Replace multiple consecutive hyphens with a single hyphen 62 | for strings.Contains(branchName, "--") { 63 | branchName = strings.ReplaceAll(branchName, "--", "-") 64 | } 65 | 66 | // Trim hyphens from start and end 67 | branchName = strings.Trim(branchName, "-") 68 | 69 | if len(branchName) > 100 { 70 | branchName = branchName[:100] 71 | // Ensure we don't end with a hyphen after truncating 72 | branchName = strings.TrimRight(branchName, "-") 73 | } 74 | 75 | err := r.Fetch() 76 | if err != nil { 77 | return "", fmt.Errorf("error fetching before creating branch: %w", err) 78 | } 79 | 80 | err = r.CheckoutBranch("main") 81 | if err != nil { 82 | return "", fmt.Errorf("error checking out main before creating branch: %w", err) 83 | } 84 | 85 | err = r.CreateBranch(branchName) 86 | if err != nil { 87 | return "", fmt.Errorf("error creating branch: %w", err) 88 | } 89 | 90 | err = r.CheckoutBranch(branchName) 91 | if err != nil { 92 | return "", fmt.Errorf("error checking out new branch: %w", err) 93 | } 94 | 95 | return branchName, nil 96 | } 97 | 98 | func (r *Repository) Reset() error { 99 | repo, err := git.PlainOpen(r.Path) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | w, err := repo.Worktree() 105 | if err != nil { 106 | return err 107 | } 108 | 109 | return w.Reset(&git.ResetOptions{Mode: git.HardReset}) 110 | } 111 | -------------------------------------------------------------------------------- /pkg/repository/branchChanges.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/go-git/go-git/v5" 9 | "github.com/go-git/go-git/v5/plumbing" 10 | "github.com/go-git/go-git/v5/plumbing/object" 11 | ) 12 | 13 | type BranchChanges struct { 14 | Files []string 15 | Commits []*object.Commit 16 | Summary string 17 | } 18 | 19 | func getBranchChanges(repo *git.Repository, currentBranch string, targetBranch string) (*BranchChanges, error) { 20 | // Get references 21 | currentRef, err := repo.Reference(plumbing.NewBranchReferenceName(currentBranch), true) 22 | if err != nil { 23 | return nil, fmt.Errorf("error getting current branch ref: %v", err) 24 | } 25 | 26 | targetRef, err := repo.Reference(plumbing.NewBranchReferenceName(targetBranch), true) 27 | if err != nil { 28 | return nil, fmt.Errorf("error getting target branch ref: %v", err) 29 | } 30 | 31 | // Get commit objects 32 | currentCommit, err := repo.CommitObject(currentRef.Hash()) 33 | if err != nil { 34 | return nil, fmt.Errorf("error getting current commit: %v", err) 35 | } 36 | 37 | targetCommit, err := repo.CommitObject(targetRef.Hash()) 38 | if err != nil { 39 | return nil, fmt.Errorf("error getting target commit: %v", err) 40 | } 41 | 42 | // Find common ancestor 43 | isAncestor := false 44 | var mergeBase *object.Commit 45 | 46 | // First check if target is ancestor of current 47 | isAncestor, err = currentCommit.IsAncestor(targetCommit) 48 | if err != nil { 49 | return nil, fmt.Errorf("error checking ancestry: %v", err) 50 | } 51 | 52 | if isAncestor { 53 | mergeBase = targetCommit 54 | } else { 55 | // Then check if current is ancestor of target 56 | isAncestor, err = targetCommit.IsAncestor(currentCommit) 57 | if err != nil { 58 | return nil, fmt.Errorf("error checking ancestry: %v", err) 59 | } 60 | if isAncestor { 61 | mergeBase = currentCommit 62 | } else { 63 | // Find the most recent common ancestor 64 | commits, err := currentCommit.MergeBase(targetCommit) 65 | if err != nil { 66 | return nil, fmt.Errorf("error finding merge base: %v", err) 67 | } 68 | if len(commits) == 0 { 69 | return nil, fmt.Errorf("no common ancestor found between branches") 70 | } 71 | mergeBase = commits[0] 72 | } 73 | } 74 | 75 | // Get commit history from current branch up to merge base 76 | cIter, err := repo.Log(&git.LogOptions{From: currentRef.Hash()}) 77 | if err != nil { 78 | return nil, fmt.Errorf("error getting commit history: %v", err) 79 | } 80 | 81 | var commits []*object.Commit 82 | var files = make(map[string]struct{}) 83 | var summary strings.Builder 84 | 85 | err = cIter.ForEach(func(c *object.Commit) error { 86 | // Stop when we reach the merge base 87 | if c.Hash == mergeBase.Hash { 88 | return io.EOF 89 | } 90 | 91 | commits = append(commits, c) 92 | summary.WriteString("- " + c.Message + "\n") 93 | 94 | // Get files changed in this commit 95 | stats, err := c.Stats() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | for _, stat := range stats { 101 | files[stat.Name] = struct{}{} 102 | } 103 | 104 | return nil 105 | }) 106 | 107 | if err != nil && err != io.EOF { 108 | return nil, fmt.Errorf("error iterating commits: %v", err) 109 | } 110 | 111 | // Convert files map to slice 112 | var filesList []string 113 | for file := range files { 114 | filesList = append(filesList, file) 115 | } 116 | 117 | return &BranchChanges{ 118 | Files: filesList, 119 | Commits: commits, 120 | Summary: summary.String(), 121 | }, nil 122 | } 123 | -------------------------------------------------------------------------------- /pkg/repository/issue.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/mule-ai/mule/pkg/remote/types" 8 | ) 9 | 10 | type Issue struct { 11 | ID int `json:"id"` 12 | Number int `json:"number"` 13 | Title string `json:"title"` 14 | Body string `json:"body"` 15 | State string `json:"state"` 16 | HTMLURL string `json:"html_url"` 17 | SourceURL string `json:"source_url"` 18 | CreatedAt string `json:"created_at"` 19 | UpdatedAt string `json:"updated_at"` 20 | Comments []*Comment `json:"comments"` 21 | PullRequests []*PullRequest `json:"pull_requests"` 22 | Labels []string `json:"labels"` 23 | } 24 | 25 | func (i *Issue) addPullRequests(pullRequests map[int]*PullRequest) { 26 | i.PullRequests = make([]*PullRequest, 0) 27 | for _, pullRequest := range pullRequests { 28 | for _, linkedIssueUrl := range pullRequest.LinkedIssueUrls { 29 | if linkedIssueUrl == i.SourceURL || linkedIssueUrl == i.HTMLURL { 30 | i.PullRequests = append(i.PullRequests, pullRequest) 31 | } 32 | } 33 | } 34 | } 35 | 36 | func (i *Issue) Completed() bool { 37 | _, hasUnresolvedComments := i.PRHasUnresolvedComments() 38 | if hasUnresolvedComments { 39 | return false 40 | } else if i.PrExists() { 41 | return true 42 | } 43 | return false 44 | } 45 | 46 | func (i *Issue) PrExists() bool { 47 | return len(i.PullRequests) > 0 48 | } 49 | 50 | func (i *Issue) PRHasUnresolvedComments() (*PullRequest, bool) { 51 | for _, pullRequest := range i.PullRequests { 52 | if pullRequest.HasUnresolvedComments() { 53 | return pullRequest, true 54 | } 55 | } 56 | return nil, false 57 | } 58 | 59 | func (r *Repository) GetIssues() ([]*Issue, error) { 60 | issues := make([]*Issue, 0, len(r.Issues)) 61 | for _, issue := range r.Issues { 62 | issues = append(issues, issue) 63 | } 64 | return issues, nil 65 | } 66 | 67 | func (r *Repository) UpdateIssues() error { 68 | if r.RemotePath == "" { 69 | return fmt.Errorf("repository remote path is not set") 70 | } 71 | issues, err := r.Remote.FetchIssues(r.RemotePath, types.IssueFilterOptions{ 72 | State: "open", 73 | Label: "mule", 74 | }) 75 | if err != nil { 76 | log.Printf("Error fetching issues: %v, request: %v", err, r.RemotePath) 77 | return err 78 | } 79 | // reset tracked issues 80 | r.Issues = make(map[int]*Issue) 81 | for _, issue := range issues { 82 | r.Issues[issue.Number] = ghIssueToIssue(issue) 83 | } 84 | return nil 85 | } 86 | 87 | func (i *Issue) ToString() string { 88 | return fmt.Sprintf("Issue #%d: %s\n%s", i.Number, i.Title, i.Body) 89 | } 90 | 91 | func ghIssueToIssue(issue types.Issue) *Issue { 92 | return &Issue{ 93 | ID: issue.Number, 94 | Number: issue.Number, 95 | Title: issue.Title, 96 | Body: issue.Body, 97 | CreatedAt: issue.CreatedAt, 98 | UpdatedAt: issue.UpdatedAt, 99 | SourceURL: issue.SourceURL, 100 | HTMLURL: issue.HTMLURL, 101 | State: issue.State, 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /pkg/repository/prompts.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "fmt" 4 | 5 | func CommitPrompt(changes string) string { 6 | return fmt.Sprintf("Generate a concise commit message for the following changes\n"+ 7 | "no placeholders, explanation, or other text should be provided\n"+ 8 | "limit the message to 72 characters\n\n%s", changes) 9 | } 10 | 11 | func PRPrompt(changes string) string { 12 | return fmt.Sprintf("Generate a detailed pull request description for the following changes:\n\n%s\n\n"+ 13 | "The description should include:\n"+ 14 | "1. A summary of the changes\n"+ 15 | "2. The motivation for the changes\n"+ 16 | "3. Any potential impact or breaking changes\n"+ 17 | "4. Testing instructions if applicable\n\n"+ 18 | "Format the response in markdown.\n"+ 19 | "Do not include any other text in the response.\n"+ 20 | "Do not include any placeholders in the response. It is expected to be a complete description.\n"+ 21 | "Provide the output as markdown, but do not wrap it in a code block.\n\n", 22 | changes) 23 | } 24 | 25 | func IssuePrompt(issue string) string { 26 | // return a prompt that will have an agent write the code to fix the issue 27 | return fmt.Sprintf("Write the code to fix the following issue:\n\n%s\n\n"+ 28 | "The code should be written in the language of the repository.\n"+ 29 | "It is recommended that you first list the files in the repository and read one of them to get an idea of the codebase.\n"+ 30 | "After that, make sure you use the writeFile tool to write the code to a file.\n\n", issue) 31 | } 32 | 33 | func PRCommentPrompt(issue, diffs, comment, diffHunk string) string { 34 | // return a prompt that includes the original issue and the comment 35 | return fmt.Sprintf("You were given the following Issue to address:\n\n%s\n\n"+ 36 | "From this, you generated the following pull request:\n\n%s\n\n"+ 37 | "A User provided you the following comment on the pull request:\n\n%s\n\n"+ 38 | "Which applies to the following diff hunk:\n\n%s\n\n"+ 39 | "Update your code to properly and thoroughly address the comment.\n"+ 40 | "It is recommended that you first list the files in the repository and read one of them to get an idea of the codebase.\n"+ 41 | "After that, make sure you use the writeFile tool to write the code to a file.\n\n", issue, diffs, comment, diffHunk) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/repository/pullRequest.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/mule-ai/mule/pkg/remote/types" 8 | ) 9 | 10 | type PullRequest struct { 11 | Number int `json:"number"` 12 | Title string `json:"title"` 13 | Body string `json:"body"` 14 | CreatedAt string `json:"created_at"` 15 | UpdatedAt string `json:"updated_at"` 16 | Labels []string `json:"labels"` 17 | IssueUrl string `json:"issue_url"` 18 | LinkedIssueUrls []string `json:"linked_issue_urls"` 19 | Diff string `json:"diff"` 20 | Comments []*Comment `json:"comments"` 21 | } 22 | 23 | type Comment struct { 24 | ID int64 `json:"id"` 25 | Body string `json:"body"` 26 | DiffHunk string `json:"diff_hunk,omitempty"` 27 | HTMLURL string `json:"html_url"` 28 | URL string `json:"url"` 29 | UserID int64 `json:"user_id"` 30 | Acknowledged bool `json:"acknowledged"` 31 | Reactions types.Reactions `json:"reactions"` 32 | } 33 | 34 | func (p *PullRequest) HasUnresolvedComments() bool { 35 | for _, comment := range p.Comments { 36 | if !comment.Acknowledged { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | 43 | func (p *PullRequest) FirstUnresolvedComment() *Comment { 44 | for _, comment := range p.Comments { 45 | if !comment.Acknowledged { 46 | return comment 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | func (r *Repository) GetPullRequests() []*PullRequest { 53 | pullRequests := make([]*PullRequest, 0, len(r.PullRequests)) 54 | for _, pullRequest := range r.PullRequests { 55 | pullRequests = append(pullRequests, pullRequest) 56 | } 57 | return pullRequests 58 | } 59 | 60 | func (r *Repository) UpdatePullRequests() error { 61 | if r.RemotePath == "" { 62 | return fmt.Errorf("repository remote path is not set") 63 | } 64 | pullRequests, err := r.Remote.FetchPullRequests(r.RemotePath, "dev-team") 65 | if err != nil { 66 | log.Printf("Error fetching pull requests: %v, request: %v", err, r.RemotePath) 67 | return err 68 | } 69 | // reset tracked pull requests 70 | r.PullRequests = make(map[int]*PullRequest) 71 | for _, pullRequest := range pullRequests { 72 | r.PullRequests[pullRequest.Number] = ghPullRequestToPullRequest(pullRequest) 73 | } 74 | return nil 75 | } 76 | 77 | func ghPullRequestToPullRequest(pullRequest types.PullRequest) *PullRequest { 78 | return &PullRequest{ 79 | Number: pullRequest.Number, 80 | Title: pullRequest.Title, 81 | Body: pullRequest.Body, 82 | CreatedAt: pullRequest.CreatedAt, 83 | UpdatedAt: pullRequest.UpdatedAt, 84 | Labels: pullRequest.Labels, 85 | IssueUrl: pullRequest.IssueURL, 86 | LinkedIssueUrls: pullRequest.LinkedIssueURLs, 87 | Diff: pullRequest.Diff, 88 | Comments: ghCommentsToComments(pullRequest.Comments), 89 | } 90 | } 91 | 92 | func ghCommentsToComments(comments []*types.Comment) []*Comment { 93 | pullRequestComments := make([]*Comment, 0, len(comments)) 94 | for _, comment := range comments { 95 | pullRequestComments = append(pullRequestComments, &Comment{ 96 | ID: comment.ID, 97 | Body: comment.Body, 98 | DiffHunk: comment.DiffHunk, 99 | HTMLURL: comment.HTMLURL, 100 | URL: comment.URL, 101 | UserID: comment.UserID, 102 | Acknowledged: comment.Reactions.PlusOne > 0, 103 | }) 104 | } 105 | return pullRequestComments 106 | } 107 | -------------------------------------------------------------------------------- /pkg/repository/state.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-git/go-git/v5" 7 | ) 8 | 9 | type Status struct { 10 | HasChanges bool `json:"hasChanges"` 11 | ChangedFiles []string `json:"changedFiles"` 12 | CurrentBranch string `json:"currentBranch"` 13 | IsClean bool `json:"isClean"` 14 | } 15 | 16 | func (r *Repository) UpdateStatus() error { 17 | status, err := r.Status() 18 | if err != nil { 19 | return err 20 | } 21 | r.State = status 22 | r.LastSync = time.Now() 23 | return nil 24 | } 25 | 26 | func (r *Repository) Status() (*Status, error) { 27 | repo, err := git.PlainOpen(r.Path) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | w, err := repo.Worktree() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | status, err := w.Status() 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | head, err := repo.Head() 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | changedFiles := []string{} 48 | for file, fileStatus := range status { 49 | if fileStatus.Staging != git.Unmodified || fileStatus.Worktree != git.Unmodified { 50 | changedFiles = append(changedFiles, file) 51 | } 52 | } 53 | 54 | return &Status{ 55 | HasChanges: !status.IsClean(), 56 | ChangedFiles: changedFiles, 57 | CurrentBranch: head.Name().Short(), 58 | IsClean: status.IsClean(), 59 | }, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type TriggerSettings struct { 4 | Integration string `json:"integration"` 5 | Event string `json:"event"` 6 | Data any `json:"data"` 7 | } 8 | 9 | type Integration interface { 10 | Call(name string, data any) (any, error) 11 | GetChannel() chan any 12 | Name() string 13 | RegisterTrigger(trigger string, data any, channel chan any) 14 | 15 | // Chat memory methods 16 | GetChatHistory(channelID string, limit int) (string, error) 17 | ClearChatHistory(channelID string) error 18 | } 19 | -------------------------------------------------------------------------------- /pkg/validation/functions.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import "os/exec" 4 | 5 | type ValidationFunc func(string) (string, error) 6 | 7 | var functions = map[string]ValidationFunc{ 8 | "getDeps": getDeps, 9 | "goFmt": goFmt, 10 | "goModTidy": goModTidy, 11 | "golangciLint": golangciLint, 12 | "goTest": goTest, 13 | } 14 | 15 | func Get(name string) (ValidationFunc, bool) { 16 | if fn, ok := functions[name]; ok { 17 | return fn, true 18 | } 19 | return nil, false 20 | } 21 | 22 | func goFmt(path string) (string, error) { 23 | cmd := exec.Command("go", "fmt", "./...") 24 | cmd.Dir = path 25 | _, err := cmd.CombinedOutput() 26 | if err != nil { 27 | discard.Info("go fmt updated files, ignoring error") 28 | } 29 | return "", nil 30 | } 31 | 32 | func goModTidy(path string) (string, error) { 33 | cmd := exec.Command("go", "mod", "tidy") 34 | cmd.Dir = path 35 | _, err := cmd.CombinedOutput() 36 | if err != nil { 37 | discard.Info("go mod tidy failed, ignoring error") 38 | } 39 | return "", nil 40 | } 41 | 42 | func golangciLint(path string) (string, error) { 43 | // cmd := exec.Command("./bin/golangci-lint", "run") 44 | cmd := exec.Command("make", "lint") 45 | cmd.Dir = path 46 | out, err := cmd.CombinedOutput() 47 | // convert byte array to string 48 | return string(out), err 49 | } 50 | 51 | func goTest(path string) (string, error) { 52 | cmd := exec.Command("go", "test", "./...") 53 | cmd.Dir = path 54 | out, err := cmd.CombinedOutput() 55 | return string(out), err 56 | } 57 | 58 | func getDeps(path string) (string, error) { 59 | cmd := exec.Command("make", "download-golangci-lint") 60 | cmd.Dir = path 61 | _, err := cmd.CombinedOutput() 62 | if err != nil { 63 | discard.Info("download golangci-lint failed") 64 | } 65 | return "", nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/validation/validation.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-logr/logr" 7 | ) 8 | 9 | var discard = logr.Discard() 10 | 11 | type ValidationInput struct { 12 | Validations []ValidationFunc 13 | Logger logr.Logger 14 | Path string 15 | } 16 | 17 | func Run(in *ValidationInput) (string, error) { 18 | // run validation attempts 19 | if len(in.Validations) < 1 { 20 | return "", nil 21 | } 22 | // run validations 23 | for _, validation := range in.Validations { 24 | out, err := validation(in.Path) 25 | if err != nil { 26 | in.Logger.Error(err, "validation failed\n\n"+out) 27 | return out, fmt.Errorf("validation failed") 28 | } 29 | } 30 | return "", nil 31 | } 32 | 33 | func Validations() []string { 34 | validations := make([]string, 0, len(functions)) 35 | for name := range functions { 36 | validations = append(validations, name) 37 | } 38 | return validations 39 | } 40 | -------------------------------------------------------------------------------- /wiki/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture Overview 2 | ## High-Level Components 3 | ```mermaid 4 | graph LR 5 | A[cmd/mule] --> B[pkg/remote/github] 6 | A --> C[pkg/repository/local] 7 | A --> D[internal/handlers] 8 | D --> E[pkg/agent] 9 | E --> F[pkg/validation] 10 | C --> G[internal/state] 11 | 12 | subgraph Core 13 | D 14 | E 15 | F 16 | end 17 | 18 | subgraph Data 19 | G 20 | C 21 | B 22 | end 23 | ``` 24 | -------------------------------------------------------------------------------- /wiki/index.md: -------------------------------------------------------------------------------- 1 | # Project Wiki 2 | ## Overview 3 | This project follows a modular architecture with distinct packages handling specific responsibilities. The main components include: 4 | 5 | - **cmd/mule**: Entry point with web handlers 6 | - **internal/** packages: Core business logic and state management 7 | - **pkg/** packages: Shared utilities and domain-specific functionality 8 | - **repository/** packages: Data access and storage operations 9 | - **handlers/**: Web API implementation 10 | - **remote/**: External service integrations (e.g., GitHub) 11 | 12 | ## Navigation 13 | 1. [Architecture Diagram](architecture.md) 14 | 2. Package Documentation: 15 | - [internal/config](internal-config.md) 16 | - [internal/handlers](internal-handlers.md) 17 | - [internal/scheduler](internal-scheduler.md) 18 | - [pkg/agent](pkg-agent.md) 19 | - [pkg/repository](pkg-repository.md) 20 | - [pkg/remote](pkg-remote.md) 21 | - [pkg/validation](pkg-validation.md) 22 | -------------------------------------------------------------------------------- /wiki/internal-config.md: -------------------------------------------------------------------------------- 1 | # internal/config Package 2 | ## Overview 3 | Manages application configuration and default settings. Provides type-safe access to configuration parameters through the `Config` struct. 4 | 5 | ## Key Components 6 | - **Config Struct**: Central storage for all configuration values 7 | - **LoadConfig()**: Initializes and validates the configuration 8 | - **GetSetting()**: Type-safe retrieval of configuration values 9 | 10 | ## Usage Example 11 | ```go 12 | // Get a GitHub token from config 13 | config.GetSetting[string]("github_token") 14 | ``` 15 | -------------------------------------------------------------------------------- /wiki/internal-handlers.md: -------------------------------------------------------------------------------- 1 | # internal/handlers Package 2 | ## Overview 3 | Handles web API request routing and business logic implementation. Contains handlers for: 4 | - GitHub integrations 5 | - Local repository operations 6 | - Logs management 7 | - Settings configuration 8 | - Repository state tracking 9 | 10 | ## Key Components 11 | - **GitHubHandler**: Manages GitHub webhook and API interactions 12 | - **LocalProviderHandler**: Handles local repository operations 13 | - **LogHandler**: Implements log retrieval and filtering 14 | - **SettingsHandler**: Manages settings persistence and updates 15 | 16 | ## Dependency Diagram 17 | ```mermaid 18 | graph TD 19 | A[internal/handlers] --> B[pkg/remote/github] 20 | A --> C[pkg/repository/local] 21 | A --> D[internal/state] 22 | A --> E[pkg/agent] 23 | ``` 24 | -------------------------------------------------------------------------------- /wiki/internal-scheduler.md: -------------------------------------------------------------------------------- 1 | # internal/scheduler Package 2 | ## Overview 3 | Manages periodic execution of repository analysis and workflow scheduling. Handles timing and orchestration of: 4 | - Repository scanning intervals 5 | - Workflow execution triggers 6 | - Agent task scheduling 7 | 8 | ## Key Functions 9 | ```go 10 | // ScheduleWorkflow() - Creates a new workflow execution schedule 11 | ScheduleWorkflow(ctx context.Context, interval time.Duration) 12 | 13 | // GetNextExecution() - Calculates the next scheduled execution time 14 | GetNextExecution(currentTime time.Time) time.Time 15 | ``` 16 | -------------------------------------------------------------------------------- /wiki/internal-settings.md: -------------------------------------------------------------------------------- 1 | # internal/settings Package 2 | ## Overview 3 | Manages application settings and agent/workflow configurations. Provides: 4 | - Default setting definitions 5 | - Settings persistence and retrieval 6 | - Validation of configuration parameters 7 | 8 | ## Key Components 9 | ```go 10 | type Settings struct { 11 | GitHubToken string 12 | AIProviders []AIProviderSettings 13 | Agents []AgentOptions 14 | SystemAgent SystemAgentSettings 15 | Workflows []WorkflowSettings 16 | } 17 | 18 | type AIProviderSettings struct { 19 | Provider string 20 | APIKey string 21 | Server string 22 | } 23 | ``` 24 | 25 | ## Dependency Diagram 26 | ```mermaid 27 | graph TD 28 | A[internal/settings] --> B[internal/config] 29 | A --> C[pkg/agent] 30 | ``` 31 | -------------------------------------------------------------------------------- /wiki/pkg-agent.md: -------------------------------------------------------------------------------- 1 | # pkg/agent Package 2 | ## Overview 3 | Manages AI agent configurations, workflow execution, and code generation capabilities. Provides interfaces for: 4 | - Agent lifecycle management 5 | - Workflow coordination between multiple agents 6 | - Unified diff (udiff) application to code repositories 7 | 8 | ## Key Components 9 | ```go 10 | type Agent struct { 11 | ID int 12 | Provider string 13 | Model string 14 | Tools []string 15 | SystemPrompt string 16 | } 17 | 18 | type Workflow struct { 19 | Steps []*WorkflowStep 20 | } 21 | 22 | func CreateAgent(cfg AgentOptions) (*Agent, error) 23 | func ExecuteWorkflow(wf *Workflow, input string) (string, error) 24 | ``` 25 | -------------------------------------------------------------------------------- /wiki/pkg-remote-github.md: -------------------------------------------------------------------------------- 1 | # pkg/remote/github Package 2 | ## Overview 3 | Implements GitHub API integration for repository operations. Provides functionality for: 4 | - Pull request creation and management 5 | - Issue tracking and comment handling 6 | - Repository state synchronization with GitHub 7 | 8 | ## Key Components 9 | ### Interfaces 10 | ```go 11 | type GithubProvider interface { 12 | CreatePullRequest(ctx context.Context, pr *github.PullRequest) (*github.PullRequest, error) 13 | GetIssueComments(ctx context.Context, owner, repo string, number int) ([]github.Comment, error) 14 | } 15 | ``` 16 | 17 | ### Functions 18 | - `GetGitHubClient()`: Creates an authenticated GitHub API client 19 | - `HandleWebhookEvent()`: Processes incoming GitHub webhook events 20 | -------------------------------------------------------------------------------- /wiki/pkg-remote-local.md: -------------------------------------------------------------------------------- 1 | # pkg/remote/local Package 2 | ## Overview 3 | Provides local repository operations for working with Git repositories without external dependencies. Key functionality includes: 4 | - Local branch management 5 | - File system operations 6 | - Commit history traversal 7 | 8 | ## Key Components 9 | ```go 10 | type LocalProvider interface { 11 | CloneRepository(url string, dir string) error 12 | GetBranches() ([]string, error) 13 | CheckoutBranch(branch string) error 14 | } 15 | 16 | func GetLocalClient(repoPath string) (*LocalProvider, error) 17 | ``` 18 | -------------------------------------------------------------------------------- /wiki/pkg-remote.md: -------------------------------------------------------------------------------- 1 | # pkg/remote Package 2 | ## Overview 3 | Handles remote repository interactions and external service integrations. Contains sub-packages for: 4 | - GitHub API operations 5 | - Local repository management 6 | - Remote connection abstraction 7 | 8 | ## Key Components 9 | ### Sub-packages 10 | 1. **github/** 11 | - Manages pull requests, issues, and comments 12 | - Implements GitHub workflow triggers 13 | 2. **local/** 14 | - Provides local repository operations 15 | 3. **types/** 16 | - Defines shared remote interface types 17 | 18 | ## Dependency Diagram 19 | ```mermaid 20 | graph TD 21 | A[pkg/remote] --> B[github] 22 | A --> C[local] 23 | B --> D[Github API] 24 | C --> E[Local Git operations] 25 | ``` 26 | -------------------------------------------------------------------------------- /wiki/pkg-repository.md: -------------------------------------------------------------------------------- 1 | # pkg/repository Package 2 | ## Overview 3 | Manages repository state, synchronization, and workflow execution tracking. Provides core functionality for: 4 | - Repository metadata storage (path, schedule, last sync time) 5 | - Remote provider integration management 6 | - State tracking of repository operations 7 | - Workflow coordination with agents 8 | - Issue/PR change generation and tracking 9 | 10 | ## Key Components 11 | ```go 12 | type Repository struct { 13 | Path string `json:"path"` 14 | RemoteProvider remote.ProviderSettings `json:"remoteProvider"` 15 | Schedule string `json:"schedule"` 16 | LastSync time.Time `json:"lastSync"` 17 | State *Status `json:"status,omitempty"` 18 | RemotePath string `json:"remotePath,omitempty"` 19 | Issues map[int]*Issue `-` // Not exported 20 | PullRequests map[int]*PullRequest `-` 21 | Mu sync.RWMutex `-` 22 | Locked bool `json:"locked"` 23 | Logger logr.Logger `-` 24 | Remote remote.Provider `-` 25 | } 26 | 27 | // Changes represents generated modifications 28 | type Changes struct { 29 | Files []string 30 | Commits []string 31 | Summary string 32 | } 33 | ``` 34 | 35 | ## Dependency Diagram 36 | ```mermaid 37 | graph TD 38 | A[pkg/repository] --> B[pkg/remote] 39 | A --> C[internal/state] 40 | A --> D[pkg/agent] 41 | A --> E[pkg/validation] 42 | 43 | subgraph Remote 44 | B 45 | end 46 | ``` 47 | 48 | ## Core Functions 49 | ```go 50 | func NewRepository(path string) *Repository 51 | func (r *Repository) generateFromIssue(agents map[int]*agent.Agent, workflow struct{ Steps []agent.WorkflowStep }, issue *Issue) (bool, error) 52 | func (r *Repository) updatePR(agents map[int]*agent.Agent, commentId int64) error 53 | ``` 54 | 55 | ## Usage Example 56 | ```go 57 | // Create a new repository instance 58 | repo := NewRepository("/path/to/repo") 59 | 60 | // Generate changes from an issue 61 | workflow := Workflow{Steps: []WorkflowStep{{ID: "step1", AgentID: 1}}} 62 | success, err := repo.generateFromIssue(agents, workflow, issue) 63 | ``` 64 | -------------------------------------------------------------------------------- /wiki/pkg-validation.md: -------------------------------------------------------------------------------- 1 | # pkg/validation Package 2 | ## Overview 3 | Provides code validation and analysis functions used by workflows to ensure quality. Includes implementations for: 4 | - Code formatting checks 5 | - Linting and static analysis 6 | - Test execution 7 | 8 | ## Key Validation Functions 9 | ```go 10 | // Format validation function 11 | goFmt(): Validates code formatting against standards 12 | 13 | golangciLint(): Runs static analysis with golangci-lint 14 | 15 | getDeps(): Verifies dependencies are properly managed 16 | ``` 17 | --------------------------------------------------------------------------------