diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0ad58bb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: Test + +on: + pull_request: + branches: + - main + - master + push: + branches: + - main + - master + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Context + uses: okteto/context@latest + with: + url: ${{ secrets.OKTETO_URL }} + token: ${{ secrets.OKTETO_TOKEN }} + + - name: Run tests with Okteto + uses: okteto/test@latest + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage.out + if-no-files-found: warn + + build: + name: Validate Dockerfile + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f73571e --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries +message +*.exe +*.dll +*.so +*.dylib + +# Test artifacts +coverage.out +*.test + +# Build artifacts +*.o +*.a + +# Temporary files +verify_behavior.sh diff --git a/Dockerfile b/Dockerfile index 6341674..502a2e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,20 +5,20 @@ RUN curl -L https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux64 > chmod +x /usr/bin/jq WORKDIR /app -COPY go.mod . +COPY go.mod go.sum ./ +RUN go mod download COPY message.go . RUN go build -o /message . +FROM debian:bookworm-slim -FROM ruby:3-slim-buster +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -RUN gem install octokit:10.0.0 faraday-retry:2.3.2 - -COPY notify-pr.sh /notify-pr.sh -RUN chmod +x /notify-pr.sh COPY --from=message-builder /usr/bin/jq /usr/bin/jq COPY entrypoint.sh /entrypoint.sh COPY --from=message-builder /message /message COPY --from=okteto /usr/local/bin/okteto /usr/local/bin/okteto +RUN chmod +x /entrypoint.sh /message + ENTRYPOINT ["/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index cf8c5aa..32b826f 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -101,11 +101,10 @@ fi if [ -n "$GITHUB_TOKEN" ]; then if [ $ret = 1 ]; then - message=$(/message $name 1) + /message generate $name 1 else - message=$(/message $name 0) + /message generate $name 0 fi - /notify-pr.sh "$message" $GITHUB_TOKEN $name fi if [ $ret = 1 ]; then diff --git a/go.mod b/go.mod index 9669a37..b36cf77 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,16 @@ module github.com/okteto/deploy-preview go 1.24.1 + +require ( + github.com/google/go-github/v58 v58.0.0 + golang.org/x/oauth2 v0.16.0 +) + +require ( + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + golang.org/x/net v0.20.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b95a34 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v58 v58.0.0 h1:Una7GGERlF/37XfkPwpzYJe0Vp4dt2k1kCjlxwjIvzw= +github.com/google/go-github/v58 v58.0.0/go.mod h1:k4hxDKEfoWpSqFlc8LTpGd9fu2KrV1YAa6Hi6FmDNY4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/message.go b/message.go index dbfe1c6..4796f0a 100755 --- a/message.go +++ b/message.go @@ -1,62 +1,247 @@ package main import ( + "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" + "strings" + + "github.com/google/go-github/v58/github" + "golang.org/x/oauth2" ) type contexts struct { - Current string `json:"current-context"` - Contexts map[string]context `json:"contexts"` + Current string `json:"current-context"` + Contexts map[string]oktetoContext `json:"contexts"` } -type context struct { +type oktetoContext struct { Name string `json:"name"` } -// Endpoint represents an Okteto statefulset +// Endpoint represents an Okteto endpoint type Endpoint struct { URL string `json:"url"` Divert bool `json:"divert"` Private bool `json:"private"` } +// GitHubEvent represents the GitHub event payload +type GitHubEvent struct { + Number int `json:"number"` + ClientPayload *ClientPayload `json:"client_payload,omitempty"` +} + +// ClientPayload represents the client_payload in repository_dispatch events +type ClientPayload struct { + PullRequest *PullRequestPayload `json:"pull_request,omitempty"` +} + +// PullRequestPayload represents the pull request info in client_payload +type PullRequestPayload struct { + Number int `json:"number"` +} + func main() { - previewName := os.Args[1] - previewCommandExitCode := os.Args[2] + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + command := os.Args[1] + + switch command { + case "generate": + handleGenerate() + case "notify": + handleNotify() + default: + fmt.Printf("Unknown command: %s\n\n", command) + printUsage() + os.Exit(1) + } +} + +func printUsage() { + fmt.Println("Usage:") + fmt.Println(" message generate ") + fmt.Println(" message notify ") +} + +func handleGenerate() { + if len(os.Args) < 4 { + fmt.Println("Usage: message generate ") + os.Exit(1) + } + + previewName := os.Args[2] + exitCode := os.Args[3] + message, err := generateMessage(previewName, exitCode) + if err != nil { + fmt.Printf("Error generating message: %v\n", err) + os.Exit(1) + } + fmt.Println(message) + + // If GITHUB_TOKEN is set, automatically notify the PR + githubToken := os.Getenv("GITHUB_TOKEN") + if githubToken != "" { + if err := notifyPR(message, githubToken, previewName); err != nil { + fmt.Printf("Error notifying PR: %v\n", err) + os.Exit(1) + } + } +} + +func handleNotify() { + if len(os.Args) < 5 { + fmt.Println("Usage: message notify ") + os.Exit(1) + } + + message := os.Args[2] + githubToken := os.Args[3] + previewName := os.Args[4] + + if err := notifyPR(message, githubToken, previewName); err != nil { + fmt.Printf("Error notifying PR: %v\n", err) + os.Exit(1) + } +} + +func generateMessage(previewName, exitCode string) (string, error) { oktetoURL, err := getOktetoURL() if err != nil { - return + return "", err } previewURL := fmt.Sprintf("%s/previews/%s", oktetoURL, previewName) - var firstLine string - if previewCommandExitCode == "0" { - firstLine = fmt.Sprintf("Your preview environment [%s](%s) has been deployed.", previewName, previewURL) + var message strings.Builder + if exitCode == "0" { + message.WriteString(fmt.Sprintf("Your preview environment [%s](%s) has been deployed.", previewName, previewURL)) } else { - firstLine = fmt.Sprintf("Your preview environment [%s](%s) has been deployed with errors.", previewName, previewURL) + message.WriteString(fmt.Sprintf("Your preview environment [%s](%s) has been deployed with errors.", previewName, previewURL)) } - fmt.Println(firstLine) endpoints, err := getEndpoints(previewName) if err != nil { - return + return message.String(), nil } + if len(endpoints) == 1 { - fmt.Printf("\n Preview environment endpoint is available [here](%s)", endpoints[0]) + message.WriteString(fmt.Sprintf("\n Preview environment endpoint is available [here](%s)", endpoints[0])) } else if len(endpoints) > 1 { endpoints = translateEndpoints(endpoints) - fmt.Printf("\n Preview environment endpoints are available at:") + message.WriteString("\n Preview environment endpoints are available at:") for _, endpoint := range endpoints { - fmt.Printf("\n * %s", endpoint) + message.WriteString(fmt.Sprintf("\n * %s", endpoint)) + } + } + + return message.String(), nil +} + +func notifyPR(message, githubToken, previewName string) error { + eventName := os.Getenv("GITHUB_EVENT_NAME") + if eventName != "pull_request" && eventName != "repository_dispatch" { + return fmt.Errorf("this action only supports either pull_request or repository_dispatch events") + } + + if githubToken == "" { + return fmt.Errorf("missing GITHUB_TOKEN") + } + + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return fmt.Errorf("missing GITHUB_REPOSITORY") + } + + prNumber, err := getPRNumber() + if err != nil { + return err + } + + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: githubToken}, + ) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + repoParts := strings.Split(repo, "/") + if len(repoParts) != 2 { + return fmt.Errorf("invalid GITHUB_REPOSITORY format: %s", repo) + } + owner := repoParts[0] + repoName := repoParts[1] + + // List all comments on the PR + comments, _, err := client.Issues.ListComments(ctx, owner, repoName, prNumber, nil) + if err != nil { + return fmt.Errorf("error listing comments: %w", err) + } + + // Find existing comment + var existingComment *github.IssueComment + markerText := fmt.Sprintf("Your preview environment") + previewMarker := fmt.Sprintf("[%s]", previewName) + + for _, comment := range comments { + if comment.Body != nil && + strings.HasPrefix(*comment.Body, markerText) && + strings.Contains(*comment.Body, previewMarker) { + existingComment = comment + break + } + } + + if existingComment != nil { + fmt.Println("Message already exists in the PR. Updating") + _, _, err = client.Issues.EditComment(ctx, owner, repoName, *existingComment.ID, &github.IssueComment{ + Body: &message, + }) + return err + } + + // Create new comment + _, _, err = client.Issues.CreateComment(ctx, owner, repoName, prNumber, &github.IssueComment{ + Body: &message, + }) + return err +} + +func getPRNumber() (int, error) { + eventPath := os.Getenv("GITHUB_EVENT_PATH") + if eventPath == "" { + return 0, fmt.Errorf("missing GITHUB_EVENT_PATH") + } + + data, err := os.ReadFile(eventPath) + if err != nil { + return 0, fmt.Errorf("error reading event file: %w", err) + } + + var event GitHubEvent + if err := json.Unmarshal(data, &event); err != nil { + return 0, fmt.Errorf("error parsing event JSON: %w", err) + } + + eventName := os.Getenv("GITHUB_EVENT_NAME") + if eventName == "pull_request" { + return event.Number, nil + } else if eventName == "repository_dispatch" { + if event.ClientPayload != nil && event.ClientPayload.PullRequest != nil { + return event.ClientPayload.PullRequest.Number, nil } + return 0, fmt.Errorf("missing pull request number in repository_dispatch event") } + return 0, fmt.Errorf("unsupported event type: %s", eventName) } func getOktetoURL() (string, error) { diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..e4840a7 --- /dev/null +++ b/message_test.go @@ -0,0 +1,474 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateMessage(t *testing.T) { + tests := []struct { + name string + previewName string + exitCode string + setupContext func(t *testing.T) func() + wantContains []string + }{ + { + name: "successful deployment", + previewName: "test-preview", + exitCode: "0", + setupContext: func(t *testing.T) func() { + return setupOktetoContext(t, "https://okteto.example.com") + }, + wantContains: []string{ + "Your preview environment [test-preview]", + "has been deployed.", + "https://okteto.example.com/previews/test-preview", + }, + }, + { + name: "deployment with errors", + previewName: "test-preview-error", + exitCode: "1", + setupContext: func(t *testing.T) func() { + return setupOktetoContext(t, "https://okteto.dev") + }, + wantContains: []string{ + "Your preview environment [test-preview-error]", + "has been deployed with errors.", + "https://okteto.dev/previews/test-preview-error", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := tt.setupContext(t) + defer cleanup() + + got, err := generateMessage(tt.previewName, tt.exitCode) + if err != nil { + t.Fatalf("generateMessage() error = %v", err) + } + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("generateMessage() = %v, want to contain %v", got, want) + } + } + }) + } +} + +func TestGetPRNumber(t *testing.T) { + tests := []struct { + name string + eventName string + eventData interface{} + want int + wantErr bool + errContains string + }{ + { + name: "pull_request event", + eventName: "pull_request", + eventData: map[string]interface{}{ + "number": 123, + }, + want: 123, + wantErr: false, + }, + { + name: "repository_dispatch event", + eventName: "repository_dispatch", + eventData: map[string]interface{}{ + "client_payload": map[string]interface{}{ + "pull_request": map[string]interface{}{ + "number": 456, + }, + }, + }, + want: 456, + wantErr: false, + }, + { + name: "repository_dispatch without PR number", + eventName: "repository_dispatch", + eventData: map[string]interface{}{}, + wantErr: true, + errContains: "missing pull request number", + }, + { + name: "unsupported event type", + eventName: "push", + eventData: map[string]interface{}{}, + wantErr: true, + errContains: "unsupported event type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + eventPath := filepath.Join(tmpDir, "event.json") + + data, err := json.Marshal(tt.eventData) + if err != nil { + t.Fatalf("Failed to marshal test event data: %v", err) + } + + if err := os.WriteFile(eventPath, data, 0644); err != nil { + t.Fatalf("Failed to write event file: %v", err) + } + + os.Setenv("GITHUB_EVENT_NAME", tt.eventName) + os.Setenv("GITHUB_EVENT_PATH", eventPath) + defer func() { + os.Unsetenv("GITHUB_EVENT_NAME") + os.Unsetenv("GITHUB_EVENT_PATH") + }() + + got, err := getPRNumber() + if (err != nil) != tt.wantErr { + t.Errorf("getPRNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("getPRNumber() error = %v, want error containing %v", err, tt.errContains) + } + return + } + + if got != tt.want { + t.Errorf("getPRNumber() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetOktetoURL(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) func() + want string + wantErr bool + errContains string + }{ + { + name: "valid context", + setupFunc: func(t *testing.T) func() { + return setupOktetoContext(t, "https://okteto.example.com") + }, + want: "https://okteto.example.com", + wantErr: false, + }, + { + name: "different URL", + setupFunc: func(t *testing.T) func() { + return setupOktetoContext(t, "https://cloud.okteto.com") + }, + want: "https://cloud.okteto.com", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := tt.setupFunc(t) + defer cleanup() + + got, err := getOktetoURL() + if (err != nil) != tt.wantErr { + t.Errorf("getOktetoURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("getOktetoURL() error = %v, want error containing %v", err, tt.errContains) + } + return + } + + if got != tt.want { + t.Errorf("getOktetoURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTranslateEndpoints(t *testing.T) { + tests := []struct { + name string + endpoints []string + want []string + }{ + { + name: "single endpoint", + endpoints: []string{"https://app.example.com"}, + want: []string{"[https://app.example.com](https://app.example.com)"}, + }, + { + name: "multiple endpoints", + endpoints: []string{ + "https://app.example.com", + "https://api.example.com", + }, + want: []string{ + "[https://app.example.com](https://app.example.com)", + "[https://api.example.com](https://api.example.com)", + }, + }, + { + name: "empty list", + endpoints: []string{}, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := translateEndpoints(tt.endpoints) + + if len(got) != len(tt.want) { + t.Errorf("translateEndpoints() returned %d items, want %d", len(got), len(tt.want)) + return + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("translateEndpoints()[%d] = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestNotifyPRValidation(t *testing.T) { + tests := []struct { + name string + setupEnv func() + message string + token string + previewName string + wantErr bool + errContains string + }{ + { + name: "missing GITHUB_EVENT_NAME", + setupEnv: func() { + os.Unsetenv("GITHUB_EVENT_NAME") + }, + message: "test message", + token: "test-token", + previewName: "test", + wantErr: true, + errContains: "only supports either pull_request or repository_dispatch", + }, + { + name: "invalid event type", + setupEnv: func() { + os.Setenv("GITHUB_EVENT_NAME", "push") + }, + message: "test message", + token: "test-token", + previewName: "test", + wantErr: true, + errContains: "only supports either pull_request or repository_dispatch", + }, + { + name: "missing token", + setupEnv: func() { + os.Setenv("GITHUB_EVENT_NAME", "pull_request") + }, + message: "test message", + token: "", + previewName: "test", + wantErr: true, + errContains: "missing GITHUB_TOKEN", + }, + { + name: "missing repository", + setupEnv: func() { + os.Setenv("GITHUB_EVENT_NAME", "pull_request") + os.Unsetenv("GITHUB_REPOSITORY") + }, + message: "test message", + token: "test-token", + previewName: "test", + wantErr: true, + errContains: "missing GITHUB_REPOSITORY", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupEnv() + defer func() { + os.Unsetenv("GITHUB_EVENT_NAME") + os.Unsetenv("GITHUB_REPOSITORY") + }() + + err := notifyPR(tt.message, tt.token, tt.previewName) + if (err != nil) != tt.wantErr { + t.Errorf("notifyPR() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && tt.errContains != "" { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("notifyPR() error = %v, want error containing %v", err, tt.errContains) + } + } + }) + } +} + +func TestHandleGenerateAutoNotification(t *testing.T) { + tests := []struct { + name string + previewName string + exitCode string + setupContext func(t *testing.T) func() + githubToken string + expectNotify bool + wantErrContains string + }{ + { + name: "generate without token - no notification", + previewName: "test-preview", + exitCode: "0", + setupContext: func(t *testing.T) func() { + return setupOktetoContext(t, "https://okteto.example.com") + }, + githubToken: "", + expectNotify: false, + }, + { + name: "generate with token - should attempt notification", + previewName: "test-preview", + exitCode: "0", + setupContext: func(t *testing.T) func() { + return setupOktetoContext(t, "https://okteto.example.com") + }, + githubToken: "fake-token", + expectNotify: true, + wantErrContains: "only supports either pull_request or repository_dispatch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := tt.setupContext(t) + defer cleanup() + + // Set up environment + if tt.githubToken != "" { + os.Setenv("GITHUB_TOKEN", tt.githubToken) + defer os.Unsetenv("GITHUB_TOKEN") + } else { + os.Unsetenv("GITHUB_TOKEN") + } + + // Test that generateMessage works + msg, err := generateMessage(tt.previewName, tt.exitCode) + if err != nil { + t.Fatalf("generateMessage() error = %v", err) + } + + if !strings.Contains(msg, tt.previewName) { + t.Errorf("Message doesn't contain preview name: %s", msg) + } + + // If we expect notification to be attempted, verify the error + if tt.expectNotify { + err := notifyPR(msg, tt.githubToken, tt.previewName) + if err == nil { + t.Error("Expected error from notifyPR, got nil") + } else if !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("Expected error containing %q, got %q", tt.wantErrContains, err.Error()) + } + } + }) + } +} + +func TestMainSwitchLogic(t *testing.T) { + tests := []struct { + name string + command string + wantErr bool + }{ + { + name: "generate command exists", + command: "generate", + wantErr: false, + }, + { + name: "notify command exists", + command: "notify", + wantErr: false, + }, + { + name: "unknown command", + command: "unknown", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test that the command is recognized in the switch statement + // We can't call main() directly, but we can verify the command names + validCommands := map[string]bool{ + "generate": true, + "notify": true, + } + + isValid := validCommands[tt.command] + if isValid == tt.wantErr { + t.Errorf("Command %q validation incorrect, isValid=%v, wantErr=%v", tt.command, isValid, tt.wantErr) + } + }) + } +} + +// Helper function to setup a mock Okteto context +func setupOktetoContext(t *testing.T, url string) func() { + tmpDir := t.TempDir() + contextDir := filepath.Join(tmpDir, ".okteto", "context") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + contextData := map[string]interface{}{ + "current-context": "test-context", + "contexts": map[string]interface{}{ + "test-context": map[string]interface{}{ + "name": url, + }, + }, + } + + data, err := json.Marshal(contextData) + if err != nil { + t.Fatalf("Failed to marshal context data: %v", err) + } + + configPath := filepath.Join(contextDir, "config.json") + if err := os.WriteFile(configPath, data, 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + + return func() { + os.Setenv("HOME", oldHome) + } +} diff --git a/okteto.yaml b/okteto.yaml new file mode 100644 index 0000000..9471a7a --- /dev/null +++ b/okteto.yaml @@ -0,0 +1,13 @@ +build: + preview-action: + context: . + dockerfile: Dockerfile + +test: + unit: + image: golang:1.24 + commands: + - go mod download + - go test -v -race -coverprofile=coverage.out + artifacts: + - coverage.out