From a147aacff88552fef4c049b1c8d174ff2d30d810 Mon Sep 17 00:00:00 2001 From: Darshan Parajuli Date: Wed, 26 Aug 2026 09:48:00 -0700 Subject: [PATCH 1/2] Add an option for customizing base github api url in order to support github enterprise api calls. --- README.md | 11 ++++++ action.yml | 5 +++ internal/app/app.go | 3 +- internal/github/gh.go | 11 +++++- internal/github/gh_test.go | 75 +++++++++++++++++++++++++++++++++++++- main.go | 3 ++ 6 files changed, 103 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 90857ad..136baf3 100644 --- a/README.md +++ b/README.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,16 @@ 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 + +To use a GitHub Enterprise instance instead of the public GitHub API, set the `github-api-url` input: + +```yaml + github-api-url: 'https://ghe.example.com' +``` + +The `/api/v3` suffix is optional. For GitHub Enterprise Cloud with data residency, use `https://api..ghe.com`. If the input is not set, the public GitHub API is used. + ## Configuration ### .codeowners File Spec diff --git a/action.yml b/action.yml index bc63b95..433d5ce 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. Set for GitHub Enterprise, e.g. https://ghe.example.com/api/v3. Defaults to the public GitHub API.' + required: false + default: '' 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..6ea85d4 100644 --- a/internal/github/gh.go +++ b/internal/github/gh.go @@ -69,8 +69,15 @@ 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 != "" { + // WithEnterpriseURLs appends api/uploads/ to the upload URL, so strip + // any api/v3 suffix to avoid ending up with api/v3/api/uploads/. + uploadUrl := strings.TrimSuffix(strings.TrimSuffix(apiUrl, "/"), "/api/v3") + opts = append(opts, github.WithEnterpriseURLs(apiUrl, uploadUrl)) + } + 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..2a30b42 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) } @@ -345,6 +345,77 @@ func TestNewGithubClient(t *testing.T) { if client.userReviewerMap != nil { t.Error("Expected userReviewerMap to be nil") } + if got := client.client.BaseURL(); got != "https://api.github.com/" { + t.Errorf("Expected base URL to be https://api.github.com/, got %s", got) + } +} + +func TestNewGithubClientApiUrl(t *testing.T) { + tt := []struct { + name string + apiUrl string + expectedBase string + expectedUpload string + expectError bool + }{ + { + name: "empty uses public GitHub", + apiUrl: "", + expectedBase: "https://api.github.com/", + expectedUpload: "https://uploads.github.com/", + }, + { + name: "enterprise host", + apiUrl: "https://ghe.example.com", + expectedBase: "https://ghe.example.com/api/v3/", + expectedUpload: "https://ghe.example.com/api/uploads/", + }, + { + name: "enterprise host with api/v3", + apiUrl: "https://ghe.example.com/api/v3", + expectedBase: "https://ghe.example.com/api/v3/", + expectedUpload: "https://ghe.example.com/api/uploads/", + }, + { + name: "enterprise host with api/v3 and trailing slash", + apiUrl: "https://ghe.example.com/api/v3/", + expectedBase: "https://ghe.example.com/api/v3/", + expectedUpload: "https://ghe.example.com/api/uploads/", + }, + { + name: "enterprise cloud with data residency", + apiUrl: "https://api.acme.ghe.com", + expectedBase: "https://api.acme.ghe.com/", + expectedUpload: "https://api.acme.ghe.com/", + }, + { + 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.expectedBase { + t.Errorf("Expected base URL to be %s, got %s", tc.expectedBase, got) + } + if got := gh.client.UploadURL(); got != tc.expectedUpload { + t.Errorf("Expected upload URL to be %s, got %s", tc.expectedUpload, got) + } + }) + } } func TestNilPRErr(t *testing.T) { @@ -1464,7 +1535,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..abb1d5e 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( // Flags holds the command line flags type Flags struct { Token *string + ApiUrl *string RepoDir *string PR *int Repo *string @@ -26,6 +27,7 @@ type Flags struct { var ( flags = &Flags{ Token: flag.String("token", getEnv("INPUT_GITHUB-TOKEN", ""), "GitHub authentication token"), + ApiUrl: flag.String("api-url", getEnv("INPUT_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"), @@ -127,6 +129,7 @@ func main() { cfg := app.Config{ Token: *flags.Token, + ApiUrl: *flags.ApiUrl, RepoDir: *flags.RepoDir, PR: *flags.PR, Repo: *flags.Repo, From 2ffb27b7d979bcd2faab0a6ee505bc3ffa45a45d Mon Sep 17 00:00:00 2001 From: Darshan Parajuli Date: Wed, 2 Sep 2026 13:48:19 -0700 Subject: [PATCH 2/2] Address review feedback --- README.md | 9 +++-- action.yml | 4 +-- internal/github/gh.go | 18 +++++++--- internal/github/gh_test.go | 69 ++++++++++++++++++++------------------ 4 files changed, 57 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 136baf3..225e2b6 100644 --- a/README.md +++ b/README.md @@ -115,13 +115,16 @@ If you plan to have organization teams as code owners, you will need to use a PA ### GitHub Enterprise -To use a GitHub Enterprise instance instead of the public GitHub API, set the `github-api-url` input: +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' + github-api-url: 'https://ghe.example.com/api/v3' ``` -The `/api/v3` suffix is optional. For GitHub Enterprise Cloud with data residency, use `https://api..ghe.com`. If the input is not set, the public GitHub API is used. +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, it downloads its prebuilt binary from public `github.com` (see [scripts/install-action.sh](scripts/install-action.sh)). On an egress-restricted Enterprise instance, either allow that egress or reference the action by branch or SHA, which builds it from source instead. ## Configuration diff --git a/action.yml b/action.yml index 433d5ce..9b8b064 100644 --- a/action.yml +++ b/action.yml @@ -16,9 +16,9 @@ inputs: required: true default: '${{ github.repository }}' github-api-url: - description: 'GitHub API base URL. Set for GitHub Enterprise, e.g. https://ghe.example.com/api/v3. Defaults to the public GitHub API.' + 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: '' + default: '${{ github.api_url }}' verbose: description: 'Print debug info' required: false diff --git a/internal/github/gh.go b/internal/github/gh.go index 6ea85d4..10a8328 100644 --- a/internal/github/gh.go +++ b/internal/github/gh.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "maps" + "net/url" "slices" "strings" "time" @@ -71,11 +72,18 @@ type GHClient struct { func NewClient(owner, repo, token, apiUrl string) (Client, error) { opts := []github.ClientOptionsFunc{github.WithAuthToken(token)} - if apiUrl != "" { - // WithEnterpriseURLs appends api/uploads/ to the upload URL, so strip - // any api/v3 suffix to avoid ending up with api/v3/api/uploads/. - uploadUrl := strings.TrimSuffix(strings.TrimSuffix(apiUrl, "/"), "/api/v3") - opts = append(opts, github.WithEnterpriseURLs(apiUrl, uploadUrl)) + 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. + baseUrl := strings.TrimRight(apiUrl, "/") + "/" + parsed, err := url.Parse(baseUrl) + if err != nil { + return nil, fmt.Errorf("invalid api url %q: %w", apiUrl, err) + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return nil, fmt.Errorf("api url %q must be an absolute http(s) URL", apiUrl) + } + opts = append(opts, github.WithURLs(&baseUrl, nil)) } client, err := github.NewClient(opts...) if err != nil { diff --git a/internal/github/gh_test.go b/internal/github/gh_test.go index 2a30b42..c986b4e 100644 --- a/internal/github/gh_test.go +++ b/internal/github/gh_test.go @@ -345,48 +345,54 @@ func TestNewGithubClient(t *testing.T) { if client.userReviewerMap != nil { t.Error("Expected userReviewerMap to be nil") } - if got := client.client.BaseURL(); got != "https://api.github.com/" { - t.Errorf("Expected base URL to be https://api.github.com/, got %s", got) - } } func TestNewGithubClientApiUrl(t *testing.T) { tt := []struct { - name string - apiUrl string - expectedBase string - expectedUpload string - expectError bool + name string + apiUrl string + expected string + expectError bool }{ { - name: "empty uses public GitHub", - apiUrl: "", - expectedBase: "https://api.github.com/", - expectedUpload: "https://uploads.github.com/", + name: "empty uses public GitHub", + apiUrl: "", + expected: "https://api.github.com/", }, { - name: "enterprise host", - apiUrl: "https://ghe.example.com", - expectedBase: "https://ghe.example.com/api/v3/", - expectedUpload: "https://ghe.example.com/api/uploads/", + name: "enterprise server", + apiUrl: "https://ghe.example.com/api/v3", + expected: "https://ghe.example.com/api/v3/", }, { - name: "enterprise host with api/v3", - apiUrl: "https://ghe.example.com/api/v3", - expectedBase: "https://ghe.example.com/api/v3/", - expectedUpload: "https://ghe.example.com/api/uploads/", + name: "enterprise server with trailing slash", + apiUrl: "https://ghe.example.com/api/v3/", + expected: "https://ghe.example.com/api/v3/", }, { - name: "enterprise host with api/v3 and trailing slash", - apiUrl: "https://ghe.example.com/api/v3/", - expectedBase: "https://ghe.example.com/api/v3/", - expectedUpload: "https://ghe.example.com/api/uploads/", + name: "enterprise server with doubled trailing slash", + apiUrl: "https://ghe.example.com/api/v3//", + expected: "https://ghe.example.com/api/v3/", }, { - name: "enterprise cloud with data residency", - apiUrl: "https://api.acme.ghe.com", - expectedBase: "https://api.acme.ghe.com/", - expectedUpload: "https://api.acme.ghe.com/", + 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: "invalid URL", @@ -408,11 +414,8 @@ func TestNewGithubClientApiUrl(t *testing.T) { t.Fatalf("unexpected error: %v", err) } gh := c.(*GHClient) - if got := gh.client.BaseURL(); got != tc.expectedBase { - t.Errorf("Expected base URL to be %s, got %s", tc.expectedBase, got) - } - if got := gh.client.UploadURL(); got != tc.expectedUpload { - t.Errorf("Expected upload URL to be %s, got %s", tc.expectedUpload, got) + if got := gh.client.BaseURL(); got != tc.expected { + t.Errorf("Expected base URL to be %s, got %s", tc.expected, got) } }) }