diff --git a/README.md b/README.md index 90857ad..1800eb4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better [![Go Report Card](https://goreportcard.com/badge/github.com/multimediallc/codeowners-plus)](https://goreportcard.com/report/github.com/multimediallc/codeowners-plus?kill_cache=1) [![Tests](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml/badge.svg)](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml) -![Coverage](https://img.shields.io/badge/Coverage-82.6%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-82.8%25-brightgreen) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) @@ -17,6 +17,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better - [Getting Started](#getting-started) - [GitHub Configuration](#github-configuration) - [GitHub Teams Support](#github-teams-support) + - [GitHub Enterprise](#github-enterprise) - [Configuration](#configuration) - [.codeowners File Spec](#codeowners-file-spec) - [Advanced Configuration](#advanced-configuration) @@ -112,6 +113,19 @@ It is recommended to also set up a rerun workflow on `pull_request_review` to re If you plan to have organization teams as code owners, you will need to use a PAT that has organization [read access for Members and Administration](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens) as the token. If you do not have organization teams as owners, [GITHUB_TOKEN](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow) should be sufficient. +### GitHub Enterprise + +Codeowners Plus talks to the API of the instance running the workflow, so it works on GitHub Enterprise without any extra configuration. To point it at a different API, set the `github-api-url` input: + +```yaml + github-api-url: 'https://ghe.example.com/api/v3' +``` + +The value should be the instance's exact API URL — the same value as the `github.api_url` context (`GITHUB_API_URL`). For GitHub Enterprise Server this includes the `/api/v3` suffix; for GitHub Enterprise Cloud with data residency use `https://api..ghe.com`. If the input is not set, it defaults to the API URL of the instance running the workflow. + +> [!Note] +> When the action is referenced by a release tag (or the SHA of a release commit), it downloads its prebuilt binary from this repository on public `github.com` (see [scripts/install-action.sh](scripts/install-action.sh)) — this is also true if you mirror the action onto your own instance, so mirrored copies should not use release refs. Referencing a branch or a non-release commit builds the binary from source instead, which requires egress to the Go toolchain and module proxy (`actions/setup-go` downloads, `proxy.golang.org`, `sum.golang.org`). On an egress-restricted Enterprise instance, allow whichever set of origins fits your setup. + ## Configuration ### .codeowners File Spec diff --git a/action.yml b/action.yml index bc63b95..9b8b064 100644 --- a/action.yml +++ b/action.yml @@ -15,6 +15,10 @@ inputs: description: 'The owner and repository name. For example `octocat/Hello-World`' required: true default: '${{ github.repository }}' + github-api-url: + description: 'GitHub API base URL. Defaults to the API URL of the instance running the workflow. If overriding, use the exact value of the `github.api_url` context (GitHub Enterprise Server includes the /api/v3 suffix; GHE Cloud with data residency does not).' + required: false + default: '${{ github.api_url }}' verbose: description: 'Print debug info' required: false @@ -111,6 +115,7 @@ runs: INPUT_GITHUB-TOKEN: ${{ inputs.github-token }} INPUT_PR: ${{ inputs.pr }} INPUT_REPOSITORY: ${{ inputs.repository }} + INPUT_GITHUB-API-URL: ${{ inputs.github-api-url }} INPUT_VERBOSE: ${{ inputs.verbose }} INPUT_QUIET: ${{ inputs.quiet }} BIN: ${{ steps.resolve.outputs.bin }} diff --git a/internal/app/app.go b/internal/app/app.go index d962024..f7d364d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -52,6 +52,7 @@ func (od *OutputData) UpdateOutputData(success bool, message string, stillRequir // Config holds the application configuration type Config struct { Token string + ApiUrl string RepoDir string PR int Repo string @@ -79,7 +80,7 @@ func New(cfg Config) (*App, error) { owner := repoSplit[0] repo := repoSplit[1] - client, err := gh.NewClient(owner, repo, cfg.Token) + client, err := gh.NewClient(owner, repo, cfg.Token, cfg.ApiUrl) if err != nil { return nil, err } diff --git a/internal/github/gh.go b/internal/github/gh.go index 8803d66..ad0c705 100644 --- a/internal/github/gh.go +++ b/internal/github/gh.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "maps" + "net/url" "slices" "strings" "time" @@ -69,8 +70,30 @@ type GHClient struct { infoBuffer io.Writer } -func NewClient(owner, repo, token string) (Client, error) { - client, err := github.NewClient(github.WithAuthToken(token)) +func NewClient(owner, repo, token, apiUrl string) (Client, error) { + opts := []github.ClientOptionsFunc{github.WithAuthToken(token)} + if apiUrl = strings.TrimSpace(apiUrl); apiUrl != "" { + // apiUrl is expected to be the instance's exact API base URL, so use it + // verbatim rather than letting go-github guess the layout from the host. + parsed, err := url.Parse(apiUrl) + if err != nil { + return nil, fmt.Errorf("invalid api url %q: %w", apiUrl, err) + } + // https only: the URL carries the auth token on every request. + if parsed.Scheme != "https" || parsed.Host == "" { + return nil, fmt.Errorf("api url %q must be an absolute https URL", apiUrl) + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("api url %q must not include credentials, a query, or a fragment", apiUrl) + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/" + baseUrl := parsed.String() + // The upload URL is left at its default: nothing here calls upload + // endpoints, and the enterprise upload host can't be derived from the + // API URL reliably (data-residency uses uploads..ghe.com). + opts = append(opts, github.WithURLs(&baseUrl, nil)) + } + client, err := github.NewClient(opts...) if err != nil { return nil, err } diff --git a/internal/github/gh_test.go b/internal/github/gh_test.go index d43fab4..e3dd200 100644 --- a/internal/github/gh_test.go +++ b/internal/github/gh_test.go @@ -322,7 +322,7 @@ func TestIsSubstringInComments(t *testing.T) { } func TestNewGithubClient(t *testing.T) { - c, err := NewClient("owner", "repo", "token") + c, err := NewClient("owner", "repo", "token", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -347,6 +347,95 @@ func TestNewGithubClient(t *testing.T) { } } +func TestNewGithubClientApiUrl(t *testing.T) { + tt := []struct { + name string + apiUrl string + expected string + expectError bool + }{ + { + name: "empty uses public GitHub", + apiUrl: "", + expected: "https://api.github.com/", + }, + { + name: "enterprise server", + apiUrl: "https://ghe.example.com/api/v3", + expected: "https://ghe.example.com/api/v3/", + }, + { + name: "enterprise server with trailing slash", + apiUrl: "https://ghe.example.com/api/v3/", + expected: "https://ghe.example.com/api/v3/", + }, + { + name: "enterprise server with doubled trailing slash", + apiUrl: "https://ghe.example.com/api/v3//", + expected: "https://ghe.example.com/api/v3/", + }, + { + name: "enterprise server on an api. hostname", + apiUrl: "https://api.corp.example.com/api/v3", + expected: "https://api.corp.example.com/api/v3/", + }, + { + name: "enterprise cloud with data residency", + apiUrl: "https://api.acme.ghe.com", + expected: "https://api.acme.ghe.com/", + }, + { + name: "surrounding whitespace is trimmed", + apiUrl: " https://ghe.example.com/api/v3 ", + expected: "https://ghe.example.com/api/v3/", + }, + { + name: "missing scheme", + apiUrl: "ghe.example.com/api/v3", + expectError: true, + }, + { + name: "http is rejected", + apiUrl: "http://ghe.example.com/api/v3", + expectError: true, + }, + { + name: "query string is rejected", + apiUrl: "https://ghe.example.com/api/v3?tenant=acme", + expectError: true, + }, + { + name: "credentials are rejected", + apiUrl: "https://user:pass@ghe.example.com/api/v3", + expectError: true, + }, + { + name: "invalid URL", + apiUrl: "://bad", + expectError: true, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + c, err := NewClient("owner", "repo", "token", tc.apiUrl) + if tc.expectError { + if err == nil { + t.Fatal("expected error but got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + gh := c.(*GHClient) + if got := gh.client.BaseURL(); got != tc.expected { + t.Errorf("Expected base URL to be %s, got %s", tc.expected, got) + } + }) + } +} + func TestNilPRErr(t *testing.T) { gh := &GHClient{} tt := []struct { @@ -1464,7 +1553,7 @@ func TestContainsValidBypassApproval(t *testing.T) { } func TestContainsValidBypassApprovalNoPR(t *testing.T) { - c, err := NewClient("test-owner", "test-repo", "test-token") + c, err := NewClient("test-owner", "test-repo", "test-token", "") if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/main.go b/main.go index ecd3d9f..74a0883 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "io" "os" "strconv" + "strings" "testing" "github.com/multimediallc/codeowners-plus/internal/app" @@ -16,6 +17,7 @@ import ( // Flags holds the command line flags type Flags struct { Token *string + ApiUrl *string RepoDir *string PR *int Repo *string @@ -25,7 +27,12 @@ type Flags struct { var ( flags = &Flags{ - Token: flag.String("token", getEnv("INPUT_GITHUB-TOKEN", ""), "GitHub authentication token"), + Token: flag.String("token", getEnv("INPUT_GITHUB-TOKEN", ""), "GitHub authentication token"), + // Fall back to GITHUB_API_URL (always set by the runner) when the input is + // empty — e.g. github-api-url passed an unset expression, or the binary run + // outside the composite action. Without it, a GHE run would silently hit the + // public GitHub API. + ApiUrl: flag.String("api-url", firstNonEmpty(getEnv("INPUT_GITHUB-API-URL", ""), getEnv("GITHUB_API_URL", "")), "GitHub API base URL (for GitHub Enterprise, e.g. https://ghe.example.com/api/v3)"), RepoDir: flag.String("dir", getEnv("GITHUB_WORKSPACE", "/"), "Path to local Git repo"), PR: flag.Int("pr", ignoreError(strconv.Atoi(getEnv("INPUT_PR", ""))), "Pull Request number"), Repo: flag.String("repo", getEnv("INPUT_REPOSITORY", ""), "GitHub repo name"), @@ -69,6 +76,17 @@ func getEnv(key, fallback string) string { return fallback } +// firstNonEmpty is used to chain env var fallbacks where an empty value means +// "not set" (the composite action exports INPUT_* vars even when blank). +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + func ignoreError[V any, E error](res V, _ E) V { return res } @@ -127,6 +145,7 @@ func main() { cfg := app.Config{ Token: *flags.Token, + ApiUrl: *flags.ApiUrl, RepoDir: *flags.RepoDir, PR: *flags.PR, Repo: *flags.Repo, diff --git a/main_test.go b/main_test.go index df0f29c..580dc5d 100644 --- a/main_test.go +++ b/main_test.go @@ -15,10 +15,12 @@ func init() { // Initialize test flags with default values flags = &Flags{ Token: new(string), + ApiUrl: new(string), RepoDir: new(string), PR: new(int), Repo: new(string), Verbose: new(bool), + Quiet: new(bool), } *flags.Token = "test-token" *flags.RepoDir = "/test/dir"