diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..bdc0d7f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Build + +on: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Build multi-platform packages + run: make build + + - name: Clean built packages + run: make clean diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8df907c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Set up golangci + uses: golangci/golangci-lint-action@v8 + with: + version: latest + + - name: Format + run: make fmt + + - name: Lint + run: make lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..01acb2a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release +on: + push: + tags: + - "v*" + +permissions: + contents: read # for checkout + +jobs: + release: + name: Release + runs-on: ubuntu-latest + permissions: + contents: write # to be able to publish a GitHub release + issues: write # to be able to comment on released issues + pull-requests: write # to be able to comment on released pull requests + id-token: write # to enable use of OIDC for npm provenance + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + + - name: Install goreleaser + run: npm i -g @goreleaser/goreleaser + + - name: Release Go binary + run: goreleaser release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup npm authentication + run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Release NPM package + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public diff --git a/.github/workflows/test_pr.yml b/.github/workflows/test_pr.yml new file mode 100644 index 0000000..fd8d1fb --- /dev/null +++ b/.github/workflows/test_pr.yml @@ -0,0 +1,33 @@ +name: Go Tests - PR + +on: + pull_request: + +jobs: + test-pr: + runs-on: ubuntu-latest + + strategy: + matrix: + go-version: ["1.24", "1.25"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-${{ matrix.go-version }}- + + - name: Run tests + run: make test diff --git a/.github/workflows/test_push.yml b/.github/workflows/test_push.yml new file mode 100644 index 0000000..e0c14fb --- /dev/null +++ b/.github/workflows/test_push.yml @@ -0,0 +1,34 @@ +name: Go Tests - Push to Main + +on: + push: + branches: ["main"] + +jobs: + test-push: + runs-on: ubuntu-latest + + strategy: + matrix: + go-version: ["1.25"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-${{ matrix.go-version }}- + + - name: Run tests + run: make test diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..17c84ea --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,9 @@ +builds: + - binary: multipilot + goos: + - windows + - darwin + - linux + goarch: + - amd64 + - arm64 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3e9bbd9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing to `multipilot` + +Thank you for your interest in contributing to this project! Please review these guidelines before getting started. + +## Issue Reporting + +### When to Report an Issue + +- You've discovered bugs but lack the knowledge or time to fix them +- You have feature requests but cannot implement them yourself + +> ⚠️ **Important:** Always search existing open and closed issues before submitting to avoid duplicates. + +### How to Report an Issue + +1. Open a new issue +2. Provide a clear, concise title that describes the problem or feature request +3. Include a detailed description of the issue or requested feature + +## Code Contributions + +### When to Contribute + +- You've identified and fixed bugs +- You've optimized or improved existing code +- You've developed new features that would benefit the community + +### How to Contribute + +1. **Fork the repository and check out a secondary branch** + +2. **Make your changes and test** + + ```bash + make build + make test + ``` + + Ensure the build succeeds and all tests pass. Add tests for new features. + +4. **Verify formatting and linting compliance** + Ensure your changes pass all linting checks. + + ```bash + make lint + make fmt + ``` + +5. **Commit your changes** + +6. **Submit a pull request** + Include a comprehensive description of your changes. + +--- + +**Thank you for contributing!** diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0630023 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) Clelia Astra Bertelli 2026 + +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: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a9edb8 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +BIN="./bin" +BIN_NAME="multipilot" +MAIN_PKG="." +SRC=$(shell find . -name "*.go") + +ifeq (, $(shell which golangci-lint)) +$(warning "could not find golangci-lint in $(PATH), run: curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh") +endif + +.PHONY: fmt lint test install_deps clean + +all: fmt lint test build + +fmt: + $(info ******************** checking formatting ********************) + @test -z $(shell gofmt -l $(SRC)) || (gofmt -d $(SRC); exit 1) + +lint: + $(info ******************** running lint tools ********************) + golangci-lint run -v + +test: install_deps + $(info ******************** running tests ********************) + go test -v ./... + +install_deps: + $(info ******************** downloading dependencies ********************) + go get -v ./... + +build: install_deps + $(info ******************** building project ********************) + @mkdir -p ${BIN} + GOARCH=amd64 GOOS=darwin go build -o ${BIN}/${BIN_NAME}-darwin-amd64 ${MAIN_PKG} + GOARCH=amd64 GOOS=linux go build -o ${BIN}/${BIN_NAME}-linux-amd64 ${MAIN_PKG} + GOARCH=amd64 GOOS=windows go build -o ${BIN}/${BIN_NAME}-windows-amd64.exe ${MAIN_PKG} + GOARCH=arm64 GOOS=darwin go build -o ${BIN}/${BIN_NAME}-darwin-arm64 ${MAIN_PKG} + GOARCH=arm64 GOOS=linux go build -o ${BIN}/${BIN_NAME}-linux-arm64 ${MAIN_PKG} + GOARCH=arm64 GOOS=windows go build -o ${BIN}/${BIN_NAME}-windows-arm64.exe ${MAIN_PKG} + +clean: + @rm -rf ${BIN} diff --git a/README.md b/README.md index 299c06c..d811a45 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ A simple orchestration layer based with Temporal Workflows to run multiple Copil ## Installation +With npm (recommended): + +```bash +npm install @cle-does-things/multipilot@latest +``` + +With Go (1.24+ required): + ```bash go install github.com/AstraBert/multipilot ``` @@ -43,8 +51,8 @@ Create a configuration file with all the tasks you want Copilot to perform, foll "API_KEY=secret123" ], "prompt": "Analyze the codebase and suggest improvements for performance", - "token": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "ai_model": "gpt-5.1", + "token": "$GH_TOKEN", + "ai_model": "claude-haiku-4-5", "system_prompt": "You are a helpful coding assistant specializing in Go and Python.", "exclude_tools": [ "shell(rm)", @@ -112,8 +120,8 @@ Create a configuration file with all the tasks you want Copilot to perform, foll "API_KEY=secret123" ], "prompt": "Analyze the codebase and suggest improvements for performance", - "token": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "ai_model": "claude-sonnet-4-20250514", + "token": "$GH_TOKEN", + "ai_model": "gpt-4.1", "system_prompt": "You are a helpful coding assistant specializing in Typescript and React.", "exclude_tools": [ "write", @@ -187,3 +195,11 @@ You will be able to render the events produced by the session by running: ```bash multipilot render --input log-file.jsonl ``` + +## Contributing + +Contributions are welcome! Please read the [Contributing Guide](./CONTRIBUTING.md) to get started. + +## License + +This project is licensed under the [MIT License](./LICENSE) diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go new file mode 100644 index 0000000..b458766 --- /dev/null +++ b/cmd/helpers_test.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "maps" + "slices" + "testing" + "time" + + "github.com/AstraBert/multipilot/shared" + copilot "github.com/github/copilot-sdk/go" +) + +func compareConfigs(cfg1, cfg2 *shared.CopilotTasks) bool { + if (cfg1 == nil) != (cfg2 == nil) { + return false + } + if cfg1 == nil && cfg2 == nil { + return true + } + if len(cfg1.Tasks) != len(cfg2.Tasks) { + return false + } + for i := range cfg1.Tasks { + // compare comparables + if cfg1.Tasks[i].LogFile != cfg2.Tasks[i].LogFile || cfg1.Tasks[i].Cwd != cfg2.Tasks[i].Cwd || cfg1.Tasks[i].LogLevel != cfg2.Tasks[i].LogLevel || cfg1.Tasks[i].Prompt != cfg2.Tasks[i].Prompt || cfg1.Tasks[i].AiModel != cfg2.Tasks[i].AiModel || cfg1.Tasks[i].SystemPrompt != cfg2.Tasks[i].SystemPrompt || cfg1.Tasks[i].Timeout != cfg2.Tasks[i].Timeout { + return false + } + } + return true +} + +func TestReadConfigToTasks(t *testing.T) { + testCases := []struct { + configFile string + expectedError bool + validationError string + expectedConfig *shared.CopilotTasks + }{ + { + configFile: "../testfiles/configs/correct.json", + expectedError: false, + validationError: "", + expectedConfig: &shared.CopilotTasks{ + Tasks: []shared.CopilotInput{ + { + LogFile: "copilot-session-multipilot.jsonl", + Cwd: "/Users/user/code-projects/multipilot", + LogLevel: "info", + Env: []string{}, + Prompt: "What is the workflow engine that the current project is using?", + GitHubToken: "$GITHUB_TOKEN", + AiModel: "gpt-4.1", + SystemPrompt: "You are a helpful assistant that performs exploratory tasks within Go codebases.", + ExcludeTools: []string{"shell(rm)", "write", "shell(rmdir)"}, + Skills: []string{}, + LocalMcpServers: map[string]copilot.MCPLocalServerConfig{}, + RemoteMcpServers: map[string]copilot.MCPRemoteServerConfig{}, + Timeout: 300, + }, + { + LogFile: "copilot-session-workflowsacp.jsonl", + Cwd: "/Users/user/code-projects/workflows-acp", + LogLevel: "info", + Env: []string{}, + Prompt: "What is the workflow engine that the current project is using?", + GitHubToken: "$GITHUB_TOKEN", + AiModel: "gpt-4.1", + SystemPrompt: "You are a helpful assistant that performs exploratory tasks within python codebases managed with uv.", + ExcludeTools: []string{"shell(rm)", "write", "shell(rmdir)"}, + Skills: []string{}, + LocalMcpServers: map[string]copilot.MCPLocalServerConfig{}, + RemoteMcpServers: map[string]copilot.MCPRemoteServerConfig{}, + Timeout: 300, + }, + }, + }, + }, + { + configFile: "../testfiles/configs/invalid.json", + expectedError: true, + validationError: "cannot use the same log file for two or more tasks because of potential race conditions", + expectedConfig: nil, + }, + { + configFile: "../testfiles/configs/notjson.txt", + expectedError: true, + validationError: "", + expectedConfig: nil, + }, + } + + for _, tc := range testCases { + config, err := ReadConfigToTasks(tc.configFile) + if tc.expectedError && err != nil { + if tc.validationError != "" && err.Error() != tc.validationError { + t.Fatalf("Expected a validation error to occur with message %s, got %s", tc.validationError, err.Error()) + } + } else if tc.expectedError && err == nil { + t.Fatal("Expected an error to occur, but got none") + } else if !tc.expectedError && err != nil { + t.Fatalf("Not expecting an error, got %s", err.Error()) + } + if !compareConfigs(config, tc.expectedConfig) { + t.Fatalf("Expected config to be %v, got %v", tc.expectedConfig, config) + } + } +} + +func compareEvents(ev1, ev2 shared.CopilotEvent) bool { + return ev1.ID == ev2.ID && ev1.Type == ev2.Type && maps.Equal(ev1.Data, ev2.Data) && ev1.Timestamp.Equal(ev2.Timestamp) +} + +func TestLoadEvents(t *testing.T) { + testCases := []struct { + logFile string + expectedEvents []shared.CopilotEvent + expectedError bool + }{ + { + logFile: "../testfiles/logs/valid.logs", + expectedEvents: []shared.CopilotEvent{ + { + Timestamp: time.Date(2026, 2, 6, 11, 7, 11, 610000000, time.UTC), + ID: "abd2d4e9-d68b-41b1-9ab2-01ea62e622d6", + Data: map[string]any{ + "turnId": "1", + }, + Type: "assistant.turn_end", + }, + { + Timestamp: time.Date(2026, 2, 6, 11, 7, 11, 610000000, time.UTC), + ID: "503b8270-43a6-4922-8571-dc2b1a5418ae", + Data: map[string]any{}, + Type: "session.idle", + }, + }, + expectedError: false, + }, + { + logFile: "../testfiles/logs/invalid.logs", + expectedEvents: nil, + expectedError: true, + }, + } + for _, tc := range testCases { + events, err := LoadEvents(tc.logFile) + if tc.expectedError && err == nil { + t.Fatal("An error was expected, got none") + } else if !tc.expectedError && err != nil { + t.Fatalf("Not expecting any error, got %s", err.Error()) + } + if events == nil && tc.expectedEvents != nil { + t.Fatal("Expecting events to be a non-null slice") + } else if events != nil && tc.expectedEvents == nil { + t.Fatalf("Expecting events to be a null slice, got %v", events) + } else { + if !slices.EqualFunc(events, tc.expectedEvents, compareEvents) { + t.Fatalf("Expecting events to be %v, got %v", tc.expectedEvents, events) + } + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 171b1b8..367ed59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -124,7 +124,7 @@ func init() { renderCmd.Flags().StringVarP(&fileToRender, "input", "i", "", "File with the JSON log records to render") renderCmd.Flags().IntVarP(&port, "port", "p", 8000, "Port where to serve the rendered logs") renderCmd.Flags().StringVarP(&host, "bind", "b", "0.0.0.0", "Host where to bind the port for logs rendering") - renderCmd.MarkFlagRequired("input") + _ = renderCmd.MarkFlagRequired("input") rootCmd.AddCommand(workerCmd) rootCmd.AddCommand(renderCmd) diff --git a/components/events.templ b/components/events.templ index d8131c1..d9c143d 100644 --- a/components/events.templ +++ b/components/events.templ @@ -5,6 +5,8 @@ import "fmt" import "sort" import "github.com/AstraBert/multipilot/shared" +import "golang.org/x/text/cases" +import "golang.org/x/text/language" var eventTypeColors = map[string]string{ // Assistant events - blue shades @@ -42,10 +44,10 @@ func getEventColor(eventType string) string { func eventTypeToTitle(event shared.CopilotEvent) string { parts := strings.SplitN(event.Type, ".", 2) if len(parts) == 1 { - return strings.ToTitle(event.Type) + return cases.Title(language.English).String(event.Type) } - title := strings.ToTitle(parts[0]) - details := strings.ToTitle(strings.ReplaceAll(parts[1], "_", " ")) + title := cases.Title(language.English).String(parts[0]) + details := cases.Title(language.English).String(strings.ReplaceAll(parts[1], "_", " ")) return fmt.Sprintf("%s - %s", title, details) } diff --git a/components/events_templ.go b/components/events_templ.go index c170fe0..01cf4a9 100644 --- a/components/events_templ.go +++ b/components/events_templ.go @@ -13,6 +13,8 @@ import "fmt" import "sort" import "github.com/AstraBert/multipilot/shared" +import "golang.org/x/text/cases" +import "golang.org/x/text/language" var eventTypeColors = map[string]string{ // Assistant events - blue shades @@ -50,10 +52,10 @@ func getEventColor(eventType string) string { func eventTypeToTitle(event shared.CopilotEvent) string { parts := strings.SplitN(event.Type, ".", 2) if len(parts) == 1 { - return strings.ToTitle(event.Type) + return cases.Title(language.English).String(event.Type) } - title := strings.ToTitle(parts[0]) - details := strings.ToTitle(strings.ReplaceAll(parts[1], "_", " ")) + title := cases.Title(language.English).String(parts[0]) + details := cases.Title(language.English).String(strings.ReplaceAll(parts[1], "_", " ")) return fmt.Sprintf("%s - %s", title, details) } @@ -126,7 +128,7 @@ func EventComponent(events []shared.CopilotEvent) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(eventTypeToTitle(event)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/events.templ`, Line: 76, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/events.templ`, Line: 78, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -139,7 +141,7 @@ func EventComponent(events []shared.CopilotEvent) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(event.Timestamp.Format("15:04:05")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/events.templ`, Line: 78, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/events.templ`, Line: 80, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -152,7 +154,7 @@ func EventComponent(events []shared.CopilotEvent) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(event.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/events.templ`, Line: 82, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/events.templ`, Line: 84, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -175,7 +177,7 @@ func EventComponent(events []shared.CopilotEvent) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/events.templ`, Line: 88, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/events.templ`, Line: 90, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { diff --git a/components/events_test.go b/components/events_test.go new file mode 100644 index 0000000..be0b280 --- /dev/null +++ b/components/events_test.go @@ -0,0 +1,116 @@ +package components + +import ( + "sort" + "testing" + "time" + + "github.com/AstraBert/multipilot/shared" +) + +func TestEventTypeToTitle(t *testing.T) { + testCases := []struct { + event shared.CopilotEvent + expectedTitle string + }{ + { + event: shared.CopilotEvent{Type: "abort"}, + expectedTitle: "Abort", + }, + { + event: shared.CopilotEvent{Type: "assistant.intent"}, + expectedTitle: "Assistant - Intent", + }, + { + event: shared.CopilotEvent{Type: "tool.execution_complete"}, + expectedTitle: "Tool - Execution Complete", + }, + } + for _, tc := range testCases { + title := eventTypeToTitle(tc.event) + if tc.expectedTitle != title { + t.Fatalf("Expected %s to be the title, got %s", tc.expectedTitle, title) + } + } +} + +func TestEventToColor(t *testing.T) { + for c := range eventTypeColors { + color := getEventColor(c) + if eventTypeColors[c] != color { + t.Fatalf("Expected %s, got %s", eventTypeColors[c], color) + } + } + noColor := getEventColor("not.an.event") + if noColor != "bg-gray-100 border-gray-300" { + t.Fatalf("Expected bg-gray-100 border-gray-300, got %s", noColor) + } +} + +func TestEventDataToList(t *testing.T) { + testCases := []struct { + name string + event shared.CopilotEvent + expected []string + }{ + { + name: "single key-value pair", + event: shared.CopilotEvent{ + Data: map[string]any{ + "turnId": "1", + }, + }, + expected: []string{"turnId: 1"}, + }, + { + name: "empty data map", + event: shared.CopilotEvent{ + Data: map[string]any{}, + }, + expected: []string{}, + }, + { + name: "multiple key-value pairs", + event: shared.CopilotEvent{ + Data: map[string]any{ + "status": "completed", + "count": 42, + "valid": true, + }, + }, + expected: []string{"status: completed", "count: 42", "valid: true"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := eventDataToList(tc.event) + + if len(result) != len(tc.expected) { + t.Fatalf("length mismatch: got %d, want %d", len(result), len(tc.expected)) + } + + sort.Strings(result) + sort.Strings(tc.expected) + + for i := range result { + if result[i] != tc.expected[i] { + t.Errorf("element %d mismatch: got %q, want %q", i, result[i], tc.expected[i]) + } + } + }) + } +} + +func TestSortEventsByTimestamp(t *testing.T) { + events := []shared.CopilotEvent{ + {Timestamp: time.Date(2026, time.February, 7, 12, 0, 0, 0, time.UTC)}, + {Timestamp: time.Date(2026, time.February, 7, 11, 59, 0, 0, time.UTC)}, + {Timestamp: time.Date(2026, time.February, 7, 12, 1, 0, 0, time.UTC)}, + {Timestamp: time.Date(2026, time.February, 7, 11, 58, 0, 0, time.UTC)}, + } + sortedEvents := sortEventsByTimestamp(events) + if !sortedEvents[0].Timestamp.Equal(events[3].Timestamp) || !sortedEvents[1].Timestamp.Equal(events[1].Timestamp) || !sortedEvents[2].Timestamp.Equal(events[0].Timestamp) || !sortedEvents[3].Timestamp.Equal(events[2].Timestamp) { + t.Fatalf("Unsorted slice: %v", sortedEvents) + } +} diff --git a/go.mod b/go.mod index 629d599..b45f217 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/github/copilot-sdk/go v0.1.20 github.com/google/uuid v1.6.0 github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.10.0 go.temporal.io/sdk v1.39.0 + golang.org/x/text v0.27.0 ) require ( @@ -24,12 +26,10 @@ require ( github.com/robfig/cron v1.2.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.10.0 // indirect go.temporal.io/api v1.59.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect diff --git a/package.json b/package.json new file mode 100644 index 0000000..2efafbc --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "@cle-does-things/multipilot", + "version": "0.1.0", + "description": "Orchestration layer to run multiple Copilot tasks concurrently.", + "main": "index.js", + "scripts": { + "postinstall": "golang-npm install", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/AstraBert/multipilot.git" + }, + "keywords": [ + "copilot", + "ai-agent", + "temporal-workflows", + "coding" + ], + "author": "Clelia Astra Bertelli", + "license": "MIT", + "bugs": { + "url": "https://github.com/AstraBert/multipilot/issues" + }, + "homepage": "https://github.com/AstraBert/multipilot#readme", + "dependencies": { + "golang-npm": "^0.0.6" + }, + "goBinary": { + "name": "multipilot", + "path": "./bin", + "url": "https://github.com/AstraBert/multipilot/releases/download/v{{version}}/multipilot_{{version}}_{{platform}}_{{arch}}.tar.gz" + } +} diff --git a/shared/models.go b/shared/models.go index 9511297..a7c90a2 100644 --- a/shared/models.go +++ b/shared/models.go @@ -71,7 +71,7 @@ func (c CopilotInput) GetToken() (string, error) { envVar := strings.TrimPrefix(c.GitHubToken, "$") val, ok := os.LookupEnv(envVar) if !ok { - return "", fmt.Errorf("No value associated to environment variable %s", envVar) + return "", fmt.Errorf("no value associated to environment variable %s", envVar) } return val, nil } diff --git a/shared/models_test.go b/shared/models_test.go new file mode 100644 index 0000000..0e9bc18 --- /dev/null +++ b/shared/models_test.go @@ -0,0 +1,257 @@ +package shared + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" +) + +func compareMcpServerKeys(expected, actual map[string]any) bool { + if (expected == nil) != (actual == nil) { + return false + } + if expected == nil { + return true + } + if len(expected) != len(actual) { + return false + } + for k := range expected { + if _, ok := actual[k]; !ok { + return false + } + } + return true +} + +func TestCopilotInput(t *testing.T) { + t.Setenv("GH_TOKEN", "hello") + testCases := []struct { + task CopilotInput + expectedToken string + expectedLogFile string + expectedMcpServers map[string]any + expectedError bool + }{ + { + task: CopilotInput{ + GitHubToken: "ghp_actualtoken123", + LogFile: "/var/log/copilot.log", + }, + expectedToken: "ghp_actualtoken123", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: nil, + expectedError: false, + }, + { + task: CopilotInput{ + GitHubToken: "$GH_TOKEN", + LogFile: "/var/log/copilot.log", + }, + expectedToken: "hello", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: nil, + expectedError: false, + }, + { + task: CopilotInput{ + GitHubToken: "$GITHUB_TOKEN", + LogFile: "/var/log/copilot.log", + }, + expectedToken: "", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: nil, + expectedError: true, + }, + { + task: CopilotInput{ + GitHubToken: "", + LogFile: "/var/log/copilot.log", + }, + expectedToken: "", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: nil, + expectedError: false, + }, + { + task: CopilotInput{ + LogFile: "", + GitHubToken: "ghp_token", + }, + expectedToken: "ghp_token", + expectedLogFile: "", + expectedMcpServers: nil, + expectedError: true, + }, + { + task: CopilotInput{ + LogFile: "/tmp/test.log", + GitHubToken: "ghp_token", + }, + expectedToken: "ghp_token", + expectedLogFile: "/tmp/test.log", + expectedMcpServers: nil, + expectedError: false, + }, + { + task: CopilotInput{ + LogFile: "/var/log/copilot.log", + GitHubToken: "ghp_token", + LocalMcpServers: nil, + RemoteMcpServers: nil, + }, + expectedToken: "ghp_token", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: nil, + expectedError: false, + }, + { + task: CopilotInput{ + LogFile: "/var/log/copilot.log", + GitHubToken: "ghp_token", + LocalMcpServers: map[string]copilot.MCPLocalServerConfig{ + "server1": {}, + }, + RemoteMcpServers: nil, + }, + expectedToken: "ghp_token", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: map[string]any{ + "server1": copilot.MCPLocalServerConfig{}, + }, + expectedError: false, + }, + { + task: CopilotInput{ + LogFile: "/var/log/copilot.log", + GitHubToken: "ghp_token", + LocalMcpServers: nil, + RemoteMcpServers: map[string]copilot.MCPRemoteServerConfig{ + "remote1": {}, + }, + }, + expectedToken: "ghp_token", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: map[string]any{ + "remote1": copilot.MCPRemoteServerConfig{}, + }, + expectedError: false, + }, + { + task: CopilotInput{ + LogFile: "/var/log/copilot.log", + GitHubToken: "ghp_token", + LocalMcpServers: map[string]copilot.MCPLocalServerConfig{ + "local1": {}, + }, + RemoteMcpServers: map[string]copilot.MCPRemoteServerConfig{ + "remote1": {}, + }, + }, + expectedToken: "ghp_token", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: map[string]any{ + "local1": copilot.MCPLocalServerConfig{}, + "remote1": copilot.MCPRemoteServerConfig{}, + }, + expectedError: false, + }, + { + task: CopilotInput{ + LogFile: "/var/log/copilot.log", + GitHubToken: "ghp_token", + LocalMcpServers: map[string]copilot.MCPLocalServerConfig{}, + RemoteMcpServers: map[string]copilot.MCPRemoteServerConfig{}, + }, + expectedToken: "ghp_token", + expectedLogFile: "/var/log/copilot.log", + expectedMcpServers: nil, + expectedError: false, + }, + } + + for _, tc := range testCases { + token, errTok := tc.task.GetToken() + logFile, errLog := tc.task.GetLogFile() + mcpServers := tc.task.GetMcpServers() + if tc.expectedError && tc.expectedToken == "" && errTok == nil { + t.Fatalf("Expected error when getting the token, got none") + } else if tc.expectedError && tc.expectedLogFile == "" && errLog == nil { + t.Fatalf("Expected error when getting the log gile, got none") + } else if !tc.expectedError && (tc.expectedLogFile != logFile || tc.expectedToken != token) { + t.Fatalf("Expected token to be %s and log file to be %s, got %s and %s", tc.expectedToken, tc.expectedLogFile, token, logFile) + } + if !compareMcpServerKeys(mcpServers, tc.expectedMcpServers) { + t.Fatalf("Expected MCP servers %v, got %v", tc.expectedMcpServers, mcpServers) + } + } +} + +func TestCopilotTasks(t *testing.T) { + testCases := []struct { + tasks CopilotTasks + expectedError bool + errorMessage string + }{ + { + tasks: CopilotTasks{ + Tasks: []CopilotInput{ + { + LogFile: "hello.jsonl", + Cwd: "/test/dir", + }, + { + LogFile: "hello1.jsonl", + Cwd: "/test/dir1", + }, + }, + }, + expectedError: false, + errorMessage: "", + }, + { + tasks: CopilotTasks{ + Tasks: []CopilotInput{ + { + LogFile: "hello.jsonl", + Cwd: "/test/dir", + }, + { + LogFile: "hello.jsonl", + Cwd: "/test/dir1", + }, + }, + }, + expectedError: true, + errorMessage: "cannot use the same log file for two or more tasks because of potential race conditions", + }, + { + tasks: CopilotTasks{ + Tasks: []CopilotInput{ + { + LogFile: "hello.jsonl", + Cwd: "/test/dir", + }, + { + LogFile: "hello1.jsonl", + Cwd: "/test/dir", + }, + }, + }, + expectedError: true, + errorMessage: "cannot use the same working directory for mulitple tasks because of potential race conditions", + }, + } + for _, tc := range testCases { + err := tc.tasks.Validate() + if tc.expectedError && err != nil { + if tc.errorMessage != err.Error() { + t.Fatalf("Expected error message to be %s, got %s", tc.errorMessage, err.Error()) + } + } else if tc.expectedError && err == nil { + t.Fatal("Expected an error, but none gotten") + } else if !tc.expectedError && err != nil { + t.Fatalf("No error expected, got %s", err.Error()) + } + } +} diff --git a/testfiles/configs/correct.json b/testfiles/configs/correct.json new file mode 100644 index 0000000..b272597 --- /dev/null +++ b/testfiles/configs/correct.json @@ -0,0 +1,34 @@ +{ + "tasks": [ + { + "log_file": "copilot-session-multipilot.jsonl", + "cwd": "/Users/user/code-projects/multipilot", + "log_level": "info", + "env": [], + "prompt": "What is the workflow engine that the current project is using?", + "token": "$GITHUB_TOKEN", + "ai_model": "gpt-4.1", + "system_prompt": "You are a helpful assistant that performs exploratory tasks within Go codebases.", + "exclude_tools": ["shell(rm)", "write", "shell(rmdir)"], + "skills": [], + "local_mcp_servers": {}, + "remote_mcp_servers": {}, + "timeout_sec": 300 + }, + { + "log_file": "copilot-session-workflowsacp.jsonl", + "cwd": "/Users/user/code-projects/workflows-acp", + "log_level": "info", + "env": [], + "prompt": "What is the workflow engine that the current project is using?", + "token": "$GITHUB_TOKEN", + "ai_model": "gpt-4.1", + "system_prompt": "You are a helpful assistant that performs exploratory tasks within python codebases managed with uv.", + "exclude_tools": ["shell(rm)", "write", "shell(rmdir)"], + "skills": [], + "local_mcp_servers": {}, + "remote_mcp_servers": {}, + "timeout_sec": 300 + } + ] +} diff --git a/testfiles/configs/invalid.json b/testfiles/configs/invalid.json new file mode 100644 index 0000000..179560a --- /dev/null +++ b/testfiles/configs/invalid.json @@ -0,0 +1,34 @@ +{ + "tasks": [ + { + "log_file": "copilot-session-multipilot.jsonl", + "cwd": "/Users/user/code-projects/multipilot", + "log_level": "info", + "env": [], + "prompt": "What is the workflow engine that the current project is using?", + "token": "$GITHUB_TOKEN", + "ai_model": "gpt-4.1", + "system_prompt": "You are a helpful assistant that performs exploratory tasks within Go codebases.", + "exclude_tools": ["shell(rm)", "write", "shell(rmdir)"], + "skills": [], + "local_mcp_servers": {}, + "remote_mcp_servers": {}, + "timeout_sec": 300 + }, + { + "log_file": "copilot-session-multipilot.jsonl", + "cwd": "/Users/user/code-projects/workflows-acp", + "log_level": "info", + "env": [], + "prompt": "What is the workflow engine that the current project is using?", + "token": "$GITHUB_TOKEN", + "ai_model": "gpt-4.1", + "system_prompt": "You are a helpful assistant that performs exploratory tasks within python codebases managed with uv.", + "exclude_tools": ["shell(rm)", "write", "shell(rmdir)"], + "skills": [], + "local_mcp_servers": {}, + "remote_mcp_servers": {}, + "timeout_sec": 300 + } + ] +} diff --git a/testfiles/configs/nojson.txt b/testfiles/configs/nojson.txt new file mode 100644 index 0000000..cd08755 --- /dev/null +++ b/testfiles/configs/nojson.txt @@ -0,0 +1 @@ +Hello world! diff --git a/testfiles/logs/invalid.logs b/testfiles/logs/invalid.logs new file mode 100644 index 0000000..ef47464 --- /dev/null +++ b/testfiles/logs/invalid.logs @@ -0,0 +1,2 @@ +INFO - this is an event +DEBUG - that is another even diff --git a/testfiles/logs/valid.logs b/testfiles/logs/valid.logs new file mode 100644 index 0000000..dba2eaa --- /dev/null +++ b/testfiles/logs/valid.logs @@ -0,0 +1,2 @@ +{"timestamp":"2026-02-06T11:07:11.61Z","id":"abd2d4e9-d68b-41b1-9ab2-01ea62e622d6","data":{"turnId":"1"},"type":"assistant.turn_end"} +{"timestamp":"2026-02-06T11:07:11.61Z","id":"503b8270-43a6-4922-8571-dc2b1a5418ae","data":{},"type":"session.idle"} diff --git a/workflow/activities.go b/workflow/activities.go index 11a993e..fccae51 100644 --- a/workflow/activities.go +++ b/workflow/activities.go @@ -27,7 +27,7 @@ func RunCopilot(ctx context.Context, task shared.CopilotInput) error { } client := copilot.NewClient(options) if err := client.Start(); err != nil { - return fmt.Errorf("An error occurred while starting the client: %s", err.Error()) + return fmt.Errorf("an error occurred while starting the client: %s", err.Error()) } defer client.Stop() @@ -74,7 +74,7 @@ func RunCopilot(ctx context.Context, task shared.CopilotInput) error { }) if err != nil { - return fmt.Errorf("An error occurred while creating a new session: %s", err.Error()) + return fmt.Errorf("an error occurred while creating a new session: %s", err.Error()) } seenIds := make(map[string]int8) @@ -151,75 +151,3 @@ func serializeEvent(event copilot.SessionEvent) (string, error) { } return string(serialized), nil } - -// func dataToString(data copilot.Data) (string, error) { -// content, err := json.Marshal(data) -// if err != nil { -// return "", err -// } -// var v map[string]any -// err = json.Unmarshal(content, &v) -// if err != nil { -// return "", err -// } -// ls := make([]string, 0, len(v)) -// for k := range v { -// if v[k] != nil { -// s := fmt.Sprintf("%s: %v", k, v[k]) -// ls = append(ls, s) -// } -// } -// return strings.Join(ls, "; "), nil -// } - -// func eventToLog(event copilot.SessionEvent) (string, error) { -// now := time.Now().Format(time.RFC1123) -// data, err := dataToString(event.Data) -// if err != nil { -// return "", nil -// } -// switch event.Type { -// // Assistant events -// case copilot.AssistantIntent: -// return fmt.Sprintf("%s - Assistant Intent: %s", now, data), nil -// case copilot.AssistantMessage: -// return fmt.Sprintf("%s - Assistant Message: %s", now, data), nil -// case copilot.AssistantMessageDelta: -// return fmt.Sprintf("%s - Assistant Message Delta: %s", now, data), nil -// case copilot.AssistantReasoning: -// return fmt.Sprintf("%s - Assistant Reasoning: %s", now, data), nil -// case copilot.AssistantReasoningDelta: -// return fmt.Sprintf("%s - Assistant Reasoning Delta: %s", now, data), nil -// case copilot.AssistantTurnStart: -// return fmt.Sprintf("%s - Assistant Turn Started", now), nil -// case copilot.AssistantTurnEnd: -// return fmt.Sprintf("%s - Assistant Turn Ended", now), nil -// case copilot.AssistantUsage: -// return fmt.Sprintf("%s - Assistant Usage: %s", now, data), nil - -// // Tool execution events -// case copilot.ToolExecutionStart: -// return fmt.Sprintf("%s - Tool Execution Started: %s", now, data), nil -// case copilot.ToolExecutionProgress: -// return fmt.Sprintf("%s - Tool Execution Progress: %s", now, data), nil -// case copilot.ToolExecutionPartialResult: -// return fmt.Sprintf("%s - Tool Execution Partial Result: %s", now, data), nil -// case copilot.ToolExecutionComplete: -// return fmt.Sprintf("%s - Tool Execution Complete: %s", now, data), nil -// case copilot.ToolUserRequested: -// return fmt.Sprintf("%s - Tool User Requested: %s", now, data), nil - -// // User events -// case copilot.UserMessage: -// return fmt.Sprintf("%s - User Message: %s", now, data), nil - -// // Error and abort events -// case copilot.SessionError: -// return fmt.Sprintf("%s - Session Error: %s", now, data), nil -// case copilot.Abort: -// return fmt.Sprintf("%s - Aborted: %s", now, data), nil - -// default: -// return fmt.Sprintf("Event [%s]: %s", event.Type, data), nil -// } -// } diff --git a/workflow/activities_test.go b/workflow/activities_test.go new file mode 100644 index 0000000..d8fef45 --- /dev/null +++ b/workflow/activities_test.go @@ -0,0 +1,48 @@ +package workflow + +import ( + "encoding/json" + "testing" + "time" + + "github.com/AstraBert/multipilot/shared" + copilot "github.com/github/copilot-sdk/go" +) + +func TestSerializeEvent(t *testing.T) { + content := "hello" + event := copilot.SessionEvent{ + Ephemeral: nil, + ID: "123", + ParentID: nil, + Timestamp: time.Date(2026, time.February, 7, 12, 0, 0, 0, time.UTC), + Type: "assistant.intent", + Data: copilot.Data{ + Content: &content, + CopilotVersion: nil, + }, + } + result, err := serializeEvent(event) + if err != nil { + t.Fatalf("Not expecting an error, got %s", err.Error()) + } + var transformed shared.CopilotEvent + err = json.Unmarshal([]byte(result), &transformed) + if err != nil { + t.Fatalf("Not expecting an error, got %s", err.Error()) + } + if transformed.ID != "123" || !transformed.Timestamp.Equal(time.Date(2026, time.February, 7, 12, 0, 0, 0, time.UTC)) || transformed.Type != "assistant.intent" { + t.Fatal("One or more fields of CopilotEvent do not match the original SessionEvent") + } + val, ok := transformed.Data["content"] + if !ok { + t.Fatal("Expected 'content' to be in the transformed event data, but it is not") + } + if val != content { + t.Fatalf("Expected %s as content, got %v", content, val) + } + _, ok = transformed.Data["copilotVersion"] + if ok { + t.Fatal("Expected copilotVersion not to be in the transformed event data because is null, but it is") + } +} diff --git a/workflow/workflow_test.go b/workflow/workflow_test.go new file mode 100644 index 0000000..261f993 --- /dev/null +++ b/workflow/workflow_test.go @@ -0,0 +1,68 @@ +package workflow + +import ( + "context" + "errors" + "testing" + + "github.com/AstraBert/multipilot/shared" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type UnitTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + + env *testsuite.TestWorkflowEnvironment +} + +func (s *UnitTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *UnitTestSuite) AfterTest(suiteName, testName string) { + s.env.AssertExpectations(s.T()) +} + +func TestUnitTestSuite(t *testing.T) { + suite.Run(t, new(UnitTestSuite)) +} + +func (s *UnitTestSuite) Test_CopilotWorkflow_RunCopilotFails() { + s.env.OnActivity(RunCopilot, mock.Anything, mock.Anything).Return(errors.New("activity failure")) + s.env.ExecuteWorkflow(CopilotWorkflow, shared.CopilotInput{LogFile: "hello.jsonl"}) + + s.True(s.env.IsWorkflowCompleted()) + + err := s.env.GetWorkflowError() + s.Error(err) + var applicationErr *temporal.ApplicationError + s.True(errors.As(err, &applicationErr)) + s.Equal("activity failure", applicationErr.Error()) +} + +func (s *UnitTestSuite) Test_CopilotWorkflow_RunCopilotSuccess() { + s.env.OnActivity(RunCopilot, mock.Anything, mock.Anything).Return(nil) + s.env.ExecuteWorkflow(CopilotWorkflow, shared.CopilotInput{LogFile: "hello.jsonl"}) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +func (s *UnitTestSuite) Test_CopilotWorkflow_CorrectParam() { + s.env.OnActivity(RunCopilot, mock.Anything, mock.Anything).Return( + func(ctx context.Context, inpt shared.CopilotInput) error { + s.Equal("hello.jsonl", inpt.LogFile) + s.Equal("/test/hello", inpt.Cwd) + s.Equal("Say hello and exit", inpt.Prompt) + s.Equal("gpt-5.1", inpt.AiModel) + return nil + }) + s.env.ExecuteWorkflow(CopilotWorkflow, shared.CopilotInput{LogFile: "hello.jsonl", Cwd: "/test/hello", Prompt: "Say hello and exit", AiModel: "gpt-5.1"}) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +}