diff --git a/dispatch/dispatch.go b/dispatch/dispatch.go index fedc8a7..022df7d 100644 --- a/dispatch/dispatch.go +++ b/dispatch/dispatch.go @@ -5,6 +5,7 @@ package dispatch import ( "context" + "errors" "fmt" "time" @@ -20,6 +21,13 @@ type Result struct { Error error } +// ErrTimedOut is returned when polling exceeds the maximum number of attempts. +var ErrTimedOut = errors.New("timed out polling for workflow job") + +// ErrUnrepairableState is returned when a workflow run enters a state that +// cannot be recovered from. +var ErrUnrepairableState = errors.New("workflow is in an unrepairable state") + // Options provides a way to define how frequently the GitHub APIs should be // polled for results, as well as the maximum number of attempts before stopping type Options struct { @@ -58,11 +66,11 @@ func WaitRunFinished(client *github.Client, opts Options, run github.WorkflowRun case "in_progress": // Do nothing, keep watching default: - return fmt.Errorf("workflow \"%s\" is in unrepairable state: %s", *run.Name, *this.Status) + return fmt.Errorf("workflow %q entered state %q: %w", *run.Name, *this.Status, ErrUnrepairableState) } } - return fmt.Errorf("timed out polling for workflow job") + return fmt.Errorf("%w", ErrTimedOut) } // FindRun finds the most recent GitHub Actions run matching a given run name. @@ -97,7 +105,7 @@ func FindRun(client *github.Client, opts Options, runName string) (github.Workfl time.Sleep(time.Duration(opts.SecondsBetweenPolls) * time.Second) } - return github.WorkflowRun{}, fmt.Errorf("timed out polling for workflow job") + return github.WorkflowRun{}, fmt.Errorf("%w", ErrTimedOut) } // Worker spawns an instance of a goroutine that listens for new job requests diff --git a/dispatch/dispatch_test.go b/dispatch/dispatch_test.go new file mode 100644 index 0000000..3d56024 --- /dev/null +++ b/dispatch/dispatch_test.go @@ -0,0 +1,363 @@ +// Copyright IBM Corp. 2023, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package dispatch + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-github/v45/github" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupMockServer is a helper function to mock the GitHub API endpoint. +func setupMockServer(t *testing.T) (*github.Client, *http.ServeMux, *httptest.Server) { + t.Helper() + mux := http.NewServeMux() + server := httptest.NewServer(mux) + client := github.NewClient(server.Client()) + u, err := url.Parse(server.URL + "/") + require.NoError(t, err) + client.BaseURL = u + return client, mux, server +} + +func TestWaitRunFinished(t *testing.T) { + baseOpts := Options{ + SecondsBetweenPolls: 0, // 0 speeds up the tests + MaxAttempts: 2, + Logger: hclog.NewNullLogger(), + GitHubOwner: "testOrg", + GitHubRepo: "testRepo", + } + + t.Run("short circuit completed", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + apiCalled := false + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + }) + + run := github.WorkflowRun{ + ID: github.Int64(1), + Name: github.String("test-run"), + Status: github.String("completed"), + } + + err := WaitRunFinished(client, baseOpts, run) + assert.NoError(t, err) + assert.False(t, apiCalled, "expected no GitHub API calls for an already-completed run") + }) + + t.Run("polls and completes", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + run := github.WorkflowRun{ + ID: github.Int64(2), + Name: github.String("test-run"), + Status: github.String("queued"), + } + + calls := 0 + mux.HandleFunc("/repos/testOrg/testRepo/actions/runs/2", func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + if _, err := fmt.Fprint(w, `{"id": 2, "status": "in_progress"}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + } else { + if _, err := fmt.Fprint(w, `{"id": 2, "status": "completed"}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + } + }) + + err := WaitRunFinished(client, baseOpts, run) + assert.NoError(t, err) + assert.Equal(t, 2, calls) + }) + + t.Run("unrepairable state", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + run := github.WorkflowRun{ + ID: github.Int64(3), + Name: github.String("test-run"), + Status: github.String("queued"), + } + + mux.HandleFunc("/repos/testOrg/testRepo/actions/runs/3", func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, `{"id": 3, "status": "failed"}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + }) + + err := WaitRunFinished(client, baseOpts, run) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnrepairableState)) + }) + + t.Run("timeout", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + run := github.WorkflowRun{ + ID: github.Int64(4), + Name: github.String("test-run"), + Status: github.String("queued"), + } + + mux.HandleFunc("/repos/testOrg/testRepo/actions/runs/4", func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, `{"id": 4, "status": "in_progress"}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + }) + + err := WaitRunFinished(client, baseOpts, run) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTimedOut)) + }) + + t.Run("api error", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + run := github.WorkflowRun{ + ID: github.Int64(5), + Name: github.String("test-run"), + Status: github.String("queued"), + } + + mux.HandleFunc("/repos/testOrg/testRepo/actions/runs/5", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + err := WaitRunFinished(client, baseOpts, run) + require.Error(t, err) + var ghErr *github.ErrorResponse + require.ErrorAs(t, err, &ghErr) + assert.Equal(t, http.StatusInternalServerError, ghErr.Response.StatusCode) + }) +} + +func TestFindRun(t *testing.T) { + baseOpts := Options{ + SecondsBetweenPolls: 0, // 0 speeds up the tests + MaxAttempts: 2, + Logger: hclog.NewNullLogger(), + GitHubOwner: "testOrg", + GitHubRepo: "testRepo", + BranchRef: "main", + WorkflowFileName: "test.yml", + } + + t.Run("finds on first attempt", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/runs", func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, `{"workflow_runs": [{"id": 1, "name": "my-run", "status": "queued"}]}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + }) + + run, err := FindRun(client, baseOpts, "my-run") + assert.NoError(t, err) + assert.Equal(t, int64(1), *run.ID) + }) + + t.Run("polls and finds", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + calls := 0 + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/runs", func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + if _, err := fmt.Fprint(w, `{"workflow_runs": []}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + } else { + if _, err := fmt.Fprint(w, `{"workflow_runs": [{"id": 2, "name": "my-run-2", "status": "queued"}]}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + } + }) + + run, err := FindRun(client, baseOpts, "my-run-2") + assert.NoError(t, err) + assert.Equal(t, int64(2), *run.ID) + assert.Equal(t, 2, calls) + }) + + t.Run("timeout", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/runs", func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, `{"workflow_runs": []}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + }) + + _, err := FindRun(client, baseOpts, "my-run-3") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTimedOut)) + }) + + t.Run("api error", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/runs", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + _, err := FindRun(client, baseOpts, "my-run-4") + require.Error(t, err) + var ghErr *github.ErrorResponse + require.ErrorAs(t, err, &ghErr) + assert.Equal(t, http.StatusInternalServerError, ghErr.Response.StatusCode) + }) +} + +func TestWorker(t *testing.T) { + baseOpts := Options{ + SecondsBetweenPolls: 0, + MaxAttempts: 2, + Logger: hclog.NewNullLogger(), + GitHubOwner: "testOrg", + GitHubRepo: "testRepo", + BatchID: "batch123", + WorkflowFileName: "test.yml", + BranchRef: "main", + } + + t.Run("success", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/dispatches", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/runs", func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, `{"workflow_runs": [{"id": 1, "name": "batch123: Audit test-repo", "status": "queued"}]}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + }) + mux.HandleFunc("/repos/testOrg/testRepo/actions/runs/1", func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, `{"id": 1, "status": "completed"}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + }) + + testRun := "test-repo" + jobs := make(chan string, 1) + results := make(chan Result, 1) + jobs <- testRun + close(jobs) + + Worker(client, baseOpts, 1, jobs, results) + + res := <-results + assert.True(t, res.Success) + assert.NoError(t, res.Error) + assert.Equal(t, testRun, res.Name) + }) + + t.Run("dispatch fails", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/dispatches", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + jobs := make(chan string, 1) + results := make(chan Result, 1) + testRun := "test-repo" + jobs <- testRun + close(jobs) + + Worker(client, baseOpts, 1, jobs, results) + + res := <-results + assert.False(t, res.Success) + assert.Error(t, res.Error) + assert.Equal(t, testRun, res.Name) + }) + + t.Run("find fails", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + // Dispatch works, but the run search throws an error + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/dispatches", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/runs", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + jobs := make(chan string, 1) + results := make(chan Result, 1) + jobs <- "test-repo" + close(jobs) + + Worker(client, baseOpts, 1, jobs, results) + + res := <-results + require.False(t, res.Success) + require.Error(t, res.Error) + var ghErr *github.ErrorResponse + require.ErrorAs(t, res.Error, &ghErr) + assert.Equal(t, http.StatusInternalServerError, ghErr.Response.StatusCode) + }) + + t.Run("wait fails with unrepairable state", func(t *testing.T) { + client, mux, server := setupMockServer(t) + defer server.Close() + + // Dispatch succeeds + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/dispatches", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + // FindRun succeeds + mux.HandleFunc("/repos/testOrg/testRepo/actions/workflows/test.yml/runs", func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, `{"workflow_runs": [{"id": 10, "name": "batch123: Audit test-repo", "status": "queued"}]}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + }) + // WaitRunFinished hits an unrepairable state + mux.HandleFunc("/repos/testOrg/testRepo/actions/runs/10", func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, `{"id": 10, "status": "failed"}`); err != nil { + t.Errorf("mock write failed: %v", err) + } + }) + + jobs := make(chan string, 1) + results := make(chan Result, 1) + testRun := "test-repo" + jobs <- testRun + close(jobs) + + Worker(client, baseOpts, 1, jobs, results) + + res := <-results + require.False(t, res.Success) + require.Error(t, res.Error) + assert.True(t, errors.Is(res.Error, ErrUnrepairableState)) + assert.Equal(t, testRun, res.Name) + }) +} diff --git a/github/client_test.go b/github/client_test.go new file mode 100644 index 0000000..973c5c0 --- /dev/null +++ b/github/client_test.go @@ -0,0 +1,404 @@ +// Copyright IBM Corp. 2023, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package github + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mitchellh/go-homedir" + "golang.org/x/oauth2" +) + +// clearAppEnv blanks the three GitHub App env vars so tests start clean. +func clearAppEnv(t *testing.T) { + t.Helper() + t.Setenv("APP_ID", "") + t.Setenv("INSTALLATION_ID", "") + t.Setenv("APP_PEM", "") +} + +// disableHomedirCache disables go-homedir's cache for the duration of a test. +func disableHomedirCache(t *testing.T) { + t.Helper() + homedir.DisableCache = true + t.Cleanup(func() { homedir.DisableCache = false }) +} + +// unsetGitHubToken removes GITHUB_TOKEN for the duration of a test and +// restores it (or leaves it unset) when the test ends. +func unsetGitHubToken(t *testing.T) { + t.Helper() + prev, had := os.LookupEnv("GITHUB_TOKEN") + require.NoError(t, os.Unsetenv("GITHUB_TOKEN")) + t.Cleanup(func() { + if had { + if err := os.Setenv("GITHUB_TOKEN", prev); err != nil { + t.Errorf("cleanup: failed to restore GITHUB_TOKEN: %v", err) + } + } else { + if err := os.Unsetenv("GITHUB_TOKEN"); err != nil { + t.Errorf("cleanup: failed to unset GITHUB_TOKEN: %v", err) + } + } + }) +} + +func TestGHClient_Raw(t *testing.T) { + client := NewGHClient() + require.NotNil(t, client) + assert.NotNil(t, client.Raw()) +} + +func TestGetGHAppConfig(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + wantExists bool + wantAppID int64 + wantInstID int64 + wantPEM string + }{ + { + name: "no config present", + envVars: map[string]string{}, + wantExists: false, + }, + { + name: "only APP_ID set", + envVars: map[string]string{ + "APP_ID": "12345", + }, + wantExists: false, + }, + { + name: "only INSTALLATION_ID set", + envVars: map[string]string{ + "INSTALLATION_ID": "67890", + }, + wantExists: false, + }, + { + name: "only APP_PEM set", + envVars: map[string]string{ + "APP_PEM": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + wantExists: false, + }, + { + name: "APP_ID and INSTALLATION_ID set but no PEM", + envVars: map[string]string{ + "APP_ID": "12345", + "INSTALLATION_ID": "67890", + }, + wantExists: false, + }, + { + name: "APP_ID and APP_PEM set but no INSTALLATION_ID", + envVars: map[string]string{ + "APP_ID": "12345", + "APP_PEM": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + wantExists: false, + }, + { + name: "all three set - config exists", + envVars: map[string]string{ + "APP_ID": "12345", + "INSTALLATION_ID": "67890", + "APP_PEM": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + wantExists: true, + wantAppID: 12345, + wantInstID: 67890, + wantPEM: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + { + name: "APP_PEM with escaped newlines", + envVars: map[string]string{ + "APP_ID": "11111", + "INSTALLATION_ID": "22222", + "APP_PEM": "-----BEGIN RSA PRIVATE KEY-----\\nMIIE\\ntest\\n-----END RSA PRIVATE KEY-----", + }, + wantExists: true, + wantAppID: 11111, + wantInstID: 22222, + wantPEM: "-----BEGIN RSA PRIVATE KEY-----\nMIIE\ntest\n-----END RSA PRIVATE KEY-----", + }, + { + name: "APP_ID is zero (invalid)", + envVars: map[string]string{ + "APP_ID": "0", + "INSTALLATION_ID": "67890", + "APP_PEM": "some-pem-data", + }, + wantExists: false, + }, + { + name: "INSTALLATION_ID is zero (invalid)", + envVars: map[string]string{ + "APP_ID": "12345", + "INSTALLATION_ID": "0", + "APP_PEM": "some-pem-data", + }, + wantExists: false, + }, + { + name: "APP_PEM is empty string", + envVars: map[string]string{ + "APP_ID": "12345", + "INSTALLATION_ID": "67890", + "APP_PEM": "", + }, + wantExists: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Clean env before each test + t.Setenv("APP_ID", "") + t.Setenv("INSTALLATION_ID", "") + t.Setenv("APP_PEM", "") + + for k, v := range tc.envVars { + t.Setenv(k, v) + } + + cc, exists := getGHAppConfig() + assert.Equal(t, tc.wantExists, exists) + + if tc.wantExists { + assert.Equal(t, tc.wantAppID, cc.appID) + assert.Equal(t, tc.wantInstID, cc.instID) + assert.Equal(t, tc.wantPEM, cc.appPEM) + } + }) + } +} + +// NOTE: This test mutates homedir.DisableCache (global state) +// and must NOT use t.Parallel(). +func TestGetGitHubCLIConfig(t *testing.T) { + disableHomedirCache(t) + tests := []struct { + name string + configData string + wantToken string + wantExists bool + setupHome bool + noConfigDir bool + }{ + { + name: "valid config with oauth_token", + configData: `github.com: + user: octocat + oauth_token: gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + git_protocol: https +`, + wantToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + wantExists: true, + setupHome: true, + }, + { + name: "config without oauth_token", + configData: `github.com: + user: octocat + git_protocol: https +`, + wantToken: "", + wantExists: false, + setupHome: true, + }, + { + name: "empty config file", + configData: "", + wantToken: "", + wantExists: false, + setupHome: true, + }, + { + name: "config with different host only", + configData: `github.enterprise.com: + user: octocat + oauth_token: gho_enterprise_token + git_protocol: https +`, + wantToken: "", + wantExists: false, + setupHome: true, + }, + { + name: "no config directory exists", + configData: "", + wantToken: "", + wantExists: false, + setupHome: true, + noConfigDir: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.setupHome { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + if !tc.noConfigDir { + configDir := filepath.Join(tmpHome, ".config", "gh") + err := os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + configPath := filepath.Join(configDir, "hosts.yml") + err = os.WriteFile(configPath, []byte(tc.configData), 0644) + require.NoError(t, err) + } + } + + token, exists := getGitHubCLIConfig() + assert.Equal(t, tc.wantExists, exists) + assert.Equal(t, tc.wantToken, token) + }) + } +} + +func TestNewGHClient_WithGitHubToken(t *testing.T) { + clearAppEnv(t) + + t.Setenv("GITHUB_TOKEN", "ghp_test_token_12345") + + client := NewGHClient() + require.NotNil(t, client) + assert.NotNil(t, client.gh) + assert.NotNil(t, client.Raw()) + _, ok := client.Raw().Client().Transport.(*oauth2.Transport) + assert.True(t, ok, "expected oauth2 transport for GITHUB_TOKEN client") +} + +func TestNewGHClient_UnauthenticatedFallback(t *testing.T) { + disableHomedirCache(t) + clearAppEnv(t) + // Ensure no GITHUB_TOKEN — must unset, not empty, because NewGHClient uses os.LookupEnv + unsetGitHubToken(t) + + // Set HOME to a temp dir with no gh config + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + client := NewGHClient() + require.NotNil(t, client) + assert.NotNil(t, client.gh) + assert.NotNil(t, client.Raw()) + assert.Nil(t, client.Raw().Client().Transport, "expected unauthenticated client: transport should be nil") +} + +func TestNewGHClient_WithGHCLIConfig(t *testing.T) { + disableHomedirCache(t) + clearAppEnv(t) + // Ensure no GITHUB_TOKEN — must unset, not empty, because NewGHClient uses os.LookupEnv + unsetGitHubToken(t) + + // Set up gh CLI config + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + configDir := filepath.Join(tmpHome, ".config", "gh") + err := os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + configData := `github.com: + user: testuser + oauth_token: gho_testtoken123456 + git_protocol: https +` + err = os.WriteFile(filepath.Join(configDir, "hosts.yml"), []byte(configData), 0644) + require.NoError(t, err) + + client := NewGHClient() + require.NotNil(t, client) + assert.NotNil(t, client.gh) + assert.NotNil(t, client.Raw()) + _, ok := client.Raw().Client().Transport.(*oauth2.Transport) + assert.True(t, ok, "expected oauth2 transport for gh CLI config client") +} + +func TestNewGHClient_WithInvalidGHAppConfig(t *testing.T) { + // Set invalid PEM to trigger the ghinstallation error path + t.Setenv("APP_ID", "12345") + t.Setenv("INSTALLATION_ID", "67890") + t.Setenv("APP_PEM", "invalid-pem-data") + + // This will attempt to create ghinstallation transport with invalid PEM + // The function logs an error but still returns a client + client := NewGHClient() + require.NotNil(t, client) + assert.Nil(t, client.Raw().Client().Transport, "expected nil transport when GH App PEM is invalid") +} + +func TestGetGHAppConfig_WithDotEnvFile(t *testing.T) { + // Use t.Chdir so the working directory is restored automatically and + // the test is safe to run in parallel with other packages. + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // os.Unsetenv is required here because t.Setenv("", "") would cause + // koanf's env provider to override .env values with an empty string. + // This test must NOT be run with t.Parallel(). + unsetAppEnv := func(t *testing.T) { + t.Helper() + for _, key := range []string{"APP_ID", "INSTALLATION_ID", "APP_PEM"} { + if err := os.Unsetenv(key); err != nil { + t.Errorf("failed to unset %s: %v", key, err) + } + } + t.Cleanup(func() { + for _, key := range []string{"APP_ID", "INSTALLATION_ID", "APP_PEM"} { + if err := os.Unsetenv(key); err != nil { + t.Errorf("cleanup: failed to unset %s: %v", key, err) + } + } + }) + } + + tests := []struct { + name string + envContent string + wantExists bool + wantAppID int64 + wantInstID int64 + }{ + { + name: "valid .env with all fields", + envContent: "APP_ID=99999\nINSTALLATION_ID=88888\nAPP_PEM=-----BEGIN RSA PRIVATE KEY-----\\ndata\\n-----END RSA PRIVATE KEY-----\n", + wantExists: true, + wantAppID: 99999, + wantInstID: 88888, + }, + { + name: "incomplete .env file", + envContent: "APP_ID=11111\n", + wantExists: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + unsetAppEnv(t) + + err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(tc.envContent), 0644) + require.NoError(t, err) + + cc, exists := getGHAppConfig() + assert.Equal(t, tc.wantExists, exists) + if tc.wantExists { + assert.Equal(t, tc.wantAppID, cc.appID) + assert.Equal(t, tc.wantInstID, cc.instID) + } + }) + } +} diff --git a/github/repo_test.go b/github/repo_test.go new file mode 100644 index 0000000..fb69cb2 --- /dev/null +++ b/github/repo_test.go @@ -0,0 +1,164 @@ +// Copyright IBM Corp. 2023, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package github + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "testing" + "time" + + gogithub "github.com/google/go-github/v45/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetRepoCreationYear(t *testing.T) { + tests := []struct { + name string + repo GHRepo + handler func(w http.ResponseWriter, r *http.Request) + wantYear int + wantErr bool + errContains string + }{ + { + name: "successful retrieval of creation year", + repo: GHRepo{Owner: "hashicorp", Name: "copywrite"}, + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/repos/hashicorp/copywrite") + assert.Equal(t, http.MethodGet, r.Method) + + repoData := &gogithub.Repository{ + CreatedAt: &gogithub.Timestamp{Time: time.Date(2021, 6, 15, 0, 0, 0, 0, time.UTC)}, + } + w.Header().Set("Content-Type", "application/json") + assert.NoError(t, json.NewEncoder(w).Encode(repoData)) + }, + wantYear: 2021, + wantErr: false, + }, + { + name: "repo created in 2023", + repo: GHRepo{Owner: "org", Name: "project"}, + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/repos/org/project") + + repoData := &gogithub.Repository{ + CreatedAt: &gogithub.Timestamp{Time: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)}, + } + w.Header().Set("Content-Type", "application/json") + assert.NoError(t, json.NewEncoder(w).Encode(repoData)) + }, + wantYear: 2023, + wantErr: false, + }, + { + name: "API returns error", + repo: GHRepo{Owner: "nonexistent", Name: "repo"}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = fmt.Fprint(w, `{"message": "Not Found"}`) + }, + wantYear: 0, + wantErr: true, + errContains: "", + }, + { + name: "API returns malformed JSON", + repo: GHRepo{Owner: "owner", Name: "repo"}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{invalid json`) + }, + wantYear: 0, + wantErr: true, + }, + { + name: "API returns server error", + repo: GHRepo{Owner: "owner", Name: "repo"}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, `{"message": "Internal Server Error"}`) + }, + wantYear: 0, + wantErr: true, + }, + { + name: "empty owner and name", + repo: GHRepo{Owner: "", Name: ""}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = fmt.Fprint(w, `{"message": "Not Found"}`) + }, + wantYear: 0, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", tc.handler) + server := httptest.NewServer(mux) + defer server.Close() + + client, err := gogithub.NewEnterpriseClient(server.URL+"/", server.URL+"/", nil) + require.NoError(t, err) + + year, err := GetRepoCreationYear(client, tc.repo) + + if tc.wantErr { + assert.Error(t, err) + if tc.errContains != "" { + assert.Contains(t, err.Error(), tc.errContains) + } + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.wantYear, year) + }) + } +} + +func TestDiscoverRepo_NotInGitRepo(t *testing.T) { + // Use t.Chdir for process-safe directory change in tests + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + _, err := DiscoverRepo() + assert.Error(t, err) + assert.Contains(t, err.Error(), "unable to determine if the current directory relates to a GitHub repo:") +} + +func TestDiscoverRepo_Success(t *testing.T) { + // Ensure github.com is in go-gh's known hosts regardless of CI environment. + // auth.KnownHosts() only adds github.com when GH_HOST, GH_TOKEN/GITHUB_TOKEN, + // or a gh config entry is present; without one of these the host list is empty + // and repository.Current() returns an error. + t.Setenv("GH_HOST", "github.com") + + tmpDir := t.TempDir() + + // Initialize a git repo with a GitHub remote so DiscoverRepo can parse the owner/name + for _, args := range [][]string{ + {"git", "init"}, + {"git", "remote", "add", "origin", "https://github.com/testowner/testrepo.git"}, + } { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = tmpDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "setup command %v failed: %s", args, out) + } + + t.Chdir(tmpDir) + + repo, err := DiscoverRepo() + require.NoError(t, err) + assert.Equal(t, "testowner", repo.Owner) + assert.Equal(t, "testrepo", repo.Name) +}