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
+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.