From 579daaff709ba2a1b45c345fb9426f96a5a63770 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 25 May 2026 17:41:02 +0100 Subject: [PATCH] feat: add GitLab support to coderd/externalauth/gitprovider Fixes CODAGT-146 Add GitLab support to the gitprovider package for gitsync/chatd PR diff flows. This is a squashed stack of 3 PRs: #25651 - refactor(coderd/externalauth): prepare gitprovider for multi-provider support - Change gitprovider.New to return (Provider, error) - Extract shared helpers (parseRetryAfter, checkRateLimitError, countDiffLines, escapePathPreserveSlashes) from github.go - Update all callers (db2sdk, exp_chats, gitsync) for new signature - Add error logging for provider construction failures - Thread context through provider resolution #25652 - feat(coderd/externalauth/gitprovider): add GitLab provider - Implement full Provider interface: FetchPullRequestStatus, FetchPullRequestDiff, FetchBranchDiff, ResolveBranchPullRequest - Handle nested groups, forks, and self-hosted instances - Rate limit detection on both library and raw HTTP paths - URL parsing/building with NormalizePullRequestURL support - Unit tests covering error paths, URL parsing, state mapping - Document GitLab configuration and known limitations #25653 - test(coderd/externalauth/gitprovider): add GitLab VCR integration tests - FetchPullRequestStatus: 4 fixtures (open, conflicts, merged, closed) - FetchPullRequestDiff: 4 fixtures - FetchBranchDiff: 3 fixtures (open, deleted, fork) - ResolveBranchPullRequest: 3 fixtures - go-vcr cassettes with sanitized GitLab API responses --- .gitattributes | 1 + .github/workflows/typos.toml | 2 + coderd/database/db2sdk/db2sdk.go | 2 +- coderd/exp_chats.go | 44 +- coderd/externalauth/externalauth.go | 18 +- .../externalauth_internal_test.go | 4 +- coderd/externalauth/externalauth_test.go | 14 + coderd/externalauth/gitprovider/github.go | 44 +- .../externalauth/gitprovider/github_test.go | 148 ++-- coderd/externalauth/gitprovider/gitlab.go | 681 +++++++++++++++ .../gitprovider/gitlab_integration_test.go | 817 ++++++++++++++++++ .../externalauth/gitprovider/gitlab_test.go | 425 +++++++++ .../externalauth/gitprovider/gitprovider.go | 79 +- .../gitprovider/gitprovider_internal_test.go | 150 ++++ .../nested_branch_deleted_after_merge.yaml | 61 ++ .../nested_fork_branch_not_on_target.yaml | 61 ++ .../FetchBranchDiff/open_mr_branch.yaml | 61 ++ .../nested_closed_from_fork.yaml | 30 + .../FetchPullRequestDiff/nested_merged.yaml | 30 + .../FetchPullRequestDiff/open_mergeable.yaml | 30 + .../open_with_conflicts.yaml | 30 + .../nested_closed_from_fork.yaml | 343 ++++++++ .../FetchPullRequestStatus/nested_merged.yaml | 345 ++++++++ .../open_mergeable.yaml | 345 ++++++++ .../open_with_conflicts.yaml | 345 ++++++++ .../nested_branch_deleted_after_merge.yaml | 32 + .../nested_fork_branch_not_on_target.yaml | 32 + .../open_mr_branch.yaml | 32 + coderd/x/gitsync/gitsync.go | 4 +- coderd/x/gitsync/gitsync_test.go | 26 +- coderd/x/gitsync/worker_test.go | 6 +- .../agents/platform-controls/git-providers.md | 63 +- go.mod | 6 +- go.sum | 14 +- 34 files changed, 4146 insertions(+), 179 deletions(-) create mode 100644 coderd/externalauth/gitprovider/gitlab.go create mode 100644 coderd/externalauth/gitprovider/gitlab_integration_test.go create mode 100644 coderd/externalauth/gitprovider/gitlab_test.go create mode 100644 coderd/externalauth/gitprovider/gitprovider_internal_test.go create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/nested_branch_deleted_after_merge.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/nested_fork_branch_not_on_target.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/open_mr_branch.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/nested_closed_from_fork.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/nested_merged.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/open_mergeable.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/open_with_conflicts.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/nested_closed_from_fork.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/nested_merged.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/open_mergeable.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/open_with_conflicts.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/nested_branch_deleted_after_merge.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/nested_fork_branch_not_on_target.yaml create mode 100644 coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/open_mr_branch.yaml diff --git a/.gitattributes b/.gitattributes index ed396ce004..ac80daab6b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ agent/agentcontainers/acmock/acmock.go linguist-generated=true agent/agentcontainers/dcspec/dcspec_gen.go linguist-generated=true agent/agentcontainers/testdata/devcontainercli/*/*.log linguist-generated=true coderd/apidoc/docs.go linguist-generated=true +coderd/externalauth/gitprovider/testdata/*/*/*.yaml linguist-generated=true docs/reference/api/*.md linguist-generated=true docs/reference/cli/*.md linguist-generated=true coderd/apidoc/swagger.json linguist-generated=true diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 6cc06617da..1615fa5459 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -61,4 +61,6 @@ extend-exclude = [ "agent/agentcontainers/testdata/devcontainercli/**", # aibridge fixtures contain truncated streaming chunks that look like typos "aibridge/fixtures/**", + # go-vcr cassettes contain real API responses with 3rd-party content + "coderd/externalauth/gitprovider/testdata/**", ] diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 4b5ba6e6a4..36b081b86b 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -2037,7 +2037,7 @@ func ChatDiffStatus(chatID uuid.UUID, status *database.ChatDiffStatus) codersdk. // so branch URLs for GitHub Enterprise instances will // be incorrect. To fix this, this function would need // access to the external auth configs. - gp := gitprovider.New("github", "", nil) + gp, _ := gitprovider.New("github", "", nil) if gp != nil { if owner, repo, _, ok := gp.ParseRepositoryOrigin(status.GitRemoteOrigin); ok { branchURL := gp.BuildBranchURL(owner, repo, status.GitBranch) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 01b1ae386f..3c701be52c 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4048,16 +4048,17 @@ func (api *API) resolveChatDiffContents( return result, nil } - gp := api.resolveGitProvider(reference.RepositoryRef.RemoteOrigin) + gp := api.resolveGitProvider(ctx, reference.RepositoryRef.RemoteOrigin) if gp == nil { return result, nil } token, err := api.resolveChatGitAccessToken(ctx, chat.OwnerID, reference.RepositoryRef.RemoteOrigin) - if err != nil { + if errors.Is(err, gitsync.ErrNoTokenAvailable) || token == nil { + // No token available; return metadata without fetching diff. + return result, nil + } else if err != nil { return result, xerrors.Errorf("resolve git access token: %w", err) - } else if token == nil { - return result, xerrors.New("nil git access token") } if reference.PullRequestURL != "" { @@ -4105,13 +4106,13 @@ func (api *API) resolveChatDiffReference( // Build the repository ref from the stored git branch/origin // that the agent reported. - reference.RepositoryRef = api.buildChatRepositoryRefFromStatus(status) + reference.RepositoryRef = api.buildChatRepositoryRefFromStatus(ctx, status) // If we have a repo ref with a branch, try to resolve the // current open PR. This picks up new PRs after the previous // one was closed. if reference.RepositoryRef != nil && reference.RepositoryRef.Owner != "" { - gp := api.resolveGitProvider(reference.RepositoryRef.RemoteOrigin) + gp := api.resolveGitProvider(ctx, reference.RepositoryRef.RemoteOrigin) if gp != nil { token, err := api.resolveChatGitAccessToken(ctx, chat.OwnerID, reference.RepositoryRef.RemoteOrigin) if token == nil || errors.Is(err, gitsync.ErrNoTokenAvailable) { @@ -4145,8 +4146,8 @@ func (api *API) resolveChatDiffReference( // PR URL so the caller can still show provider/owner/repo. if reference.RepositoryRef == nil && reference.PullRequestURL != "" { for _, extAuth := range api.ExternalAuthConfigs { - gp := extAuth.Git(api.HTTPClient) - if gp == nil { + gp, err := extAuth.Git(api.HTTPClient) + if err != nil || gp == nil { continue } if parsed, ok := gp.ParsePullRequestURL(reference.PullRequestURL); ok { @@ -4167,14 +4168,14 @@ func (api *API) resolveChatDiffReference( // buildChatRepositoryRefFromStatus constructs a chatRepositoryRef // from the git branch and remote origin stored in the cached status. // Returns nil if no ref data is available. -func (api *API) buildChatRepositoryRefFromStatus(status database.ChatDiffStatus) *chatRepositoryRef { +func (api *API) buildChatRepositoryRefFromStatus(ctx context.Context, status database.ChatDiffStatus) *chatRepositoryRef { branch := strings.TrimSpace(status.GitBranch) origin := strings.TrimSpace(status.GitRemoteOrigin) if branch == "" || origin == "" { return nil } - providerType, gp := api.resolveExternalAuth(origin) + providerType, gp := api.resolveExternalAuth(ctx, origin) repoRef := &chatRepositoryRef{ Provider: providerType, RemoteOrigin: origin, @@ -4242,8 +4243,8 @@ func (api *API) getCachedChatDiffStatus( // resolveExternalAuth finds the external auth config matching the // given remote origin URL and returns both the provider type string // (e.g. "github") and the gitprovider.Provider. Returns ("", nil) -// if no matching config is found. -func (api *API) resolveExternalAuth(origin string) (providerType string, gp gitprovider.Provider) { +// if no matching config is found or no provider could be constructed. +func (api *API) resolveExternalAuth(ctx context.Context, origin string) (providerType string, gp gitprovider.Provider) { origin = strings.TrimSpace(origin) if origin == "" { return "", nil @@ -4252,8 +4253,19 @@ func (api *API) resolveExternalAuth(origin string) (providerType string, gp gitp if extAuth.Regex == nil || !extAuth.Regex.MatchString(origin) { continue } - return strings.ToLower(strings.TrimSpace(extAuth.Type)), - extAuth.Git(api.HTTPClient) + p, err := extAuth.Git(api.HTTPClient) + if err != nil { + api.Logger.Warn(ctx, "failed to construct git provider", + slog.F("provider_id", extAuth.ID), + slog.F("provider_type", extAuth.Type), + slog.Error(err), + ) + continue + } + if p == nil { + continue + } + return strings.ToLower(strings.TrimSpace(extAuth.Type)), p } return "", nil } @@ -4261,8 +4273,8 @@ func (api *API) resolveExternalAuth(origin string) (providerType string, gp gitp // resolveGitProvider finds the external auth config matching the // given remote origin URL and returns its git provider. Returns // nil if no matching git provider is configured. -func (api *API) resolveGitProvider(origin string) gitprovider.Provider { - _, gp := api.resolveExternalAuth(origin) +func (api *API) resolveGitProvider(ctx context.Context, origin string) gitprovider.Provider { + _, gp := api.resolveExternalAuth(ctx, origin) return gp } diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index eb9305eec0..b2e4a7ee47 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -117,13 +117,14 @@ type Config struct { CodeChallengeMethodsSupported []promoauth.Oauth2PKCEChallengeMethod } -// Git returns a Provider for this config if the provider type -// is a supported git hosting provider. Returns nil for non-git -// providers (e.g. Slack, JFrog). -func (c *Config) Git(client *http.Client) gitprovider.Provider { +// Git returns a Provider for this config if the provider type is a +// supported git hosting provider. Returns (nil, nil) for non-git +// providers (e.g. Slack, JFrog). Returns a non-nil error if provider +// construction fails. +func (c *Config) Git(client *http.Client) (gitprovider.Provider, error) { norm := strings.ToLower(c.Type) if !codersdk.EnhancedExternalAuthProvider(norm).Git() { - return nil + return nil, nil //nolint:nilnil // nil provider means non-git type, not an error } return gitprovider.New(norm, c.APIBaseURL, client) } @@ -957,6 +958,11 @@ func copyDefaultSettings(config *codersdk.ExternalAuthConfig, defaults codersdk. config.APIBaseURL = "https://api.github.com" case codersdk.EnhancedExternalAuthProviderGitLab: config.APIBaseURL = "https://gitlab.com/api/v4" + if config.AuthURL != "" { + if au, err := url.Parse(config.AuthURL); err == nil && !strings.EqualFold(au.Host, "gitlab.com") { + config.APIBaseURL = au.Scheme + "://" + au.Host + "/api/v4" + } + } case codersdk.EnhancedExternalAuthProviderGitea: config.APIBaseURL = "https://gitea.com/api/v1" } @@ -1038,7 +1044,7 @@ func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCo DisplayName: "GitLab", DisplayIcon: "/icon/gitlab.svg", Regex: `^(https?://)?gitlab\.com(/.*)?$`, - Scopes: []string{"write_repository"}, + Scopes: []string{"write_repository", "read_api"}, CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)}, } diff --git a/coderd/externalauth/externalauth_internal_test.go b/coderd/externalauth/externalauth_internal_test.go index 363225f362..af10c03c24 100644 --- a/coderd/externalauth/externalauth_internal_test.go +++ b/coderd/externalauth/externalauth_internal_test.go @@ -30,7 +30,7 @@ func TestGitlabDefaults(t *testing.T) { DisplayIcon: "/icon/gitlab.svg", Regex: `^(https?://)?gitlab\.com(/.*)?$`, APIBaseURL: "https://gitlab.com/api/v4", - Scopes: []string{"write_repository"}, + Scopes: []string{"write_repository", "read_api"}, CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)}, } } @@ -91,6 +91,7 @@ func TestGitlabDefaults(t *testing.T) { config.TokenURL = "https://gitlab.company.org/oauth/token" config.RevokeURL = "https://gitlab.company.org/oauth/revoke" config.Regex = `^(https?://)?gitlab\.company\.org(/.*)?$` + config.APIBaseURL = "https://gitlab.company.org/api/v4" }, }, { @@ -113,6 +114,7 @@ func TestGitlabDefaults(t *testing.T) { config.RevokeURL = "https://token.com/revoke" config.Regex = `random` config.CodeChallengeMethodsSupported = []string{"random"} + config.APIBaseURL = "https://auth.com/api/v4" }, }, } diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index 493c12eae9..56453fc325 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -1231,6 +1231,20 @@ func TestConvertYAML(t *testing.T) { require.NoError(t, err) require.Equal(t, 10*time.Second, configs[0].RevokeTimeout) }) + + t.Run("SelfHostedGitLabAPIBaseURL", func(t *testing.T) { + t.Parallel() + configs, err := externalauth.ConvertConfig(instrument, []codersdk.ExternalAuthConfig{{ + Type: string(codersdk.EnhancedExternalAuthProviderGitLab), + ClientID: "id", + ClientSecret: "secret", + AuthURL: "https://gitlab.corp.com/oauth/authorize", + TokenURL: "https://gitlab.corp.com/oauth/token", + }}, &url.URL{}) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Equal(t, "https://gitlab.corp.com/api/v4", configs[0].APIBaseURL) + }) } // TestConstantQueryParams verifies a constant query parameter can be set in the diff --git a/coderd/externalauth/gitprovider/github.go b/coderd/externalauth/gitprovider/github.go index 8f177256cd..0204bb2bb5 100644 --- a/coderd/externalauth/gitprovider/github.go +++ b/coderd/externalauth/gitprovider/github.go @@ -10,7 +10,6 @@ import ( "regexp" "strconv" "strings" - "time" "golang.org/x/xerrors" @@ -19,8 +18,6 @@ import ( const ( defaultGitHubAPIBaseURL = "https://api.github.com" - // Adding padding to our retry times to guard against over-consumption of request quotas. - RateLimitPadding = 5 * time.Minute ) type githubProvider struct { @@ -148,7 +145,7 @@ func (g *githubProvider) ParsePullRequestURL(raw string) (PRRef, bool) { func (g *githubProvider) NormalizePullRequestURL(raw string) string { ref, ok := g.ParsePullRequestURL(strings.TrimRight( strings.TrimSpace(raw), - "),.;", + trailingPunctuation, )) if !ok { return "" @@ -411,12 +408,8 @@ func (g *githubProvider) decodeJSON( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { - retryAfter := ParseRetryAfter(resp.Header, g.clock) - if retryAfter > 0 { - return &RateLimitError{RetryAfter: g.clock.Now().Add(retryAfter + RateLimitPadding)} - } - // No rate-limit headers — fall through to generic error. + if rlErr := checkRateLimitError(resp, g.clock, "X-Ratelimit-Reset"); rlErr != nil { + return rlErr } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) if readErr != nil { @@ -461,11 +454,8 @@ func (g *githubProvider) fetchDiff( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { - retryAfter := ParseRetryAfter(resp.Header, g.clock) - if retryAfter > 0 { - return "", &RateLimitError{RetryAfter: g.clock.Now().Add(retryAfter + RateLimitPadding)} - } + if rlErr := checkRateLimitError(resp, g.clock, "X-Ratelimit-Reset"); rlErr != nil { + return "", rlErr } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) if readErr != nil { @@ -491,30 +481,6 @@ func (g *githubProvider) fetchDiff( return string(buf), nil } -// ParseRetryAfter extracts a retry-after time from GitHub -// rate-limit headers. Returns zero value if no recognizable header is -// present. -func ParseRetryAfter(h http.Header, clk quartz.Clock) time.Duration { - if clk == nil { - clk = quartz.NewReal() - } - // Retry-After header: seconds until retry. - if ra := h.Get("Retry-After"); ra != "" { - if secs, err := strconv.Atoi(ra); err == nil { - return time.Duration(secs) * time.Second - } - } - // X-Ratelimit-Reset header: unix timestamp. We compute the - // duration from now according to the caller's clock. - if reset := h.Get("X-Ratelimit-Reset"); reset != "" { - if ts, err := strconv.ParseInt(reset, 10, 64); err == nil { - d := time.Unix(ts, 0).Sub(clk.Now()) - return d - } - } - return 0 -} - // reviewStats holds aggregated review statistics for a PR. type reviewStats struct { changesRequested bool diff --git a/coderd/externalauth/gitprovider/github_test.go b/coderd/externalauth/gitprovider/github_test.go index fb2b510553..f3ddc572b2 100644 --- a/coderd/externalauth/gitprovider/github_test.go +++ b/coderd/externalauth/gitprovider/github_test.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "strconv" "strings" "testing" "time" @@ -16,12 +15,12 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/externalauth/gitprovider" - "github.com/coder/quartz" ) func TestGitHubParseRepositoryOrigin(t *testing.T) { t.Parallel() - gp := gitprovider.New("github", "", nil) + gp, err := gitprovider.New("github", "", nil) + require.NoError(t, err) require.NotNil(t, gp) tests := []struct { @@ -121,7 +120,8 @@ func TestGitHubParseRepositoryOrigin(t *testing.T) { func TestGitHubParsePullRequestURL(t *testing.T) { t.Parallel() - gp := gitprovider.New("github", "", nil) + gp, err := gitprovider.New("github", "", nil) + require.NoError(t, err) require.NotNil(t, gp) tests := []struct { @@ -194,7 +194,8 @@ func TestGitHubParsePullRequestURL(t *testing.T) { func TestGitHubNormalizePullRequestURL(t *testing.T) { t.Parallel() - gp := gitprovider.New("github", "", nil) + gp, err := gitprovider.New("github", "", nil) + require.NoError(t, err) require.NotNil(t, gp) tests := []struct { @@ -245,7 +246,8 @@ func TestGitHubNormalizePullRequestURL(t *testing.T) { func TestGitHubBuildBranchURL(t *testing.T) { t.Parallel() - gp := gitprovider.New("github", "", nil) + gp, err := gitprovider.New("github", "", nil) + require.NoError(t, err) require.NotNil(t, gp) tests := []struct { @@ -310,7 +312,8 @@ func TestGitHubBuildBranchURL(t *testing.T) { func TestGitHubBuildPullRequestURL(t *testing.T) { t.Parallel() - gp := gitprovider.New("github", "", nil) + gp, err := gitprovider.New("github", "", nil) + require.NoError(t, err) require.NotNil(t, gp) tests := []struct { @@ -356,7 +359,8 @@ func TestGitHubBuildPullRequestURL(t *testing.T) { func TestGitHubEnterpriseURLs(t *testing.T) { t.Parallel() - gp := gitprovider.New("github", "https://ghes.corp.com/api/v3", nil) + gp, err := gitprovider.New("github", "https://ghes.corp.com/api/v3", nil) + require.NoError(t, err) require.NotNil(t, gp) t.Run("ParseRepositoryOrigin HTTPS", func(t *testing.T) { @@ -419,7 +423,8 @@ func TestGitHubEnterpriseURLs(t *testing.T) { func TestNewUnsupportedProvider(t *testing.T) { t.Parallel() - gp := gitprovider.New("unsupported", "", nil) + gp, err := gitprovider.New("unsupported", "", nil) + require.NoError(t, err) assert.Nil(t, gp, "unsupported provider type should return nil") } @@ -434,10 +439,11 @@ func TestGitHubRatelimit_403WithResetHeader(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) require.NotNil(t, gp) - _, err := gp.FetchPullRequestStatus( + _, err = gp.FetchPullRequestStatus( context.Background(), "test-token", gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, @@ -459,10 +465,11 @@ func TestGitHubRatelimit_429WithRetryAfter(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) require.NotNil(t, gp) - _, err := gp.FetchPullRequestStatus( + _, err = gp.FetchPullRequestStatus( context.Background(), "test-token", gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, @@ -486,10 +493,11 @@ func TestGitHubRatelimit_403NormalError(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) require.NotNil(t, gp) - _, err := gp.FetchPullRequestStatus( + _, err = gp.FetchPullRequestStatus( context.Background(), "bad-token", gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, @@ -515,7 +523,9 @@ func TestGitHubFetchPullRequestDiff(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) diff, err := gp.FetchPullRequestDiff( @@ -537,7 +547,9 @@ func TestGitHubFetchPullRequestDiff(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) diff, err := gp.FetchPullRequestDiff( @@ -559,10 +571,12 @@ func TestGitHubFetchPullRequestDiff(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) - _, err := gp.FetchPullRequestDiff( + _, err = gp.FetchPullRequestDiff( context.Background(), "test-token", gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, @@ -581,10 +595,11 @@ func TestFetchPullRequestDiff_Ratelimit(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) require.NotNil(t, gp) - _, err := gp.FetchPullRequestDiff( + _, err = gp.FetchPullRequestDiff( context.Background(), "test-token", gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, @@ -614,10 +629,11 @@ func TestFetchBranchDiff_Ratelimit(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) require.NotNil(t, gp) - _, err := gp.FetchBranchDiff( + _, err = gp.FetchBranchDiff( context.Background(), "test-token", gitprovider.BranchRef{Owner: "org", Repo: "repo", Branch: "feat"}, @@ -747,7 +763,9 @@ func TestFetchPullRequestStatus(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) before := time.Now().UTC() @@ -793,7 +811,9 @@ func TestResolveBranchPullRequest(t *testing.T) { defer srv.Close() srvURL = srv.URL - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) prRef, err := gp.ResolveBranchPullRequest( @@ -817,7 +837,9 @@ func TestResolveBranchPullRequest(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) prRef, err := gp.ResolveBranchPullRequest( @@ -840,7 +862,9 @@ func TestResolveBranchPullRequest(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) prRef, err := gp.ResolveBranchPullRequest( @@ -873,7 +897,9 @@ func TestFetchBranchDiff(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) diff, err := gp.FetchBranchDiff( @@ -894,10 +920,12 @@ func TestFetchBranchDiff(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) - _, err := gp.FetchBranchDiff( + _, err = gp.FetchBranchDiff( context.Background(), "test-token", gitprovider.BranchRef{Owner: "org", Repo: "repo", Branch: "feat"}, @@ -921,10 +949,12 @@ func TestFetchBranchDiff(t *testing.T) { })) defer srv.Close() - gp := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client()) + require.NoError(t, err) + require.NotNil(t, gp) - _, err := gp.FetchBranchDiff( + _, err = gp.FetchBranchDiff( context.Background(), "test-token", gitprovider.BranchRef{Owner: "org", Repo: "repo", Branch: "feat"}, @@ -937,59 +967,9 @@ func TestEscapePathPreserveSlashes(t *testing.T) { t.Parallel() // The function is unexported, so test it indirectly via BuildBranchURL. // A branch with a space in a segment should be escaped, but slashes preserved. - gp := gitprovider.New("github", "", nil) + gp, err := gitprovider.New("github", "", nil) + require.NoError(t, err) require.NotNil(t, gp) got := gp.BuildBranchURL("owner", "repo", "feat/my thing") assert.Equal(t, "https://github.com/owner/repo/tree/feat/my%20thing", got) } - -func TestParseRetryAfter(t *testing.T) { - t.Parallel() - - clk := quartz.NewMock(t) - clk.Set(time.Now()) - - t.Run("RetryAfterSeconds", func(t *testing.T) { - t.Parallel() - h := http.Header{} - h.Set("Retry-After", "120") - d := gitprovider.ParseRetryAfter(h, clk) - assert.Equal(t, 120*time.Second, d) - }) - - t.Run("XRatelimitReset", func(t *testing.T) { - t.Parallel() - future := clk.Now().Add(90 * time.Second) - t.Logf("now: %d future: %d", clk.Now().Unix(), future.Unix()) - h := http.Header{} - h.Set("X-Ratelimit-Reset", strconv.FormatInt(future.Unix(), 10)) - d := gitprovider.ParseRetryAfter(h, clk) - assert.WithinDuration(t, future, clk.Now().Add(d), time.Second) - }) - - t.Run("NoHeaders", func(t *testing.T) { - t.Parallel() - h := http.Header{} - d := gitprovider.ParseRetryAfter(h, clk) - assert.Equal(t, time.Duration(0), d) - }) - - t.Run("InvalidValue", func(t *testing.T) { - t.Parallel() - h := http.Header{} - h.Set("Retry-After", "not-a-number") - d := gitprovider.ParseRetryAfter(h, clk) - assert.Equal(t, time.Duration(0), d) - }) - - t.Run("RetryAfterTakesPrecedence", func(t *testing.T) { - t.Parallel() - h := http.Header{} - h.Set("Retry-After", "60") - h.Set("X-Ratelimit-Reset", strconv.FormatInt( - clk.Now().Unix()+120, 10, - )) - d := gitprovider.ParseRetryAfter(h, clk) - assert.Equal(t, 60*time.Second, d) - }) -} diff --git a/coderd/externalauth/gitprovider/gitlab.go b/coderd/externalauth/gitprovider/gitlab.go new file mode 100644 index 0000000000..70dc7576ac --- /dev/null +++ b/coderd/externalauth/gitprovider/gitlab.go @@ -0,0 +1,681 @@ +package gitprovider + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + + gitlab "gitlab.com/gitlab-org/api/client-go" + "golang.org/x/xerrors" + + "github.com/coder/quartz" +) + +type gitlabProvider struct { + webBaseURL string + client *gitlab.Client + clock quartz.Clock +} + +func newGitLab(baseURL string, httpClient *http.Client, clock quartz.Clock) (*gitlabProvider, error) { + if baseURL == "" { + baseURL = "https://gitlab.com" + } + baseURL = strings.TrimRight(baseURL, "/") + baseURL = strings.TrimSuffix(baseURL, "/api/v4") + if httpClient == nil { + httpClient = http.DefaultClient + } + + client, err := gitlab.NewClient("", + gitlab.WithBaseURL(baseURL), + gitlab.WithHTTPClient(httpClient), + gitlab.WithoutRetries(), + ) + if err != nil { + return nil, xerrors.Errorf("create gitlab client: %w", err) + } + + return &gitlabProvider{ + webBaseURL: baseURL, + client: client, + clock: clock, + }, nil +} + +var _ Provider = (*gitlabProvider)(nil) + +// webHost returns the hostname (with port if present) of the GitLab web URL. +func (g *gitlabProvider) webHost() string { + u, err := url.Parse(g.webBaseURL) + if err != nil { + return "gitlab.com" + } + return u.Host +} + +// reqOpts returns per-request options for authentication and context. +func reqOpts(ctx context.Context, token string) []gitlab.RequestOptionFunc { + opts := []gitlab.RequestOptionFunc{gitlab.WithContext(ctx)} + if token != "" { + opts = append(opts, gitlab.WithToken(gitlab.OAuthToken, token)) + } + return opts +} + +// gitLabPID returns the full project path (owner/repo) for use as a pid. +// The library handles URL encoding internally. +func gitLabPID(owner, repo string) string { + return owner + "/" + repo +} + +func (g *gitlabProvider) FetchPullRequestStatus( + ctx context.Context, + token string, + ref PRRef, +) (*PRStatus, error) { + pid := gitLabPID(ref.Owner, ref.Repo) + opts := reqOpts(ctx, token) + + // Fetch merge request details. + mr, _, err := g.client.MergeRequests.GetMergeRequest(pid, int64(ref.Number), nil, opts...) + if err != nil { + return nil, g.wrapError(err, "get merge request") + } + + // Fetch approvals. + approvals, _, err := g.client.MergeRequests.GetMergeRequestApprovals(pid, int64(ref.Number), opts...) + if err != nil { + return nil, g.wrapError(err, "get merge request approvals") + } + + // Fetch commits to get the commit count. + var totalCommits int32 + commits, resp, err := g.client.MergeRequests.GetMergeRequestCommits( + pid, int64(ref.Number), + &gitlab.GetMergeRequestCommitsOptions{ListOptions: gitlab.ListOptions{PerPage: 100}}, + opts..., + ) + if err != nil { + return nil, g.wrapError(err, "get merge request commits") + } + if resp.TotalItems > 0 { + totalCommits = int32(resp.TotalItems) + } else { + totalCommits = int32(len(commits)) + } + + // Fetch MR diffs to compute additions/deletions. + // The commits endpoint does not return per-commit stats, so we + // count +/- lines from the unified diff returned by this endpoint. + var additions, deletions int32 + diffs, _, err := g.client.MergeRequests.ListMergeRequestDiffs( + pid, int64(ref.Number), + // NOTE: fetches a single page of up to 100 diffs. MRs with more than + // 100 changed files will have correct ChangedFiles (from MR metadata) + // but undercounted Additions/Deletions. Pagination is omitted because + // the gitsync worker only uses ChangedFiles for its heuristics today. + &gitlab.ListMergeRequestDiffsOptions{ListOptions: gitlab.ListOptions{PerPage: 100}}, + opts..., + ) + if err != nil { + return nil, g.wrapError(err, "list merge request diffs") + } + for _, d := range diffs { + diffAdditions, diffDeletions := countDiffLines(d.Diff) + additions += diffAdditions + deletions += diffDeletions + } + + // Map GitLab state to normalized state. + state := mapGitLabState(mr.State) + + // Use diff_refs.head_sha if available, fall back to top-level sha. + headSHA := cmp.Or(mr.DiffRefs.HeadSha, mr.SHA) + + // Parse changes_count (it's a string, possibly "1000+"). + var changedFiles int32 + if mr.ChangesCount != "" { + trimmed := strings.TrimSuffix(mr.ChangesCount, "+") + if n, err := strconv.Atoi(trimmed); err == nil { + changedFiles = int32(n) + } + } + + // TODO(CODAGT-440): These fields have semantic gaps vs the GitHub + // provider. GitLab's "Approved" is threshold-based (not "at least one + // approval and no changes requested"), ChangesRequested has no GitLab + // equivalent, and ReviewerCount only counts approvers. + reviewerCount := int32(len(approvals.ApprovedBy)) + + var authorLogin, authorAvatarURL string + if mr.Author != nil { + authorLogin = mr.Author.Username + authorAvatarURL = mr.Author.AvatarURL + } + + return &PRStatus{ + Title: mr.Title, + State: state, + Draft: mr.Draft, + HeadSHA: headSHA, + HeadBranch: mr.SourceBranch, + DiffStats: DiffStats{ + Additions: additions, + Deletions: deletions, + ChangedFiles: changedFiles, + }, + ChangesRequested: false, + Approved: approvals.Approved, + ReviewerCount: reviewerCount, + AuthorLogin: authorLogin, + AuthorAvatarURL: authorAvatarURL, + BaseBranch: mr.TargetBranch, + PRNumber: int(mr.IID), + Commits: totalCommits, + FetchedAt: g.clock.Now().UTC(), + }, nil +} + +func (g *gitlabProvider) ResolveBranchPullRequest( + ctx context.Context, + token string, + ref BranchRef, +) (*PRRef, error) { + if ref.Owner == "" || ref.Repo == "" || ref.Branch == "" { + return nil, nil + } + + pid := gitLabPID(ref.Owner, ref.Repo) + opts := reqOpts(ctx, token) + + mrs, _, err := g.client.MergeRequests.ListProjectMergeRequests(pid, &gitlab.ListProjectMergeRequestsOptions{ + ListOptions: gitlab.ListOptions{PerPage: 1}, + SourceBranch: gitlab.Ptr(ref.Branch), + State: gitlab.Ptr("opened"), + OrderBy: gitlab.Ptr("updated_at"), + Sort: gitlab.Ptr("desc"), + }, opts...) + if err != nil { + return nil, g.wrapError(err, "list merge requests by branch") + } + if len(mrs) == 0 { + return nil, nil + } + + prRef, ok := g.ParsePullRequestURL(mrs[0].WebURL) + if !ok { + // Fallback: construct from known owner/repo and returned IID. + return &PRRef{ + Owner: ref.Owner, + Repo: ref.Repo, + Number: int(mrs[0].IID), + }, nil + } + return &prRef, nil +} + +func (g *gitlabProvider) FetchPullRequestDiff( + ctx context.Context, + token string, + ref PRRef, +) (string, error) { + pid := gitLabPID(ref.Owner, ref.Repo) + + // Make a direct HTTP request instead of using the library's + // ShowMergeRequestRawDiffs, which reads the entire response + // into memory before returning. We use io.LimitReader to + // bound memory and reject diffs exceeding MaxDiffSize. + rawURL := fmt.Sprintf("%sprojects/%s/merge_requests/%d/raw_diffs", + g.client.BaseURL().String(), url.PathEscape(pid), ref.Number) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", g.wrapError(err, "create raw diffs request") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := g.client.HTTPClient().Do(req) + if err != nil { + return "", g.wrapError(err, "get merge request raw diffs") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if rlErr := checkRateLimitError(resp, g.clock, "RateLimit-Reset"); rlErr != nil { + return "", rlErr + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if readErr != nil { + return "", g.wrapError( + xerrors.Errorf("unexpected status %d", resp.StatusCode), + "get merge request raw diffs", + ) + } + return "", g.wrapError( + xerrors.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))), + "get merge request raw diffs", + ) + } + + buf, err := io.ReadAll(io.LimitReader(resp.Body, MaxDiffSize+1)) + if err != nil { + return "", g.wrapError(err, "read merge request raw diffs") + } + if len(buf) > MaxDiffSize { + return "", ErrDiffTooLarge + } + return string(buf), nil +} + +// compareResponse is the subset of GitLab's compare endpoint response +// that we need. We decode manually (instead of using the library) so +// we can bound memory with io.LimitReader before JSON parsing. +type compareResponse struct { + Diffs []struct { + Diff string `json:"diff"` + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` + NewFile bool `json:"new_file"` + DeletedFile bool `json:"deleted_file"` + RenamedFile bool `json:"renamed_file"` + Collapsed bool `json:"collapsed"` + TooLarge bool `json:"too_large"` + } `json:"diffs"` + CompareTimeout bool `json:"compare_timeout"` +} + +func (g *gitlabProvider) FetchBranchDiff( + ctx context.Context, + token string, + ref BranchRef, +) (string, error) { + if ref.Owner == "" || ref.Repo == "" || ref.Branch == "" { + return "", nil + } + + pid := gitLabPID(ref.Owner, ref.Repo) + opts := reqOpts(ctx, token) + + // Get the default branch from the project. + project, _, err := g.client.Projects.GetProject(pid, nil, opts...) + if err != nil { + return "", g.wrapError(err, "get project") + } + defaultBranch := strings.TrimSpace(project.DefaultBranch) + if defaultBranch == "" { + return "", xerrors.New("gitlab project default branch is empty") + } + + // Use raw HTTP with io.LimitReader to bound memory. The library's + // Compare() decodes the full response before returning, which + // would allow a maliciously large diff to OOM the process. + compareURL := fmt.Sprintf("%sprojects/%s/repository/compare?from=%s&to=%s&unidiff=true", + g.client.BaseURL().String(), + url.PathEscape(pid), + url.QueryEscape(defaultBranch), + url.QueryEscape(ref.Branch), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, compareURL, nil) + if err != nil { + return "", g.wrapError(err, "create compare request") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := g.client.HTTPClient().Do(req) + if err != nil { + return "", g.wrapError(err, "compare branches") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if rlErr := checkRateLimitError(resp, g.clock, "RateLimit-Reset"); rlErr != nil { + return "", rlErr + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if readErr != nil { + return "", g.wrapError( + xerrors.Errorf("unexpected status %d", resp.StatusCode), + "compare branches", + ) + } + return "", g.wrapError( + xerrors.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))), + "compare branches", + ) + } + + // Bound the read to MaxDiffSize + overhead for JSON structure. + // The JSON envelope (commits, metadata) adds some overhead beyond + // the raw diff content, so we allow ~10% extra for framing. + maxRead := int64(MaxDiffSize) + int64(MaxDiffSize/10) + 4096 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxRead+1)) + if err != nil { + return "", g.wrapError(err, "read compare response") + } + if int64(len(body)) > maxRead { + return "", ErrDiffTooLarge + } + + var compare compareResponse + if err := json.Unmarshal(body, &compare); err != nil { + return "", g.wrapError(err, "decode compare response") + } + if compare.CompareTimeout { + return "", xerrors.New("gitlab compare timed out; diff may be incomplete") + } + + // Reconstruct unified diff from individual file diffs. + var sb strings.Builder + var estimated int + for _, d := range compare.Diffs { + estimated += len(d.Diff) + len(d.OldPath) + len(d.NewPath) + 20 + } + if estimated > MaxDiffSize { + return "", ErrDiffTooLarge + } + sb.Grow(estimated) + for _, d := range compare.Diffs { + if d.Collapsed || d.TooLarge { + slog.WarnContext(ctx, "gitlab compare: file diff truncated", + slog.String("path", d.NewPath), + slog.Bool("collapsed", d.Collapsed), + slog.Bool("too_large", d.TooLarge), + ) + } + fmt.Fprintf(&sb, "diff --git a/%s b/%s\n", d.OldPath, d.NewPath) + // Add standard unified diff file headers. + switch { + case d.NewFile: + sb.WriteString("--- /dev/null\n") + fmt.Fprintf(&sb, "+++ b/%s\n", d.NewPath) + case d.DeletedFile: + fmt.Fprintf(&sb, "--- a/%s\n", d.OldPath) + sb.WriteString("+++ /dev/null\n") + default: + fmt.Fprintf(&sb, "--- a/%s\n", d.OldPath) + fmt.Fprintf(&sb, "+++ b/%s\n", d.NewPath) + } + sb.WriteString(d.Diff) + // Ensure each file diff ends with a newline. + if len(d.Diff) > 0 && d.Diff[len(d.Diff)-1] != '\n' { + sb.WriteByte('\n') + } + } + + result := sb.String() + if len(result) > MaxDiffSize { + return "", ErrDiffTooLarge + } + return result, nil +} + +// ParseRepositoryOrigin preserves slashes in owner because GitLab supports +// subgroup paths such as group/subgroup/repo. +// +// TODO: this does not handle GitLab instances installed under a relative URL +// prefix (e.g. https://example.com/gitlab/). See +// https://docs.gitlab.com/install/relative_url/ for details. +func (g *gitlabProvider) ParseRepositoryOrigin(raw string) (owner, repo, normalizedOrigin string, ok bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", "", false + } + + host := g.webHost() + + // Try SSH format: git@HOST:path.git or ssh://git@HOST/path.git + if path, matched := g.parseSSHOrigin(raw, host); matched { + owner, repo = splitOwnerRepo(path) + if owner == "" || repo == "" { + return "", "", "", false + } + normalized := fmt.Sprintf("%s/%s/%s", g.webBaseURL, owner, repo) + return owner, repo, normalized, true + } + + // Try HTTPS format. + u, err := url.Parse(raw) + if err != nil { + return "", "", "", false + } + if !strings.EqualFold(u.Host, host) { + return "", "", "", false + } + if u.Scheme != "https" && u.Scheme != "http" { + return "", "", "", false + } + + path := strings.TrimPrefix(u.Path, "/") + path = strings.TrimSuffix(path, "/") + path = strings.TrimSuffix(path, ".git") + if path == "" { + return "", "", "", false + } + + owner, repo = splitOwnerRepo(path) + if owner == "" || repo == "" { + return "", "", "", false + } + + normalized := fmt.Sprintf("%s/%s/%s", g.webBaseURL, owner, repo) + return owner, repo, normalized, true +} + +func (g *gitlabProvider) ParsePullRequestURL(raw string) (PRRef, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return PRRef{}, false + } + + u, err := url.Parse(raw) + if err != nil { + return PRRef{}, false + } + + host := g.webHost() + if !strings.EqualFold(u.Host, host) { + return PRRef{}, false + } + + // GitLab MR URLs: /owner/repo/-/merge_requests/123 + // or /group/subgroup/repo/-/merge_requests/123 + path := strings.TrimPrefix(u.Path, "/") + path = strings.TrimSuffix(path, "/") + + // Find "-/merge_requests/NUMBER" in the path. + const mrMarker = "-/merge_requests/" + idx := strings.Index(path, mrMarker) + if idx < 0 { + return PRRef{}, false + } + + // Everything before the marker (minus trailing slash) is the project path. + projPath := path[:idx] + projPath = strings.TrimSuffix(projPath, "/") + if projPath == "" { + return PRRef{}, false + } + + // The number comes after the marker. + afterMR := path[idx+len(mrMarker):] + // Strip any trailing path segments. + if slashIdx := strings.Index(afterMR, "/"); slashIdx >= 0 { + afterMR = afterMR[:slashIdx] + } + + number, err := strconv.Atoi(afterMR) + if err != nil || number <= 0 { + return PRRef{}, false + } + + owner, repo := splitOwnerRepo(projPath) + if owner == "" || repo == "" { + return PRRef{}, false + } + + return PRRef{ + Owner: owner, + Repo: repo, + Number: number, + }, true +} + +// NormalizePullRequestURL normalizes a GitLab merge request URL. +func (g *gitlabProvider) NormalizePullRequestURL(raw string) string { + ref, ok := g.ParsePullRequestURL(strings.TrimRight( + strings.TrimSpace(raw), + trailingPunctuation, + )) + if !ok { + return "" + } + return g.BuildPullRequestURL(ref) +} + +// BuildBranchURL keeps owner and repo unescaped because GitLab owners can +// include subgroup paths with slashes. +func (g *gitlabProvider) BuildBranchURL(owner, repo, branch string) string { + owner = strings.TrimSpace(owner) + repo = strings.TrimSpace(repo) + branch = strings.TrimSpace(branch) + if owner == "" || repo == "" || branch == "" { + return "" + } + + return fmt.Sprintf( + "%s/%s/%s/-/tree/%s", + g.webBaseURL, + owner, + repo, + escapePathPreserveSlashes(branch), + ) +} + +// BuildRepositoryURL keeps owner and repo unescaped because GitLab owners can +// include subgroup paths with slashes. +func (g *gitlabProvider) BuildRepositoryURL(owner, repo string) string { + owner = strings.TrimSpace(owner) + repo = strings.TrimSpace(repo) + if owner == "" || repo == "" { + return "" + } + return fmt.Sprintf("%s/%s/%s", g.webBaseURL, owner, repo) +} + +func (g *gitlabProvider) BuildPullRequestURL(ref PRRef) string { + if ref.Owner == "" || ref.Repo == "" || ref.Number <= 0 { + return "" + } + return fmt.Sprintf("%s/%s/%s/-/merge_requests/%d", g.webBaseURL, ref.Owner, ref.Repo, ref.Number) +} + +// wrapError converts library errors to our domain errors (e.g. rate limits). +func (g *gitlabProvider) wrapError(err error, action string) error { + if errResp, ok := errors.AsType[*gitlab.ErrorResponse](err); ok { + if rlErr := checkRateLimitError(errResp.Response, g.clock, "RateLimit-Reset"); rlErr != nil { + return rlErr + } + } + return xerrors.Errorf("gitlab %s: %w", action, err) +} + +// mapGitLabState maps a GitLab merge request state string to a normalized PRState. +func mapGitLabState(state string) PRState { + switch strings.ToLower(strings.TrimSpace(state)) { + case "opened": + return PRStateOpen + case "merged": + return PRStateMerged + case "closed", "locked": + return PRStateClosed + default: + return PRStateClosed + } +} + +// splitOwnerRepo splits a path like "group/subgroup/repo" into +// owner="group/subgroup" and repo="repo". The last segment is always +// the repo name, and everything before it is the owner. +func splitOwnerRepo(path string) (owner, repo string) { + path = strings.TrimPrefix(path, "/") + path = strings.TrimSuffix(path, "/") + if path == "" { + return "", "" + } + + lastSlash := strings.LastIndex(path, "/") + if lastSlash < 0 { + // No slash means no owner/repo split possible. + return "", "" + } + + owner = path[:lastSlash] + repo = path[lastSlash+1:] + if owner == "" || repo == "" { + return "", "" + } + return owner, repo +} + +// parseSSHOrigin attempts to parse an SSH git remote URL for the given host. +// Returns the path (without .git suffix) and true if it matched. +func (g *gitlabProvider) parseSSHOrigin(raw string, host string) (string, bool) { + // Handle ssh://git@HOST/path.git format. + if strings.HasPrefix(raw, "ssh://") { + u, err := url.Parse(raw) + if err != nil { + return "", false + } + // The host in SSH URLs may include a port, so compare case-insensitively. + if !strings.EqualFold(u.Host, host) && !strings.EqualFold(u.Hostname(), hostWithoutPort(host)) { + return "", false + } + path := strings.TrimPrefix(u.Path, "/") + path = strings.TrimSuffix(path, ".git") + path = strings.TrimSuffix(path, "/") + if path == "" { + return "", false + } + return path, true + } + + // Handle git@HOST:path.git format (SCP-like syntax). + prefix := "git@" + host + ":" + // Also try matching without port for host comparison. + prefixNoPort := "git@" + hostWithoutPort(host) + ":" + + path, ok := strings.CutPrefix(raw, prefix) + if !ok { + path, ok = strings.CutPrefix(raw, prefixNoPort) + } + if !ok { + return "", false + } + + path = strings.TrimSuffix(path, ".git") + path = strings.TrimSuffix(path, "/") + if path == "" { + return "", false + } + return path, true +} + +// hostWithoutPort strips the port from a host:port string. +func hostWithoutPort(host string) string { + if idx := strings.LastIndex(host, ":"); idx >= 0 { + return host[:idx] + } + return host +} diff --git a/coderd/externalauth/gitprovider/gitlab_integration_test.go b/coderd/externalauth/gitprovider/gitlab_integration_test.go new file mode 100644 index 0000000000..67fdd595ae --- /dev/null +++ b/coderd/externalauth/gitprovider/gitlab_integration_test.go @@ -0,0 +1,817 @@ +package gitprovider_test + +import ( + "net/http" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" + "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" + + "github.com/coder/coder/v2/coderd/externalauth/gitprovider" + "github.com/coder/coder/v2/testutil" +) + +// newGitLabVCR creates a go-vcr recorder for GitLab integration tests. +// In replay mode (default), it serves responses from the cassette file. +// When GITLAB_UPDATE_GOLDEN=true, it records live responses to the cassette. +func newGitLabVCR(t *testing.T, cassetteName string) *recorder.Recorder { + t.Helper() + + mode := recorder.ModeReplayOnly + if update, _ := strconv.ParseBool(os.Getenv("GITLAB_UPDATE_GOLDEN")); update { + mode = recorder.ModeRecordOnly + } + + rec, err := recorder.New( + "testdata/gitlab_cassettes/"+cassetteName, + recorder.WithMode(mode), + recorder.WithSkipRequestLatency(true), + // Match only on method + URL; the default matcher is too strict + // (compares proto, all headers, etc.) and breaks replay. + // TODO: consider verifying that an Authorization header is present + // during replay to catch auth-wiring regressions. + recorder.WithMatcher(func(r *http.Request, i cassette.Request) bool { + return r.Method == i.Method && r.URL.String() == i.URL + }), + // Strip headers down to an allowlist to reduce cassette noise. + recorder.WithHook(func(i *cassette.Interaction) error { + allowedRequestHeaders := map[string]struct{}{ + "Accept": {}, + "Content-Type": {}, + } + for h := range i.Request.Headers { + if _, ok := allowedRequestHeaders[h]; !ok { + i.Request.Headers[h] = []string{"stripped"} + } + } + + allowedResponseHeaders := map[string]struct{}{ + "Content-Type": {}, + "X-Total": {}, + } + for h := range i.Response.Headers { + if _, ok := allowedResponseHeaders[h]; !ok { + i.Response.Headers[h] = []string{"stripped"} + } + } + return nil + }, recorder.AfterCaptureHook), + ) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, rec.Stop()) + }) + + return rec +} + +// TestGitLabIntegration exercises every gitprovider.Provider method +// against recorded GitLab API responses (go-vcr cassettes). +// +// To update cassettes from live GitLab: +// +// GITLAB_UPDATE_GOLDEN=true GITLAB_TOKEN= go test ./coderd/externalauth/gitprovider/ -run TestGitLabIntegration -count=1 +// +// Fixtures: +// +// 1. https://gitlab.com/test-group9945421/test-project/-/merge_requests/3 +// Simple namespace (single-level group). +// State: open. Same-repo MR, 1 file, mergeable. +// +// 2. https://gitlab.com/test-group9945421/test-project/-/merge_requests/2 +// Simple namespace (single-level group). +// State: open. Same-repo MR, 1 file, has conflicts. +// +// 3. https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/1 +// Nested group (multi-level namespace: test-group9945421/test-subgroup). +// State: merged. Same-repo MR, 1 file. Source branch deleted after merge. +// +// 4. https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/3 +// Nested group. State: closed (not merged). From a fork. +// Source branch "forked" does not exist on the target project. +func TestGitLabIntegration(t *testing.T) { + t.Parallel() + + apiURL := "https://gitlab.com" + + // Token is only used when recording (GITLAB_UPDATE_GOLDEN=true). + token := os.Getenv("GITLAB_TOKEN") + + // URL parsing tests don't need VCR (no API calls). + provider, err := gitprovider.New("gitlab", apiURL, http.DefaultClient) + require.NoError(t, err) + require.NotNil(t, provider, "gitprovider.New returned nil for \"gitlab\"") + + // --- URL parsing (no API calls) --- + + t.Run("ParseRepositoryOrigin", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + expectOK bool + expectOwner string + expectRepo string + expectNormalized string + }{ + { + name: "HTTPS simple", + raw: "https://gitlab.com/test-group9945421/test-project.git", + expectOK: true, + expectOwner: "test-group9945421", + expectRepo: "test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-project", + }, + { + name: "HTTPS no .git", + raw: "https://gitlab.com/test-group9945421/test-project", + expectOK: true, + expectOwner: "test-group9945421", + expectRepo: "test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-project", + }, + { + name: "HTTPS trailing slash", + raw: "https://gitlab.com/test-group9945421/test-project/", + expectOK: true, + expectOwner: "test-group9945421", + expectRepo: "test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-project", + }, + { + name: "SSH", + raw: "git@gitlab.com:test-group9945421/test-project.git", + expectOK: true, + expectOwner: "test-group9945421", + expectRepo: "test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-project", + }, + { + name: "SSH prefix", + raw: "ssh://git@gitlab.com/test-group9945421/test-project.git", + expectOK: true, + expectOwner: "test-group9945421", + expectRepo: "test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-project", + }, + { + name: "Nested group HTTPS", + raw: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project.git", + expectOK: true, + expectOwner: "test-group9945421/test-subgroup", + expectRepo: "another-test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project", + }, + { + name: "Nested group HTTPS no .git", + raw: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project", + expectOK: true, + expectOwner: "test-group9945421/test-subgroup", + expectRepo: "another-test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project", + }, + { + name: "Nested group SSH", + raw: "git@gitlab.com:test-group9945421/test-subgroup/another-test-project.git", + expectOK: true, + expectOwner: "test-group9945421/test-subgroup", + expectRepo: "another-test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project", + }, + { + name: "Nested group SSH prefix", + raw: "ssh://git@gitlab.com/test-group9945421/test-subgroup/another-test-project.git", + expectOK: true, + expectOwner: "test-group9945421/test-subgroup", + expectRepo: "another-test-project", + expectNormalized: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project", + }, + { + name: "GitHub does not match", + raw: "https://github.com/coder/coder", + expectOK: false, + }, + { + name: "Empty string", + raw: "", + expectOK: false, + }, + { + name: "Not a URL", + raw: "not-a-url", + expectOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + owner, repo, normalized, ok := provider.ParseRepositoryOrigin(tt.raw) + assert.Equal(t, tt.expectOK, ok) + if tt.expectOK { + assert.Equal(t, tt.expectOwner, owner) + assert.Equal(t, tt.expectRepo, repo) + assert.Equal(t, tt.expectNormalized, normalized) + } + }) + } + }) + + t.Run("ParsePullRequestURL", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + expectOK bool + expectOwner string + expectRepo string + expectNumber int + }{ + { + name: "Simple namespace", + raw: "https://gitlab.com/test-group9945421/test-project/-/merge_requests/3", + expectOK: true, + expectOwner: "test-group9945421", + expectRepo: "test-project", + expectNumber: 3, + }, + { + name: "Nested group", + raw: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/1", + expectOK: true, + expectOwner: "test-group9945421/test-subgroup", + expectRepo: "another-test-project", + expectNumber: 1, + }, + { + name: "Nested group second MR", + raw: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/3", + expectOK: true, + expectOwner: "test-group9945421/test-subgroup", + expectRepo: "another-test-project", + expectNumber: 3, + }, + { + name: "With query string", + raw: "https://gitlab.com/test-group9945421/test-project/-/merge_requests/3?tab=diffs", + expectOK: true, + expectOwner: "test-group9945421", + expectRepo: "test-project", + expectNumber: 3, + }, + { + name: "With fragment", + raw: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/1#note_123", + expectOK: true, + expectOwner: "test-group9945421/test-subgroup", + expectRepo: "another-test-project", + expectNumber: 1, + }, + { + name: "With path suffix (diffs tab)", + raw: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/3/diffs", + expectOK: true, + expectOwner: "test-group9945421/test-subgroup", + expectRepo: "another-test-project", + expectNumber: 3, + }, + { + name: "GitHub PR does not match", + raw: "https://github.com/coder/coder/pull/123", + expectOK: false, + }, + { + name: "Not a MR URL", + raw: "https://gitlab.com/test-group9945421/test-project/-/issues/1", + expectOK: false, + }, + { + name: "Empty string", + raw: "", + expectOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ref, ok := provider.ParsePullRequestURL(tt.raw) + assert.Equal(t, tt.expectOK, ok) + if tt.expectOK { + assert.Equal(t, tt.expectOwner, ref.Owner) + assert.Equal(t, tt.expectRepo, ref.Repo) + assert.Equal(t, tt.expectNumber, ref.Number) + } + }) + } + }) + + t.Run("NormalizePullRequestURL", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + expected string + }{ + { + name: "Simple, already normalized", + raw: "https://gitlab.com/test-group9945421/test-project/-/merge_requests/3", + expected: "https://gitlab.com/test-group9945421/test-project/-/merge_requests/3", + }, + { + name: "Simple with query and fragment", + raw: "https://gitlab.com/test-group9945421/test-project/-/merge_requests/3?tab=diffs#note_123", + expected: "https://gitlab.com/test-group9945421/test-project/-/merge_requests/3", + }, + { + name: "Nested group with query", + raw: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/1?diff_id=1234", + expected: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/1", + }, + { + name: "Nested group with path suffix", + raw: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/3/diffs", + expected: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/3", + }, + { + name: "Not a MR URL", + raw: "https://example.com/foo", + expected: "", + }, + { + name: "Empty string", + raw: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := provider.NormalizePullRequestURL(tt.raw) + assert.Equal(t, tt.expected, got) + }) + } + }) + + t.Run("BuildBranchURL", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + owner string + repo string + branch string + expected string + }{ + { + name: "Simple namespace", + owner: "test-group9945421", + repo: "test-project", + branch: "main", + expected: "https://gitlab.com/test-group9945421/test-project/-/tree/main", + }, + { + name: "Nested group", + owner: "test-group9945421/test-subgroup", + repo: "another-test-project", + branch: "main", + expected: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/tree/main", + }, + { + name: "Branch with special name", + owner: "test-group9945421/test-subgroup", + repo: "another-test-project", + branch: "johnstcn-main-patch-54711", + expected: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/tree/johnstcn-main-patch-54711", + }, + { + name: "Empty owner", + owner: "", + repo: "test-project", + branch: "main", + expected: "", + }, + { + name: "Empty repo", + owner: "test-group9945421", + repo: "", + branch: "main", + expected: "", + }, + { + name: "Empty branch", + owner: "test-group9945421", + repo: "test-project", + branch: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := provider.BuildBranchURL(tt.owner, tt.repo, tt.branch) + assert.Equal(t, tt.expected, got) + }) + } + }) + + t.Run("BuildRepositoryURL", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + owner string + repo string + expected string + }{ + { + name: "Simple namespace", + owner: "test-group9945421", + repo: "test-project", + expected: "https://gitlab.com/test-group9945421/test-project", + }, + { + name: "Nested group", + owner: "test-group9945421/test-subgroup", + repo: "another-test-project", + expected: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project", + }, + { + name: "Empty owner", + owner: "", + repo: "test-project", + expected: "", + }, + { + name: "Empty repo", + owner: "test-group9945421", + repo: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := provider.BuildRepositoryURL(tt.owner, tt.repo) + assert.Equal(t, tt.expected, got) + }) + } + }) + + t.Run("BuildPullRequestURL", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref gitprovider.PRRef + expected string + }{ + { + name: "Simple namespace", + ref: gitprovider.PRRef{Owner: "test-group9945421", Repo: "test-project", Number: 3}, + expected: "https://gitlab.com/test-group9945421/test-project/-/merge_requests/3", + }, + { + name: "Nested group", + ref: gitprovider.PRRef{Owner: "test-group9945421/test-subgroup", Repo: "another-test-project", Number: 1}, + expected: "https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/1", + }, + { + name: "Empty owner", + ref: gitprovider.PRRef{Owner: "", Repo: "test-project", Number: 3}, + expected: "", + }, + { + name: "Empty repo", + ref: gitprovider.PRRef{Owner: "test-group9945421", Repo: "", Number: 3}, + expected: "", + }, + { + name: "Zero number", + ref: gitprovider.PRRef{Owner: "test-group9945421", Repo: "test-project", Number: 0}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := provider.BuildPullRequestURL(tt.ref) + assert.Equal(t, tt.expected, got) + }) + } + }) + + // --- API calls (use VCR cassettes) --- + + t.Run("FetchPullRequestStatus", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref gitprovider.PRRef + expectState gitprovider.PRState + expectAuthor string + expectHead string + expectBase string + expectBranch string + expectTitle string + expectDraft bool + expectChanges int32 + expectApproved bool + expectReviewerCount int32 + expectChangesReq bool + }{ + { + name: "open_mergeable", + ref: gitprovider.PRRef{Owner: "test-group9945421", Repo: "test-project", Number: 3}, + expectState: gitprovider.PRStateOpen, + expectAuthor: "johnstcn", + expectHead: "da57fca657e02c1fbe131402f927d134a34b257b", + expectBase: "main", + expectBranch: "johnstcn-main-patch-98822", + expectTitle: "Open mergeable", + expectDraft: false, + expectChanges: 1, + expectApproved: true, + expectReviewerCount: 0, + expectChangesReq: false, + }, + { + name: "open_with_conflicts", + ref: gitprovider.PRRef{Owner: "test-group9945421", Repo: "test-project", Number: 2}, + expectState: gitprovider.PRStateOpen, + expectAuthor: "johnstcn", + expectHead: "642379758fa148ff24cba5f676226a3f8e560d73", + expectBase: "main", + expectBranch: "johnstcn-main-patch-84369", + expectTitle: "Open with conflicts", + expectDraft: false, + expectChanges: 1, + expectApproved: true, + expectReviewerCount: 0, + expectChangesReq: false, + }, + { + name: "nested_merged", + ref: gitprovider.PRRef{Owner: "test-group9945421/test-subgroup", Repo: "another-test-project", Number: 1}, + expectState: gitprovider.PRStateMerged, + expectAuthor: "johnstcn", + expectHead: "ff919f3dc418e4fbffb6fbded7b4c9ae60a4531b", + expectBase: "main", + expectBranch: "johnstcn-main-patch-54711", + expectTitle: "Nested merged", + expectDraft: false, + expectChanges: 1, + expectApproved: true, + expectReviewerCount: 0, + expectChangesReq: false, + }, + { + name: "nested_closed_from_fork", + ref: gitprovider.PRRef{Owner: "test-group9945421/test-subgroup", Repo: "another-test-project", Number: 3}, + expectState: gitprovider.PRStateClosed, + expectAuthor: "johnstcn", + expectHead: "6b743c6728fa248e3654657e0e576eafcf472953", + expectBase: "main", + expectBranch: "forked", + expectTitle: "Nested closed from fork", + expectDraft: false, + expectChanges: 1, + expectApproved: true, + expectReviewerCount: 0, + expectChangesReq: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + rec := newGitLabVCR(t, "FetchPullRequestStatus/"+tt.name) + vcrProvider, err := gitprovider.New("gitlab", apiURL, rec.GetDefaultClient()) + require.NoError(t, err) + require.NotNil(t, vcrProvider) + + status, err := vcrProvider.FetchPullRequestStatus(ctx, token, tt.ref) + require.NoError(t, err) + require.NotNil(t, status) + + assert.Equal(t, tt.expectState, status.State) + assert.Equal(t, tt.expectDraft, status.Draft) + assert.Equal(t, tt.ref.Number, status.PRNumber) + assert.False(t, status.FetchedAt.IsZero()) + assert.WithinDuration(t, time.Now(), status.FetchedAt, 10*time.Second) + + // Fields that are always populated. + assert.NotEmpty(t, status.Title) + assert.NotEmpty(t, status.HeadSHA) + assert.NotEmpty(t, status.HeadBranch) + assert.NotEmpty(t, status.BaseBranch) + assert.NotEmpty(t, status.AuthorLogin) + + // Exact assertions for publicly-verifiable fixtures. + if tt.expectAuthor != "" { + assert.Equal(t, tt.expectAuthor, status.AuthorLogin) + } + if tt.expectHead != "" { + assert.Equal(t, tt.expectHead, status.HeadSHA) + } + if tt.expectBase != "" { + assert.Equal(t, tt.expectBase, status.BaseBranch) + } + if tt.expectBranch != "" { + assert.Equal(t, tt.expectBranch, status.HeadBranch) + } + if tt.expectTitle != "" { + assert.Equal(t, tt.expectTitle, status.Title) + } + if tt.expectChanges > 0 { + assert.Equal(t, tt.expectChanges, status.DiffStats.ChangedFiles) + } + + // Approval-related fields populated from GitLab approvals endpoint. + assert.Equal(t, tt.expectApproved, status.Approved) + assert.Equal(t, tt.expectReviewerCount, status.ReviewerCount) + assert.Equal(t, tt.expectChangesReq, status.ChangesRequested) + }) + } + }) + + t.Run("FetchPullRequestDiff", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref gitprovider.PRRef + }{ + { + name: "open_mergeable", + ref: gitprovider.PRRef{Owner: "test-group9945421", Repo: "test-project", Number: 3}, + }, + { + name: "open_with_conflicts", + ref: gitprovider.PRRef{Owner: "test-group9945421", Repo: "test-project", Number: 2}, + }, + { + name: "nested_merged", + ref: gitprovider.PRRef{Owner: "test-group9945421/test-subgroup", Repo: "another-test-project", Number: 1}, + }, + { + name: "nested_closed_from_fork", + ref: gitprovider.PRRef{Owner: "test-group9945421/test-subgroup", Repo: "another-test-project", Number: 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + rec := newGitLabVCR(t, "FetchPullRequestDiff/"+tt.name) + vcrProvider, err := gitprovider.New("gitlab", apiURL, rec.GetDefaultClient()) + require.NoError(t, err) + require.NotNil(t, vcrProvider) + + diff, err := vcrProvider.FetchPullRequestDiff(ctx, token, tt.ref) + require.NoError(t, err) + assert.NotEmpty(t, diff) + assert.Contains(t, diff, "diff --git") + }) + } + }) + + t.Run("ResolveBranchPullRequest", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref gitprovider.BranchRef + expectNil bool // true if branch is known-deleted or from a fork + }{ + { + name: "open_mr_branch", + ref: gitprovider.BranchRef{ + Owner: "test-group9945421", + Repo: "test-project", + Branch: "johnstcn-main-patch-98822", + }, + expectNil: false, + }, + { + name: "nested_branch_deleted_after_merge", + ref: gitprovider.BranchRef{ + Owner: "test-group9945421/test-subgroup", + Repo: "another-test-project", + Branch: "johnstcn-main-patch-54711", + }, + expectNil: true, + }, + { + name: "nested_fork_branch_not_on_target", + ref: gitprovider.BranchRef{ + Owner: "test-group9945421/test-subgroup", + Repo: "another-test-project", + Branch: "forked", + }, + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + rec := newGitLabVCR(t, "ResolveBranchPullRequest/"+tt.name) + vcrProvider, err := gitprovider.New("gitlab", apiURL, rec.GetDefaultClient()) + require.NoError(t, err) + require.NotNil(t, vcrProvider) + + ref, err := vcrProvider.ResolveBranchPullRequest(ctx, token, tt.ref) + require.NoError(t, err) + if tt.expectNil { + assert.Nil(t, ref) + } else { + require.NotNil(t, ref) + assert.Equal(t, tt.ref.Owner, ref.Owner) + assert.Equal(t, tt.ref.Repo, ref.Repo) + assert.Greater(t, ref.Number, 0) + } + }) + } + }) + + t.Run("FetchBranchDiff", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref gitprovider.BranchRef + expectErr bool // true if branch no longer exists + }{ + { + name: "open_mr_branch", + ref: gitprovider.BranchRef{ + Owner: "test-group9945421", + Repo: "test-project", + Branch: "johnstcn-main-patch-98822", + }, + }, + { + name: "nested_branch_deleted_after_merge", + ref: gitprovider.BranchRef{ + Owner: "test-group9945421/test-subgroup", + Repo: "another-test-project", + Branch: "johnstcn-main-patch-54711", + }, + // Branch was removed after merge. + expectErr: true, + }, + { + name: "nested_fork_branch_not_on_target", + ref: gitprovider.BranchRef{ + Owner: "test-group9945421/test-subgroup", + Repo: "another-test-project", + Branch: "forked", + }, + // Branch only existed in the fork, not on the target repo. + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + rec := newGitLabVCR(t, "FetchBranchDiff/"+tt.name) + vcrProvider, err := gitprovider.New("gitlab", apiURL, rec.GetDefaultClient()) + require.NoError(t, err) + require.NotNil(t, vcrProvider) + + diff, err := vcrProvider.FetchBranchDiff(ctx, token, tt.ref) + if tt.expectErr { + // TODO: assert on error content (not just presence) to + // distinguish real API errors from stale-cassette mismatches. + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, diff) + }) + } + }) +} diff --git a/coderd/externalauth/gitprovider/gitlab_test.go b/coderd/externalauth/gitprovider/gitlab_test.go new file mode 100644 index 0000000000..4bf0eda37b --- /dev/null +++ b/coderd/externalauth/gitprovider/gitlab_test.go @@ -0,0 +1,425 @@ +package gitprovider_test + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/externalauth/gitprovider" + "github.com/coder/quartz" +) + +func TestGitLabFetchPullRequestStatus(t *testing.T) { + t.Parallel() + + t.Run("HeadSHAFallback", func(t *testing.T) { + t.Parallel() + + // When diff_refs.head_sha is empty, FetchPullRequestStatus + // should fall back to the top-level sha field. + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/projects/owner%2Frepo/merge_requests/1", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"title":"T","state":"opened","source_branch":"feat","target_branch":"main","sha":"fallback-sha","draft":false,"iid":1,"changes_count":"1","web_url":"http://HOST/owner/repo/-/merge_requests/1","author":{"username":"u"},"diff_refs":{"head_sha":""}}`)) + }) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/merge_requests/1/approvals", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"approved":false,"approved_by":[]}`)) + }) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/merge_requests/1/commits", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total", "2") + _, _ = w.Write([]byte(`[{"id":"abc","short_id":"abc","title":"c1"}]`)) + }) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/merge_requests/1/diffs", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Two file diffs: first has +5/-2, second has +3/-1 + _, _ = w.Write([]byte(`[{"diff":"@@ -1,3 +1,6 @@\n+a\n+b\n+c\n+d\n+e\n-x\n-y\n","new_path":"file1.txt","old_path":"file1.txt"},{"diff":"@@ -1,2 +1,4 @@\n+a\n+b\n+c\n-x\n","new_path":"file2.txt","old_path":"file2.txt"}]`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + status, err := gp.FetchPullRequestStatus( + t.Context(), + "token", + gitprovider.PRRef{Owner: "owner", Repo: "repo", Number: 1}, + ) + require.NoError(t, err) + assert.Equal(t, "fallback-sha", status.HeadSHA) + assert.Equal(t, int32(2), status.Commits) + assert.Equal(t, int32(8), status.DiffStats.Additions) + assert.Equal(t, int32(3), status.DiffStats.Deletions) + assert.Equal(t, int32(1), status.DiffStats.ChangedFiles) + }) +} + +func TestGitLabFetchPullRequestDiff(t *testing.T) { + t.Parallel() + + t.Run("TooLarge", func(t *testing.T) { + t.Parallel() + + oversizeDiff := string(make([]byte, gitprovider.MaxDiffSize+1024)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(oversizeDiff)) + })) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + _, err = gp.FetchPullRequestDiff( + t.Context(), + "test-token", + gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, + ) + assert.ErrorIs(t, err, gitprovider.ErrDiffTooLarge) + }) +} + +func TestGitLabFetchBranchDiff(t *testing.T) { + t.Parallel() + + t.Run("TrailingNewlineAppended", func(t *testing.T) { + t.Parallel() + + // When a file diff does not end with a newline, FetchBranchDiff + // should append one so the unified diff is well-formed. + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/compare", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // diff field intentionally lacks a trailing newline. + _, _ = w.Write([]byte(`{"diffs":[{"old_path":"a.txt","new_path":"a.txt","diff":"@@ -1 +1 @@\n-old\n+new"}]}`)) + }) + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"default_branch":"main"}`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + diff, err := gp.FetchBranchDiff( + t.Context(), + "token", + gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"}, + ) + require.NoError(t, err) + // Must end with newline even though the API response did not. + assert.True(t, len(diff) > 0 && diff[len(diff)-1] == '\n') + assert.Equal(t, "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n", diff) + }) + + t.Run("EmptyDefaultBranch", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"default_branch":""}`)) + })) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + _, err = gp.FetchBranchDiff( + t.Context(), + "test-token", + gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "default branch is empty") + }) + + t.Run("CompareTimeout", func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/compare", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"compare_timeout":true,"diffs":[]}`)) + }) + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"default_branch":"main"}`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + _, err = gp.FetchBranchDiff( + t.Context(), + "test-token", + gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out") + }) + + t.Run("TooLarge", func(t *testing.T) { + t.Parallel() + + buf := make([]byte, gitprovider.MaxDiffSize+1024) + for i := range buf { + buf[i] = 'x' + } + oversizeDiff := string(buf) + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"default_branch":"main"}`)) + }) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/compare", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"diffs":[{"old_path":"big.txt","new_path":"big.txt","diff":"%s"}]}`, oversizeDiff) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + _, err = gp.FetchBranchDiff( + t.Context(), + "test-token", + gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"}, + ) + assert.ErrorIs(t, err, gitprovider.ErrDiffTooLarge) + }) +} + +func TestGitLabResolveBranchPullRequest(t *testing.T) { + t.Parallel() + + t.Run("FallbackOnUnparsableWebURL", func(t *testing.T) { + t.Parallel() + + // When the MR's web_url cannot be parsed by ParsePullRequestURL, + // ResolveBranchPullRequest falls back to constructing the PRRef + // from the known owner/repo and the returned IID. + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/projects/owner%2Frepo/merge_requests", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Return a web_url that won't match the provider's host. + _, _ = w.Write([]byte(`[{"iid":99,"web_url":"https://other-host.example.com/x/y/-/merge_requests/99"}]`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + prRef, err := gp.ResolveBranchPullRequest( + t.Context(), + "token", + gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"}, + ) + require.NoError(t, err) + require.NotNil(t, prRef) + assert.Equal(t, "owner", prRef.Owner) + assert.Equal(t, "repo", prRef.Repo) + assert.Equal(t, 99, prRef.Number) + }) + + t.Run("EmptyRef", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called for empty branch ref") + })) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + prRef, err := gp.ResolveBranchPullRequest( + t.Context(), + "test-token", + gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: ""}, + ) + require.NoError(t, err) + assert.Nil(t, prRef) + }) +} + +func TestGitLabRateLimit(t *testing.T) { + t.Parallel() + + t.Run("429WithRetryAfter", func(t *testing.T) { + t.Parallel() + + mClock := quartz.NewMock(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "120") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"message":"rate limit exceeded"}`)) + })) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client(), gitprovider.WithClock(mClock)) + require.NoError(t, err) + + _, err = gp.FetchPullRequestStatus( + t.Context(), + "test-token", + gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, + ) + require.Error(t, err) + + rlErr, ok := errors.AsType[*gitprovider.RateLimitError](err) + require.True(t, ok, "error should be *RateLimitError, got: %T", err) + + expected := mClock.Now().Add(120*time.Second + gitprovider.RateLimitPadding) + assert.True(t, rlErr.RetryAfter.Equal(expected), "expected %v, got %v", expected, rlErr.RetryAfter) + }) + + t.Run("403WithRateLimitReset", func(t *testing.T) { + t.Parallel() + + mClock := quartz.NewMock(t) + + resetTime := mClock.Now().Add(60 * time.Second) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("RateLimit-Reset", fmt.Sprintf("%d", resetTime.Unix())) + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"rate limit exceeded"}`)) + })) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client(), gitprovider.WithClock(mClock)) + require.NoError(t, err) + + _, err = gp.FetchPullRequestStatus( + t.Context(), + "test-token", + gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, + ) + require.Error(t, err) + + rlErr, ok := errors.AsType[*gitprovider.RateLimitError](err) + require.True(t, ok, "error should be *RateLimitError, got: %T", err) + + expected := resetTime.Add(gitprovider.RateLimitPadding) + assert.True(t, rlErr.RetryAfter.Equal(expected), "expected %v, got %v", expected, rlErr.RetryAfter) + }) + + t.Run("429OnRawDiffEndpoint", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "raw_diffs") { + w.Header().Set("Retry-After", "60") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + mClock := quartz.NewMock(t) + mClock.Set(time.Date(2026, 5, 25, 12, 0, 0, 0, time.UTC)) + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client(), gitprovider.WithClock(mClock)) + require.NoError(t, err) + + _, err = gp.FetchPullRequestDiff( + t.Context(), + "test-token", + gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, + ) + require.Error(t, err) + + rlErr, ok := errors.AsType[*gitprovider.RateLimitError](err) + require.True(t, ok, "error should be *RateLimitError, got: %T", err) + + expected := mClock.Now().Add(60*time.Second + gitprovider.RateLimitPadding) + assert.Equal(t, expected, rlErr.RetryAfter) + }) + + t.Run("403WithoutRateLimitHeaders", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"forbidden"}`)) + })) + defer srv.Close() + + gp, err := gitprovider.New("gitlab", srv.URL, srv.Client()) + require.NoError(t, err) + + _, err = gp.FetchPullRequestStatus( + t.Context(), + "bad-token", + gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, + ) + require.Error(t, err) + + _, ok := errors.AsType[*gitprovider.RateLimitError](err) + assert.False(t, ok, "error should NOT be *RateLimitError") + assert.Contains(t, err.Error(), "403") + }) +} + +func TestGitLabSelfHosted(t *testing.T) { + t.Parallel() + + gp, err := gitprovider.New("gitlab", "https://gitlab.corp.com", nil) + require.NoError(t, err) + + t.Run("ParseRepositoryOriginMatches", func(t *testing.T) { + t.Parallel() + owner, repo, _, ok := gp.ParseRepositoryOrigin("https://gitlab.corp.com/org/repo.git") + assert.True(t, ok) + assert.Equal(t, "org", owner) + assert.Equal(t, "repo", repo) + }) + + t.Run("ParseRepositoryOriginRejectsGitLabCom", func(t *testing.T) { + t.Parallel() + _, _, _, ok := gp.ParseRepositoryOrigin("https://gitlab.com/org/repo.git") + assert.False(t, ok, "gitlab.com URL should not match self-hosted instance") + }) + + t.Run("ParsePullRequestURLMatches", func(t *testing.T) { + t.Parallel() + ref, ok := gp.ParsePullRequestURL("https://gitlab.corp.com/org/repo/-/merge_requests/1") + assert.True(t, ok) + assert.Equal(t, "org", ref.Owner) + assert.Equal(t, "repo", ref.Repo) + assert.Equal(t, 1, ref.Number) + }) + + t.Run("ParsePullRequestURLRejectsGitLabCom", func(t *testing.T) { + t.Parallel() + _, ok := gp.ParsePullRequestURL("https://gitlab.com/org/repo/-/merge_requests/1") + assert.False(t, ok, "gitlab.com MR URL should not match self-hosted instance") + }) + + t.Run("BuildPullRequestURL", func(t *testing.T) { + t.Parallel() + result := gp.BuildPullRequestURL(gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 42}) + assert.Equal(t, "https://gitlab.corp.com/org/repo/-/merge_requests/42", result) + }) +} diff --git a/coderd/externalauth/gitprovider/gitprovider.go b/coderd/externalauth/gitprovider/gitprovider.go index 50a254ae0d..9828318a9c 100644 --- a/coderd/externalauth/gitprovider/gitprovider.go +++ b/coderd/externalauth/gitprovider/gitprovider.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + "strconv" + "strings" "time" "golang.org/x/xerrors" @@ -102,11 +104,19 @@ type PRStatus struct { FetchedAt time.Time } +// trailingPunctuation is the set of characters stripped from the right +// of a raw URL before parsing it as a pull request URL. +const trailingPunctuation = "),;." + // MaxDiffSize is the maximum number of bytes read from a diff // response. Diffs exceeding this limit are rejected with // ErrDiffTooLarge. const MaxDiffSize = 4 << 20 // 4 MiB +// RateLimitPadding is added to rate-limit retry times to guard +// against over-consumption of request quotas. +const RateLimitPadding = 5 * time.Minute + // ErrDiffTooLarge is returned when a diff exceeds MaxDiffSize. var ErrDiffTooLarge = xerrors.Errorf("diff exceeds maximum size of %d bytes", MaxDiffSize) @@ -169,9 +179,9 @@ type Provider interface { } // New creates a Provider for the given provider type and API base -// URL. Returns nil if the provider type is not a supported git -// provider. -func New(providerType string, apiBaseURL string, httpClient *http.Client, opts ...Option) Provider { +// URL. Returns (nil, nil) for unsupported provider types and a +// non-nil error if construction fails. +func New(providerType string, apiBaseURL string, httpClient *http.Client, opts ...Option) (Provider, error) { o := providerOptions{} for _, opt := range opts { opt(&o) @@ -182,12 +192,71 @@ func New(providerType string, apiBaseURL string, httpClient *http.Client, opts . switch providerType { case "github": - return newGitHub(apiBaseURL, httpClient, o.clock) + return newGitHub(apiBaseURL, httpClient, o.clock), nil + case "gitlab": + return newGitLab(apiBaseURL, httpClient, o.clock) default: - // Other providers (gitlab, bitbucket-cloud, etc.) will be + // Other providers (bitbucket-cloud, etc.) will be // added here as they are implemented. + return nil, nil //nolint:nilnil // nil provider means unsupported type, not an error + } +} + +// parseRetryAfter extracts a retry duration from rate-limit response +// headers. It checks Retry-After (seconds) first, then the named +// resetHeader (unix timestamp). Returns zero if no recognizable header +// is present. +func parseRetryAfter(h http.Header, resetHeader string, clk quartz.Clock) time.Duration { + if clk == nil { + clk = quartz.NewReal() + } + // Retry-After header: seconds until retry. + if ra := h.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil { + return time.Duration(secs) * time.Second + } + } + // Reset header: unix timestamp. We compute the duration from now + // according to the caller's clock. + if reset := h.Get(resetHeader); reset != "" { + if ts, err := strconv.ParseInt(reset, 10, 64); err == nil { + return time.Unix(ts, 0).Sub(clk.Now()) + } + } + return 0 +} + +// checkRateLimitError returns a *RateLimitError when resp indicates a +// rate limit (HTTP 403 or 429) with recognizable retry headers; +// otherwise nil. A nil resp returns nil. +func checkRateLimitError(resp *http.Response, clk quartz.Clock, resetHeader string) error { + if resp == nil { return nil } + if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusTooManyRequests { + return nil + } + if clk == nil { + clk = quartz.NewReal() + } + retryAfter := parseRetryAfter(resp.Header, resetHeader, clk) + if retryAfter <= 0 { + return nil + } + return &RateLimitError{RetryAfter: clk.Now().Add(retryAfter + RateLimitPadding)} +} + +// countDiffLines counts added and deleted lines in a unified diff. It excludes +// file header lines such as +++ b/file and --- a/file. +func countDiffLines(diff string) (additions, deletions int32) { + for _, line := range strings.Split(diff, "\n") { + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + additions++ + } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { + deletions++ + } + } + return additions, deletions } // RateLimitError indicates the git provider's API rate limit was hit. diff --git a/coderd/externalauth/gitprovider/gitprovider_internal_test.go b/coderd/externalauth/gitprovider/gitprovider_internal_test.go new file mode 100644 index 0000000000..786ad1ecab --- /dev/null +++ b/coderd/externalauth/gitprovider/gitprovider_internal_test.go @@ -0,0 +1,150 @@ +package gitprovider + +import ( + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/coder/quartz" +) + +func TestCountDiffLines(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + diff string + additions int32 + deletions int32 + }{ + { + name: "Empty", + }, + { + name: "OnlyAdditions", + diff: "+a\n+b\n+c\n", + additions: 3, + }, + { + name: "OnlyDeletions", + diff: "-a\n-b\n", + deletions: 2, + }, + { + name: "MixedWithHeaders", + diff: "--- a/file.txt\n+++ b/file.txt\n@@ -1,2 +1,3 @@\n unchanged\n-old\n+new\n+another\n", + additions: 2, + deletions: 1, + }, + { + name: "NoTrailingNewline", + diff: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + additions, deletions := countDiffLines(tt.diff) + assert.Equal(t, tt.additions, additions) + assert.Equal(t, tt.deletions, deletions) + }) + } +} + +func TestParseRetryAfter(t *testing.T) { + t.Parallel() + + clk := quartz.NewMock(t) + clk.Set(time.Date(2026, 5, 25, 12, 0, 0, 0, time.UTC)) + + t.Run("RetryAfterSeconds", func(t *testing.T) { + t.Parallel() + h := http.Header{} + h.Set("Retry-After", "120") + d := parseRetryAfter(h, "X-Ratelimit-Reset", clk) + assert.Equal(t, 120*time.Second, d) + }) + + t.Run("GitHubResetHeader", func(t *testing.T) { + t.Parallel() + future := clk.Now().Add(90 * time.Second) + h := http.Header{} + h.Set("X-Ratelimit-Reset", strconv.FormatInt(future.Unix(), 10)) + d := parseRetryAfter(h, "X-Ratelimit-Reset", clk) + assert.WithinDuration(t, future, clk.Now().Add(d), time.Second) + }) + + t.Run("GitLabResetHeader", func(t *testing.T) { + t.Parallel() + future := clk.Now().Add(45 * time.Second) + h := http.Header{} + h.Set("RateLimit-Reset", strconv.FormatInt(future.Unix(), 10)) + d := parseRetryAfter(h, "RateLimit-Reset", clk) + assert.WithinDuration(t, future, clk.Now().Add(d), time.Second) + }) + + t.Run("NoHeaders", func(t *testing.T) { + t.Parallel() + h := http.Header{} + d := parseRetryAfter(h, "X-Ratelimit-Reset", clk) + assert.Equal(t, time.Duration(0), d) + }) + + t.Run("InvalidValue", func(t *testing.T) { + t.Parallel() + h := http.Header{} + h.Set("Retry-After", "not-a-number") + d := parseRetryAfter(h, "X-Ratelimit-Reset", clk) + assert.Equal(t, time.Duration(0), d) + }) + + t.Run("RetryAfterTakesPrecedence", func(t *testing.T) { + t.Parallel() + h := http.Header{} + h.Set("Retry-After", "60") + h.Set("X-Ratelimit-Reset", strconv.FormatInt(clk.Now().Add(120*time.Second).Unix(), 10)) + d := parseRetryAfter(h, "X-Ratelimit-Reset", clk) + assert.Equal(t, 60*time.Second, d) + }) + + t.Run("NilClock", func(t *testing.T) { + t.Parallel() + h := http.Header{} + h.Set("Retry-After", "1") + d := parseRetryAfter(h, "X-Ratelimit-Reset", nil) + assert.Equal(t, time.Second, d) + }) +} + +func TestMapGitLabState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expect PRState + }{ + {name: "opened", input: "opened", expect: PRStateOpen}, + {name: "Opened_mixed_case", input: "Opened", expect: PRStateOpen}, + {name: "merged", input: "merged", expect: PRStateMerged}, + {name: "closed", input: "closed", expect: PRStateClosed}, + {name: "locked", input: "locked", expect: PRStateClosed}, + {name: "unknown_defaults_to_closed", input: "something_else", expect: PRStateClosed}, + {name: "empty_defaults_to_closed", input: "", expect: PRStateClosed}, + {name: "whitespace_trimmed", input: " opened ", expect: PRStateOpen}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := mapGitLabState(tt.input) + assert.Equal(t, tt.expect, got) + }) + } +} diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/nested_branch_deleted_after_merge.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/nested_branch_deleted_after_merge.yaml new file mode 100644 index 0000000000..3f77db8e23 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/nested_branch_deleted_after_merge.yaml @@ -0,0 +1,61 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":82312037,"description":null,"name":"another-test-project","name_with_namespace":"test-group / test-subgroup / another-test-project","path":"another-test-project","path_with_namespace":"test-group9945421/test-subgroup/another-test-project","created_at":"2026-05-18T15:20:05.607Z","default_branch":"main","tag_list":[],"topics":[],"ssh_url_to_repo":"git@gitlab.com:test-group9945421/test-subgroup/another-test-project.git","http_url_to_repo":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project.git","web_url":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project","readme_url":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/blob/main/README.md","forks_count":1,"avatar_url":null,"star_count":0,"last_activity_at":"2026-05-18T15:20:05.517Z","visibility":"public","namespace":{"id":132531619,"name":"test-subgroup","path":"test-subgroup","kind":"group","full_path":"test-group9945421/test-subgroup","parent_id":132520176,"avatar_url":null,"web_url":"https://gitlab.com/groups/test-group9945421/test-subgroup"}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100.000000ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/repository/compare?from=main&to=johnstcn-main-patch-54711 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"message":"404 Ref Not Found"}' + headers: + Content-Type: + - application/json + status: 404 Not Found + code: 404 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/nested_fork_branch_not_on_target.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/nested_fork_branch_not_on_target.yaml new file mode 100644 index 0000000000..96316747b2 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/nested_fork_branch_not_on_target.yaml @@ -0,0 +1,61 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":82312037,"description":null,"name":"another-test-project","name_with_namespace":"test-group / test-subgroup / another-test-project","path":"another-test-project","path_with_namespace":"test-group9945421/test-subgroup/another-test-project","created_at":"2026-05-18T15:20:05.607Z","default_branch":"main","tag_list":[],"topics":[],"ssh_url_to_repo":"git@gitlab.com:test-group9945421/test-subgroup/another-test-project.git","http_url_to_repo":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project.git","web_url":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project","readme_url":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/blob/main/README.md","forks_count":1,"avatar_url":null,"star_count":0,"last_activity_at":"2026-05-18T15:20:05.517Z","visibility":"public","namespace":{"id":132531619,"name":"test-subgroup","path":"test-subgroup","kind":"group","full_path":"test-group9945421/test-subgroup","parent_id":132520176,"avatar_url":null,"web_url":"https://gitlab.com/groups/test-group9945421/test-subgroup"}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100.000000ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/repository/compare?from=main&to=forked + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"message":"404 Ref Not Found"}' + headers: + Content-Type: + - application/json + status: 404 Not Found + code: 404 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/open_mr_branch.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/open_mr_branch.yaml new file mode 100644 index 0000000000..6a888c4fee --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchBranchDiff/open_mr_branch.yaml @@ -0,0 +1,61 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":82310987,"description":null,"name":"test-project","name_with_namespace":"test-group / test-project","path":"test-project","path_with_namespace":"test-group9945421/test-project","created_at":"2026-05-18T14:50:04.401Z","default_branch":"main","tag_list":[],"topics":[],"ssh_url_to_repo":"git@gitlab.com:test-group9945421/test-project.git","http_url_to_repo":"https://gitlab.com/test-group9945421/test-project.git","web_url":"https://gitlab.com/test-group9945421/test-project","readme_url":"https://gitlab.com/test-group9945421/test-project/-/blob/main/README.md","forks_count":0,"avatar_url":null,"star_count":0,"last_activity_at":"2026-05-18T14:50:04.313Z","visibility":"public","namespace":{"id":132520176,"name":"test-group","path":"test-group9945421","kind":"group","full_path":"test-group9945421","parent_id":null,"avatar_url":null,"web_url":"https://gitlab.com/groups/test-group9945421"}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100.000000ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/repository/compare?from=main&to=johnstcn-main-patch-98822&unidiff=true + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"commit":{"id":"da57fca657e02c1fbe131402f927d134a34b257b","short_id":"da57fca6","created_at":"2026-05-18T14:53:46.000+00:00","parent_ids":["bc2d14403364db33c7811b29598509b8cf0223c4"],"title":"Open mergeable","message":"Open mergeable","author_name":"Cian Johnston","author_email":"public@cianjohnston.ie","authored_date":"2026-05-18T14:53:46.000+00:00","committer_name":"Cian Johnston","committer_email":"public@cianjohnston.ie","committed_date":"2026-05-18T14:53:46.000+00:00","trailers":{},"extended_trailers":{},"web_url":"https://gitlab.com/test-group9945421/test-project/-/commit/da57fca657e02c1fbe131402f927d134a34b257b"},"commits":[{"id":"da57fca657e02c1fbe131402f927d134a34b257b","short_id":"da57fca6","created_at":"2026-05-18T14:53:46.000+00:00","parent_ids":["bc2d14403364db33c7811b29598509b8cf0223c4"],"title":"Open mergeable","message":"Open mergeable","author_name":"Cian Johnston","author_email":"public@cianjohnston.ie","authored_date":"2026-05-18T14:53:46.000+00:00","committer_name":"Cian Johnston","committer_email":"public@cianjohnston.ie","committed_date":"2026-05-18T14:53:46.000+00:00","trailers":{},"extended_trailers":{},"web_url":"https://gitlab.com/test-group9945421/test-project/-/commit/da57fca657e02c1fbe131402f927d134a34b257b"}],"diffs":[{"diff":"@@ -1,6 +1,6 @@\n # test-project\n \n-\n+This is a test project for testing things.\n \n ## Next Steps\n \n","collapsed":false,"too_large":false,"new_path":"README.md","old_path":"README.md","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"generated_file":null}],"compare_timeout":false,"compare_same_ref":false,"web_url":"https://gitlab.com/test-group9945421/test-project/-/compare/bc2d14403364db33c7811b29598509b8cf0223c4...da57fca657e02c1fbe131402f927d134a34b257b"}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/nested_closed_from_fork.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/nested_closed_from_fork.yaml new file mode 100644 index 0000000000..06d1b07e55 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/nested_closed_from_fork.yaml @@ -0,0 +1,30 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Authorization: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/3/raw_diffs + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: 'diff --git a/README.md b/README.md\nindex b48d45443e349c6dd113da4bb7546504a07a5cce..2474182060dcbf875e7c54ffc60ecea9bbd60da3 100644\n--- a/README.md\n+++ b/README.md\n@@ -2,6 +2,8 @@\n \n This is another test project for testing stuff.\n \n+Here''s a change. Might not merge it.\n+\n ## Getting started\n \n To make it easy for you to get started with GitLab, here''s a list of recommended next steps.\n' + headers: + Content-Type: + - text/plain + status: 200 OK + code: 200 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/nested_merged.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/nested_merged.yaml new file mode 100644 index 0000000000..4fed5862ef --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/nested_merged.yaml @@ -0,0 +1,30 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Authorization: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/1/raw_diffs + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: 'diff --git a/README.md b/README.md\nindex c1dc7b34c381ad6f417bb3f11dba4b1e8f076ff4..b48d45443e349c6dd113da4bb7546504a07a5cce 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,6 +1,6 @@\n # another-test-project\n \n-\n+This is another test project for testing stuff.\n \n ## Getting started\n \n' + headers: + Content-Type: + - text/plain + status: 200 OK + code: 200 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/open_mergeable.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/open_mergeable.yaml new file mode 100644 index 0000000000..8e59d87d56 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/open_mergeable.yaml @@ -0,0 +1,30 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Authorization: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/3/raw_diffs + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: 'diff --git a/README.md b/README.md\nindex 6e58dc2a1e909f3454154f1e8a9f69a4de8198ba..29ea424e45078bbf94f921c281e894c7c97777cc 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,6 +1,6 @@\n # test-project\n \n-\n+This is a test project for testing things.\n \n ## Next Steps\n \n' + headers: + Content-Type: + - text/plain + status: 200 OK + code: 200 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/open_with_conflicts.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/open_with_conflicts.yaml new file mode 100644 index 0000000000..8a79dd66a7 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestDiff/open_with_conflicts.yaml @@ -0,0 +1,30 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Authorization: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/2/raw_diffs + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: 'diff --git a/README.md b/README.md\nindex 021416c15be3198c727d9a1d5a9e233f40caa940..a48adb327c52e95878f32c0ab39e9ff4c29954e0 100644\n--- a/README.md\n+++ b/README.md\n@@ -2,7 +2,7 @@\n \n \n \n-## Getting started\n+## What Next\n \n To make it easy for you to get started with GitLab, here''s a list of recommended next steps.\n \n' + headers: + Content-Type: + - text/plain + status: 200 OK + code: 200 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/nested_closed_from_fork.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/nested_closed_from_fork.yaml new file mode 100644 index 0000000000..ac3c854f90 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/nested_closed_from_fork.yaml @@ -0,0 +1,343 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/3 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":486265263,"iid":3,"project_id":82312037,"title":"Nested closed from fork","description":"","state":"closed","created_at":"2026-05-18T15:31:35.464Z","updated_at":"2026-05-18T15:31:51.925Z","merged_by":null,"merge_user":null,"merged_at":null,"closed_by":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"closed_at":"2026-05-18T15:31:51.941Z","target_branch":"main","source_branch":"forked","user_notes_count":0,"upvotes":0,"downvotes":0,"author":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"assignees":[{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"}],"assignee":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"reviewers":[],"source_project_id":82312091,"target_project_id":82312037,"labels":[],"draft":false,"imported":false,"imported_from":"none","work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","detailed_merge_status":"not_open","merge_after":null,"sha":"6b743c6728fa248e3654657e0e576eafcf472953","merge_commit_sha":null,"squash_commit_sha":null,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":true,"prepared_at":"2026-05-18T15:31:37.673Z","allow_collaboration":true,"allow_maintainer_to_push":true,"reference":"!3","references":{"short":"!3","relative":"!3","full":"test-group9945421/test-subgroup/another-test-project!3"},"web_url":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/3","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"squash_on_merge":false,"task_completion_status":{"count":0,"completed_count":0},"has_conflicts":false,"blocking_discussions_resolved":true,"approvals_before_merge":null,"subscribed":false,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"head_pipeline":null,"diff_refs":{"base_sha":"76b308af8b4711f47887c6862607f6d5924f47c0","head_sha":"6b743c6728fa248e3654657e0e576eafcf472953","start_sha":"76b308af8b4711f47887c6862607f6d5924f47c0"},"merge_error":null,"first_contribution":false,"user":{"can_merge":false}}' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + status: 200 OK + code: 200 + duration: 264.50708ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/3/approvals + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":486265263,"iid":3,"project_id":82312037,"title":"Nested closed from fork","description":"","state":"closed","created_at":"2026-05-18T15:31:35.464Z","updated_at":"2026-05-18T15:31:51.925Z","merge_status":"can_be_merged","approved":true,"approvals_required":0,"approvals_left":0,"require_password_to_approve":false,"approved_by":[],"suggested_approvers":[],"approvers":[],"approver_groups":[],"user_has_approved":false,"user_can_approve":false,"approval_rules_left":[],"has_approval_rules":false,"merge_request_approvers_available":false,"multiple_approval_rules_available":false,"invalid_approvers_rules":[]}' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + status: 200 OK + code: 200 + duration: 219.958602ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + form: + per_page: + - "100" + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/3/commits?per_page=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"id":"6b743c6728fa248e3654657e0e576eafcf472953","short_id":"6b743c67","created_at":"2026-05-18T15:23:06.000+00:00","parent_ids":["76b308af8b4711f47887c6862607f6d5924f47c0"],"title":"Nested closed","message":"Nested closed","author_name":"Cian Johnston","author_email":"public@cianjohnston.ie","authored_date":"2026-05-18T15:23:06.000+00:00","committer_name":"Cian Johnston","committer_email":"public@cianjohnston.ie","committed_date":"2026-05-18T15:23:06.000+00:00","trailers":{},"extended_trailers":{},"web_url":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/commit/6b743c6728fa248e3654657e0e576eafcf472953"}]' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Link: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Next-Page: + - stripped + X-Page: + - stripped + X-Per-Page: + - stripped + X-Prev-Page: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + X-Total: + - "1" + X-Total-Pages: + - stripped + status: 200 OK + code: 200 + duration: 209.568896ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + form: + per_page: + - "100" + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/3/diffs?per_page=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"diff":"@@ -2,6 +2,8 @@\n \n This is another test project for testing stuff.\n \n+Here''s a change. Might not merge it.\n+\n ## Getting started\n \n To make it easy for you to get started with GitLab, here''s a list of recommended next steps.\n","collapsed":false,"too_large":false,"new_path":"README.md","old_path":"README.md","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"generated_file":false}]' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Link: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Next-Page: + - stripped + X-Page: + - stripped + X-Per-Page: + - stripped + X-Prev-Page: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + X-Total: + - "1" + X-Total-Pages: + - stripped + status: 200 OK + code: 200 + duration: 343.393368ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/nested_merged.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/nested_merged.yaml new file mode 100644 index 0000000000..022a45d2ed --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/nested_merged.yaml @@ -0,0 +1,345 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/1 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":486261628,"iid":1,"project_id":82312037,"title":"Nested merged","description":"","state":"merged","created_at":"2026-05-18T15:21:59.875Z","updated_at":"2026-05-18T15:22:07.620Z","merged_by":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"merge_user":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"merged_at":"2026-05-18T15:22:07.165Z","closed_by":null,"closed_at":null,"target_branch":"main","source_branch":"johnstcn-main-patch-54711","user_notes_count":0,"upvotes":0,"downvotes":0,"author":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"assignees":[{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"}],"assignee":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"reviewers":[],"source_project_id":82312037,"target_project_id":82312037,"labels":[],"draft":false,"imported":false,"imported_from":"none","work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","detailed_merge_status":"not_open","merge_after":null,"sha":"ff919f3dc418e4fbffb6fbded7b4c9ae60a4531b","merge_commit_sha":"76b308af8b4711f47887c6862607f6d5924f47c0","squash_commit_sha":null,"discussion_locked":null,"should_remove_source_branch":true,"force_remove_source_branch":true,"prepared_at":"2026-05-18T15:22:02.380Z","reference":"!1","references":{"short":"!1","relative":"!1","full":"test-group9945421/test-subgroup/another-test-project!1"},"web_url":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/merge_requests/1","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"squash_on_merge":false,"task_completion_status":{"count":0,"completed_count":0},"has_conflicts":false,"blocking_discussions_resolved":true,"approvals_before_merge":null,"subscribed":false,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"head_pipeline":null,"diff_refs":{"base_sha":"ecd06ae70b01b8185c16bddb19db6e7e000e6fc3","head_sha":"ff919f3dc418e4fbffb6fbded7b4c9ae60a4531b","start_sha":"ecd06ae70b01b8185c16bddb19db6e7e000e6fc3"},"merge_error":null,"first_contribution":true,"user":{"can_merge":false}}' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + status: 200 OK + code: 200 + duration: 255.584981ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/1/approvals + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":486261628,"iid":1,"project_id":82312037,"title":"Nested merged","description":"","state":"merged","created_at":"2026-05-18T15:21:59.875Z","updated_at":"2026-05-18T15:22:07.620Z","merge_status":"can_be_merged","approved":true,"approvals_required":0,"approvals_left":0,"require_password_to_approve":false,"approved_by":[],"suggested_approvers":[],"approvers":[],"approver_groups":[],"user_has_approved":false,"user_can_approve":false,"approval_rules_left":[],"has_approval_rules":false,"merge_request_approvers_available":false,"multiple_approval_rules_available":false,"invalid_approvers_rules":[]}' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + status: 200 OK + code: 200 + duration: 238.750519ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + form: + per_page: + - "100" + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/1/commits?per_page=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"id":"ff919f3dc418e4fbffb6fbded7b4c9ae60a4531b","short_id":"ff919f3d","created_at":"2026-05-18T15:21:50.000+00:00","parent_ids":["ecd06ae70b01b8185c16bddb19db6e7e000e6fc3"],"title":"Nested merged","message":"Nested merged","author_name":"Cian Johnston","author_email":"public@cianjohnston.ie","authored_date":"2026-05-18T15:21:50.000+00:00","committer_name":"Cian Johnston","committer_email":"public@cianjohnston.ie","committed_date":"2026-05-18T15:21:50.000+00:00","trailers":{},"extended_trailers":{},"web_url":"https://gitlab.com/test-group9945421/test-subgroup/another-test-project/-/commit/ff919f3dc418e4fbffb6fbded7b4c9ae60a4531b"}]' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Link: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Next-Page: + - stripped + X-Page: + - stripped + X-Per-Page: + - stripped + X-Prev-Page: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + X-Total: + - "1" + X-Total-Pages: + - stripped + status: 200 OK + code: 200 + duration: 243.115989ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + form: + per_page: + - "100" + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests/1/diffs?per_page=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"diff":"@@ -1,6 +1,6 @@\n # another-test-project\n \n-\n+This is another test project for testing stuff.\n \n ## Getting started\n \n","collapsed":false,"too_large":false,"new_path":"README.md","old_path":"README.md","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"generated_file":false}]' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Link: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Next-Page: + - stripped + X-Page: + - stripped + X-Per-Page: + - stripped + X-Prev-Page: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + X-Total: + - "1" + X-Total-Pages: + - stripped + status: 200 OK + code: 200 + duration: 271.552894ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/open_mergeable.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/open_mergeable.yaml new file mode 100644 index 0000000000..b1de467d7f --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/open_mergeable.yaml @@ -0,0 +1,345 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/3 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":486249709,"iid":3,"project_id":82310987,"title":"Open mergeable","description":"","state":"opened","created_at":"2026-05-18T14:53:54.688Z","updated_at":"2026-05-18T14:53:55.972Z","merged_by":null,"merge_user":null,"merged_at":null,"closed_by":null,"closed_at":null,"target_branch":"main","source_branch":"johnstcn-main-patch-98822","user_notes_count":0,"upvotes":0,"downvotes":0,"author":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"assignees":[{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"}],"assignee":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"reviewers":[],"source_project_id":82310987,"target_project_id":82310987,"labels":[],"draft":false,"imported":false,"imported_from":"none","work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","merge_after":null,"sha":"da57fca657e02c1fbe131402f927d134a34b257b","merge_commit_sha":null,"squash_commit_sha":null,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":true,"prepared_at":"2026-05-18T14:53:55.966Z","reference":"!3","references":{"short":"!3","relative":"!3","full":"test-group9945421/test-project!3"},"web_url":"https://gitlab.com/test-group9945421/test-project/-/merge_requests/3","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"squash_on_merge":false,"task_completion_status":{"count":0,"completed_count":0},"has_conflicts":false,"blocking_discussions_resolved":true,"approvals_before_merge":null,"subscribed":false,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"head_pipeline":null,"diff_refs":{"base_sha":"bc2d14403364db33c7811b29598509b8cf0223c4","head_sha":"da57fca657e02c1fbe131402f927d134a34b257b","start_sha":"bc2d14403364db33c7811b29598509b8cf0223c4"},"merge_error":null,"first_contribution":false,"user":{"can_merge":false}}' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + status: 200 OK + code: 200 + duration: 381.20188ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/3/approvals + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":486249709,"iid":3,"project_id":82310987,"title":"Open mergeable","description":"","state":"opened","created_at":"2026-05-18T14:53:54.688Z","updated_at":"2026-05-18T14:53:55.972Z","merge_status":"can_be_merged","approved":true,"approvals_required":0,"approvals_left":0,"require_password_to_approve":false,"approved_by":[],"suggested_approvers":[],"approvers":[],"approver_groups":[],"user_has_approved":false,"user_can_approve":false,"approval_rules_left":[],"has_approval_rules":false,"merge_request_approvers_available":false,"multiple_approval_rules_available":false,"invalid_approvers_rules":[]}' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + status: 200 OK + code: 200 + duration: 196.210578ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + form: + per_page: + - "100" + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/3/commits?per_page=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"id":"da57fca657e02c1fbe131402f927d134a34b257b","short_id":"da57fca6","created_at":"2026-05-18T14:53:46.000+00:00","parent_ids":["bc2d14403364db33c7811b29598509b8cf0223c4"],"title":"Open mergeable","message":"Open mergeable","author_name":"Cian Johnston","author_email":"public@cianjohnston.ie","authored_date":"2026-05-18T14:53:46.000+00:00","committer_name":"Cian Johnston","committer_email":"public@cianjohnston.ie","committed_date":"2026-05-18T14:53:46.000+00:00","trailers":{},"extended_trailers":{},"web_url":"https://gitlab.com/test-group9945421/test-project/-/commit/da57fca657e02c1fbe131402f927d134a34b257b"}]' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Link: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Next-Page: + - stripped + X-Page: + - stripped + X-Per-Page: + - stripped + X-Prev-Page: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + X-Total: + - "1" + X-Total-Pages: + - stripped + status: 200 OK + code: 200 + duration: 217.874878ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + form: + per_page: + - "100" + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/3/diffs?per_page=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"diff":"@@ -1,6 +1,6 @@\n # test-project\n \n-\n+This is a test project for testing things.\n \n ## Next Steps\n \n","collapsed":false,"too_large":false,"new_path":"README.md","old_path":"README.md","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"generated_file":false}]' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Link: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Next-Page: + - stripped + X-Page: + - stripped + X-Per-Page: + - stripped + X-Prev-Page: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + X-Total: + - "1" + X-Total-Pages: + - stripped + status: 200 OK + code: 200 + duration: 266.716685ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/open_with_conflicts.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/open_with_conflicts.yaml new file mode 100644 index 0000000000..fceea56cbc --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/FetchPullRequestStatus/open_with_conflicts.yaml @@ -0,0 +1,345 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/2 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":486248759,"iid":2,"project_id":82310987,"title":"Open with conflicts","description":"","state":"opened","created_at":"2026-05-18T14:51:51.015Z","updated_at":"2026-05-18T14:53:08.449Z","merged_by":null,"merge_user":null,"merged_at":null,"closed_by":null,"closed_at":null,"target_branch":"main","source_branch":"johnstcn-main-patch-84369","user_notes_count":0,"upvotes":0,"downvotes":0,"author":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"assignees":[{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"}],"assignee":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"reviewers":[],"source_project_id":82310987,"target_project_id":82310987,"labels":[],"draft":false,"imported":false,"imported_from":"none","work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"cannot_be_merged","detailed_merge_status":"conflict","merge_after":null,"sha":"642379758fa148ff24cba5f676226a3f8e560d73","merge_commit_sha":null,"squash_commit_sha":null,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":true,"prepared_at":"2026-05-18T14:51:52.481Z","reference":"!2","references":{"short":"!2","relative":"!2","full":"test-group9945421/test-project!2"},"web_url":"https://gitlab.com/test-group9945421/test-project/-/merge_requests/2","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"squash_on_merge":false,"task_completion_status":{"count":0,"completed_count":0},"has_conflicts":true,"blocking_discussions_resolved":true,"approvals_before_merge":null,"subscribed":false,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"head_pipeline":null,"diff_refs":{"base_sha":"c71f88a175d4b5506805edb70b43c5885f087860","head_sha":"642379758fa148ff24cba5f676226a3f8e560d73","start_sha":"c71f88a175d4b5506805edb70b43c5885f087860"},"merge_error":null,"first_contribution":false,"user":{"can_merge":false}}' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + status: 200 OK + code: 200 + duration: 295.911218ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/2/approvals + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"id":486248759,"iid":2,"project_id":82310987,"title":"Open with conflicts","description":"","state":"opened","created_at":"2026-05-18T14:51:51.015Z","updated_at":"2026-05-18T14:53:08.449Z","merge_status":"cannot_be_merged","approved":true,"approvals_required":0,"approvals_left":0,"require_password_to_approve":false,"approved_by":[],"suggested_approvers":[],"approvers":[],"approver_groups":[],"user_has_approved":false,"user_can_approve":false,"approval_rules_left":[],"has_approval_rules":false,"merge_request_approvers_available":false,"multiple_approval_rules_available":false,"invalid_approvers_rules":[]}' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + status: 200 OK + code: 200 + duration: 188.621935ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + form: + per_page: + - "100" + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/2/commits?per_page=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"id":"642379758fa148ff24cba5f676226a3f8e560d73","short_id":"64237975","created_at":"2026-05-18T14:51:45.000+00:00","parent_ids":["c71f88a175d4b5506805edb70b43c5885f087860"],"title":"Edit README.md","message":"Edit README.md","author_name":"Cian Johnston","author_email":"public@cianjohnston.ie","authored_date":"2026-05-18T14:51:45.000+00:00","committer_name":"Cian Johnston","committer_email":"public@cianjohnston.ie","committed_date":"2026-05-18T14:51:45.000+00:00","trailers":{},"extended_trailers":{},"web_url":"https://gitlab.com/test-group9945421/test-project/-/commit/642379758fa148ff24cba5f676226a3f8e560d73"}]' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Link: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Next-Page: + - stripped + X-Page: + - stripped + X-Per-Page: + - stripped + X-Prev-Page: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + X-Total: + - "1" + X-Total-Pages: + - stripped + status: 200 OK + code: 200 + duration: 231.443536ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + form: + per_page: + - "100" + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests/2/diffs?per_page=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"diff":"@@ -2,7 +2,7 @@\n \n \n \n-## Getting started\n+## What Next\n \n To make it easy for you to get started with GitLab, here''s a list of recommended next steps.\n \n","collapsed":false,"too_large":false,"new_path":"README.md","old_path":"README.md","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"generated_file":false}]' + headers: + Cache-Control: + - stripped + Cf-Cache-Status: + - stripped + Cf-Ray: + - stripped + Content-Security-Policy: + - stripped + Content-Type: + - application/json + Date: + - stripped + Etag: + - stripped + Gitlab-Lb: + - stripped + Gitlab-Sv: + - stripped + Link: + - stripped + Nel: + - stripped + Ratelimit-Limit: + - stripped + Ratelimit-Name: + - stripped + Ratelimit-Observed: + - stripped + Ratelimit-Remaining: + - stripped + Ratelimit-Reset: + - stripped + Referrer-Policy: + - stripped + Server: + - stripped + Set-Cookie: + - stripped + Strict-Transport-Security: + - stripped + Vary: + - stripped + X-Content-Type-Options: + - stripped + X-Frame-Options: + - stripped + X-Gitlab-Meta: + - stripped + X-Next-Page: + - stripped + X-Page: + - stripped + X-Per-Page: + - stripped + X-Prev-Page: + - stripped + X-Request-Id: + - stripped + X-Runtime: + - stripped + X-Total: + - "1" + X-Total-Pages: + - stripped + status: 200 OK + code: 200 + duration: 244.621276ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/nested_branch_deleted_after_merge.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/nested_branch_deleted_after_merge.yaml new file mode 100644 index 0000000000..04e1a4ae34 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/nested_branch_deleted_after_merge.yaml @@ -0,0 +1,32 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests?order_by=updated_at&per_page=1&sort=desc&source_branch=johnstcn-main-patch-54711&state=opened + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[]' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/nested_fork_branch_not_on_target.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/nested_fork_branch_not_on_target.yaml new file mode 100644 index 0000000000..251bc52882 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/nested_fork_branch_not_on_target.yaml @@ -0,0 +1,32 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-subgroup%2Fanother-test-project/merge_requests?order_by=updated_at&per_page=1&sort=desc&source_branch=forked&state=opened + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[]' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100.000000ms diff --git a/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/open_mr_branch.yaml b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/open_mr_branch.yaml new file mode 100644 index 0000000000..6fde6a9014 --- /dev/null +++ b/coderd/externalauth/gitprovider/testdata/gitlab_cassettes/ResolveBranchPullRequest/open_mr_branch.yaml @@ -0,0 +1,32 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + Accept: + - application/json + Private-Token: + - stripped + User-Agent: + - stripped + url: https://gitlab.com/api/v4/projects/test-group9945421%2Ftest-project/merge_requests?order_by=updated_at&per_page=1&sort=desc&source_branch=johnstcn-main-patch-98822&state=opened + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"id":486249709,"iid":3,"project_id":82310987,"title":"Open mergeable","description":"","state":"opened","created_at":"2026-05-18T14:53:54.688Z","updated_at":"2026-05-18T14:53:55.972Z","merged_by":null,"merge_user":null,"merged_at":null,"closed_by":null,"closed_at":null,"target_branch":"main","source_branch":"johnstcn-main-patch-98822","user_notes_count":0,"upvotes":0,"downvotes":0,"author":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"assignees":[{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"}],"assignee":{"id":687093,"username":"johnstcn","public_email":"","name":"Cian Johnston","state":"active","locked":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/687093/avatar.png","web_url":"https://gitlab.com/johnstcn"},"reviewers":[],"source_project_id":82310987,"target_project_id":82310987,"labels":[],"draft":false,"imported":false,"imported_from":"none","work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","merge_after":null,"sha":"da57fca657e02c1fbe131402f927d134a34b257b","merge_commit_sha":null,"squash_commit_sha":null,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":true,"prepared_at":"2026-05-18T14:53:55.966Z","reference":"!3","references":{"short":"!3","relative":"!3","full":"johnstcn/test-project!3"},"web_url":"https://gitlab.com/test-group9945421/test-project/-/merge_requests/3","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"squash_on_merge":false,"task_completion_status":{"count":0,"completed_count":0},"has_conflicts":false,"blocking_discussions_resolved":true,"approvals_before_merge":null}]' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100.000000ms diff --git a/coderd/x/gitsync/gitsync.go b/coderd/x/gitsync/gitsync.go index 6d2090b86e..ccfcf80d62 100644 --- a/coderd/x/gitsync/gitsync.go +++ b/coderd/x/gitsync/gitsync.go @@ -30,7 +30,7 @@ const ( // ProviderResolver maps a git remote origin to the gitprovider // that handles it. Returns nil if no provider matches. -type ProviderResolver func(origin string) gitprovider.Provider +type ProviderResolver func(ctx context.Context, origin string) gitprovider.Provider var ErrNoTokenAvailable error = errors.New("no token available") @@ -159,7 +159,7 @@ func (r *Refresher) Refresh( // duplicate resolution for rows in the same group. var resolved []resolvedGroup for key, indices := range groups { - provider := r.providers(key.origin) + provider := r.providers(ctx, key.origin) if provider == nil { err := xerrors.Errorf("no provider for origin %q", key.origin) for _, i := range indices { diff --git a/coderd/x/gitsync/gitsync_test.go b/coderd/x/gitsync/gitsync_test.go index 1033865df1..d181e3875f 100644 --- a/coderd/x/gitsync/gitsync_test.go +++ b/coderd/x/gitsync/gitsync_test.go @@ -128,7 +128,7 @@ func TestRefresher_WithPRURL(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } @@ -184,7 +184,7 @@ func TestRefresher_BranchResolvesToPR(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } @@ -226,7 +226,7 @@ func TestRefresher_BranchNoPRYet(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } @@ -255,7 +255,7 @@ func TestRefresher_BranchNoPRYet(t *testing.T) { func TestRefresher_NoProviderForOrigin(t *testing.T) { t.Parallel() - providers := func(_ string) gitprovider.Provider { return nil } + providers := func(_ context.Context, _ string) gitprovider.Provider { return nil } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } @@ -296,7 +296,7 @@ func TestRefresher_TokenResolutionFails(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return nil, errors.New("token lookup failed") } @@ -328,7 +328,7 @@ func TestRefresher_EmptyToken(t *testing.T) { mp := &mockProvider{} - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref(""), nil } @@ -366,7 +366,7 @@ func TestRefresher_ProviderFetchFails(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } @@ -402,7 +402,7 @@ func TestRefresher_PRURLParseFailure(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } @@ -440,7 +440,7 @@ func TestRefresher_BatchGroupsByOwnerAndOrigin(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } var tokenCalls atomic.Int32 tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { @@ -522,7 +522,7 @@ func TestRefresher_UsesInjectedClock(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } @@ -574,7 +574,7 @@ func TestRefresher_RateLimitSkipsRemainingInGroup(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } @@ -695,7 +695,7 @@ func TestRefresher_CorrectTokenPerOrigin(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal()) @@ -780,7 +780,7 @@ func TestRefresher_ConcurrentProcessing(t *testing.T) { }, } - providers := func(_ string) gitprovider.Provider { return mp } + providers := func(_ context.Context, _ string) gitprovider.Provider { return mp } tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) { return ptr.Ref("test-token"), nil } diff --git a/coderd/x/gitsync/worker_test.go b/coderd/x/gitsync/worker_test.go index dabe0d1e8e..e2a90a3bf0 100644 --- a/coderd/x/gitsync/worker_test.go +++ b/coderd/x/gitsync/worker_test.go @@ -82,7 +82,7 @@ func newTestRefresher(t *testing.T, clk quartz.Clock, opts ...testRefresherOpt) }, } - providers := func(string) gitprovider.Provider { return prov } + providers := func(context.Context, string) gitprovider.Provider { return prov } tokens := func(context.Context, uuid.UUID, string) (*string, error) { return ptr.Ref("tok"), nil } @@ -1120,7 +1120,7 @@ func TestRefreshChat_RefreshError(t *testing.T) { // UpsertChatDiffStatus should NOT be called. // Provider resolver returns nil → "no provider" error. - providers := func(string) gitprovider.Provider { return nil } + providers := func(context.Context, string) gitprovider.Provider { return nil } tokens := func(context.Context, uuid.UUID, string) (*string, error) { return ptr.Ref("tok"), nil } @@ -1205,7 +1205,7 @@ func TestWorker_NoTokenBackoff(t *testing.T) { // Token resolver returns empty token → ErrNoTokenAvailable. // Provider methods should never be called. prov := &mockProvider{} - providers := func(string) gitprovider.Provider { return prov } + providers := func(context.Context, string) gitprovider.Provider { return prov } tokens := func(context.Context, uuid.UUID, string) (*string, error) { return ptr.Ref(""), nil } diff --git a/docs/ai-coder/agents/platform-controls/git-providers.md b/docs/ai-coder/agents/platform-controls/git-providers.md index 65ea46f988..8b6e03a14d 100644 --- a/docs/ai-coder/agents/platform-controls/git-providers.md +++ b/docs/ai-coder/agents/platform-controls/git-providers.md @@ -6,9 +6,6 @@ to power the in-chat diff viewer. Self-hosted GitHub Enterprise deployments require one additional setting (`API_BASE_URL`) for this feature to work. -> [!NOTE] -> Only `github` type external auth providers are supported today. - ## GitHub Enterprise configuration For public `github.com`, no additional configuration is needed. @@ -37,6 +34,66 @@ patterns from the API base URL. > If you have both a `github.com` and a GHE external auth config, only the > GHE config needs `API_BASE_URL`. +## GitLab configuration + +For `gitlab.com`, no additional `API_BASE_URL` is needed. Coder +automatically derives it from your `AUTH_URL` for self-hosted instances. + +### Required scopes + +The default GitLab scopes (`read_user`) are sufficient for basic +authentication. To use merge request features (diffs, status checks) with +Coder Agents, configure: + +```env +CODER_EXTERNAL_AUTH_0_ID="primary-gitlab" +CODER_EXTERNAL_AUTH_0_TYPE=gitlab +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_SCOPES="write_repository read_api" +``` + +The `read_api` scope grants read access to the API (needed for fetching +merge request metadata and diffs). The `write_repository` scope allows +pushing commits and creating merge requests. + +### Self-hosted GitLab + +For self-hosted GitLab, set `AUTH_URL` and `TOKEN_URL` to your instance. +Coder derives `API_BASE_URL` automatically from `AUTH_URL`: + +```env +CODER_EXTERNAL_AUTH_0_ID="primary-gitlab" +CODER_EXTERNAL_AUTH_0_TYPE=gitlab +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_AUTH_URL="https://gitlab.example.com/oauth/authorize" +CODER_EXTERNAL_AUTH_0_TOKEN_URL="https://gitlab.example.com/oauth/token" +CODER_EXTERNAL_AUTH_0_SCOPES="write_repository read_api" +CODER_EXTERNAL_AUTH_0_REGEX=gitlab\.example\.com +``` + +> [!NOTE] +> You may also set `API_BASE_URL` explicitly if needed (e.g., +> `https://gitlab.example.com/api/v4`), but this is usually unnecessary. + +## Known limitations + +### GitLab + +The GitLab provider has some semantic differences compared to the GitHub +provider: + +- **Approved** uses GitLab's threshold-based approval (e.g., "all required + approvals met") rather than GitHub's "at least one approval and no changes + requested" model. +- **Changes requested** has no GitLab equivalent. This field is always + reported as `false`. +- **Reviewer count** only counts users who have approved, not all assigned + reviewers. + +These gaps are tracked internally and may be refined in future releases. + ## Troubleshooting ### Diffs not appearing on GHE diff --git a/go.mod b/go.mod index afbedf22f2..cfaf45c729 100644 --- a/go.mod +++ b/go.mod @@ -341,13 +341,13 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/nftables v0.2.0 // indirect github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -521,7 +521,9 @@ require ( github.com/smallstep/pkcs7 v0.2.1 github.com/sony/gobreaker/v2 v2.4.0 github.com/tidwall/sjson v1.2.5 + gitlab.com/gitlab-org/api/client-go v1.46.0 gonum.org/v1/gonum v0.17.0 + gopkg.in/dnaeon/go-vcr.v4 v4.0.6 mvdan.cc/sh/v3 v3.13.1 ) diff --git a/go.sum b/go.sum index a290649c90..5371c24816 100644 --- a/go.sum +++ b/go.sum @@ -593,8 +593,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= @@ -641,7 +642,6 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -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.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -653,8 +653,8 @@ github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 h1:DdHws/Y github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405/go.mod h1:4RgUDSnsxP19d65zJWqvqJ/poJxBCvmna50eXmIvoR8= github.com/google/go-github/v61 v61.0.0 h1:VwQCBwhyE9JclCI+22/7mLB1PuU9eowCXKY5pNlu1go= github.com/google/go-github/v61 v61.0.0/go.mod h1:0WR+KmsWX75G2EbpyGsGmradjo3IiciuI4BmdVCobQY= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -679,6 +679,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= +github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo= @@ -1274,6 +1276,8 @@ github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= +gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.nhat.io/otelsql v0.16.0 h1:MUKhNSl7Vk1FGyopy04FBDimyYogpRFs0DBB9frQal0= go.nhat.io/otelsql v0.16.0/go.mod h1:YB2ocf0Q8+kK4kxzXYUOHj7P2Km8tNmE2QlRS0frUtc= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -1547,6 +1551,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=