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
This commit is contained in:
Cian Johnston
2026-05-25 17:41:02 +01:00
committed by GitHub
parent 2ad2f7869d
commit 579daaff70
34 changed files with 4146 additions and 179 deletions
+1 -1
View File
@@ -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)
+28 -16
View File
@@ -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
}
+12 -6
View File
@@ -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)},
}
@@ -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"
},
},
}
+14
View File
@@ -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
+5 -39
View File
@@ -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
+64 -84
View File
@@ -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)
})
}
+681
View File
@@ -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
}
@@ -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=<pat> 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)
})
}
})
}
@@ -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)
})
}
+74 -5
View File
@@ -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.
@@ -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)
})
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+2 -2
View File
@@ -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 {
+13 -13
View File
@@ -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
}
+3 -3
View File
@@ -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
}