Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ccae998
feat: add GitLab support to externalauth/gitprovider
johnstcn May 15, 2026
c081c56
fixup! feat: add GitLab support to externalauth/gitprovider
johnstcn May 15, 2026
37ed044
fixup! fixup! feat: add GitLab support to externalauth/gitprovider
johnstcn May 15, 2026
90c2cb5
fix: handle ErrNoTokenAvailable gracefully in diff handler
johnstcn May 18, 2026
0977b25
fix: address review findings (DEREM-1 through DEREM-13)
johnstcn May 18, 2026
c85b9eb
feat: populate Commits/Additions/Deletions via MR commits endpoint
johnstcn May 18, 2026
bb1701a
test: replace GitLab VCR cassettes with dedicated test fixtures
johnstcn May 18, 2026
0a19642
fix: address review findings (DEREM-4, DEREM-8, DEREM-9)
johnstcn May 18, 2026
afe59a5
refactor: use errors.AsType for type-safe error assertions
johnstcn May 18, 2026
15892bd
fix: address R2 review findings (DEREM-25 through DEREM-29)
johnstcn May 18, 2026
b7d5be2
Merge branch 'main' into cj/gitsync-gitlab
johnstcn May 18, 2026
a3a5fee
Merge remote-tracking branch 'origin/main' into cj/gitsync-gitlab
johnstcn May 22, 2026
a798071
feat(scripts/develop): load .env file for local dev configuration
johnstcn May 22, 2026
26c5b59
fix(scripts/develop): load .env before option parsing
johnstcn May 22, 2026
00c0e83
fix: derive GitLab APIBaseURL from AuthURL for self-hosted instances
johnstcn May 25, 2026
833488d
fix: compute GitLab MR additions/deletions from diffs endpoint
johnstcn May 25, 2026
aee62c4
refactor: tidy gitprovider helpers
johnstcn May 25, 2026
fd5e79b
Merge remote-tracking branch 'origin/main' into cj/gitsync-gitlab
johnstcn May 25, 2026
4cec8b5
fix: use checkRateLimitError in FetchBranchDiff
johnstcn May 25, 2026
244cbcc
fix: add ---/+++ headers to FetchBranchDiff output
johnstcn May 25, 2026
b073470
chore: add TODO comments for known limitations
johnstcn May 25, 2026
804009c
fix: add mapGitLabState test and log truncated diffs
johnstcn May 25, 2026
2eafcba
docs: document GitLab known limitations
johnstcn May 25, 2026
4f041a9
fix: restore Checking logs section in git-providers docs
johnstcn May 25, 2026
85e3d3b
Apply suggestion from @johnstcn
johnstcn May 25, 2026
f642982
test: assert Approved, ReviewerCount, ChangesRequested in FetchPullRe…
johnstcn May 25, 2026
7eabc99
fix: include response body in FetchBranchDiff error diagnostics
johnstcn May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ agent/agentcontainers/acmock/acmock.go linguist-generated=true
agent/agentcontainers/dcspec/dcspec_gen.go linguist-generated=true
agent/agentcontainers/testdata/devcontainercli/*/*.log linguist-generated=true
coderd/apidoc/docs.go linguist-generated=true
coderd/externalauth/gitprovider/testdata/*/*/*.yaml linguist-generated=true
docs/reference/api/*.md linguist-generated=true
docs/reference/cli/*.md linguist-generated=true
coderd/apidoc/swagger.json linguist-generated=true
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,6 @@ extend-exclude = [
"agent/agentcontainers/testdata/devcontainercli/**",
# aibridge fixtures contain truncated streaming chunks that look like typos
"aibridge/fixtures/**",
# go-vcr cassettes contain real API responses with 3rd-party content
"coderd/externalauth/gitprovider/testdata/**",
]
2 changes: 1 addition & 1 deletion coderd/database/db2sdk/db2sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [DEREM-7] This hardcodes "github" as the fallback provider. With GitLab now supported, any GitLab-hosted GitRemoteOrigin stored in ChatDiffStatus will fail ParseRepositoryOrigin (host mismatch) and produce no branch URL. The pre-existing TODO acknowledges the GitHub Enterprise gap; GitLab widens it.

This needs a human decision: either accept the gap and document it, or iterate over configured providers. The fix requires access to external auth configs at the db2sdk layer, which is a broader refactor.

(Mafuuu P3, Ryosuke P3)

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. This is pre-existing behavior unrelated to the GitLab provider addition; the db2sdk.go fallback to "github" needs a broader refactor (iterating over configured providers). Out of scope for this PR.

🤖 Generated with Coder Agents

if gp != nil {
if owner, repo, _, ok := gp.ParseRepositoryOrigin(status.GitRemoteOrigin); ok {
branchURL := gp.BuildBranchURL(owner, repo, status.GitBranch)
Expand Down
44 changes: 28 additions & 16 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Comment thread
johnstcn marked this conversation as resolved.
// (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
Expand All @@ -4252,17 +4253,28 @@ 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
}

// 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
}

Expand Down
18 changes: 12 additions & 6 deletions coderd/externalauth/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
johnstcn marked this conversation as resolved.
}
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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)},
}

Expand Down
4 changes: 3 additions & 1 deletion coderd/externalauth/externalauth_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
}
}
Expand Down Expand Up @@ -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"
},
},
{
Expand All @@ -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"
},
},
}
Expand Down
14 changes: 14 additions & 0 deletions coderd/externalauth/externalauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 5 additions & 39 deletions coderd/externalauth/gitprovider/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"regexp"
"strconv"
"strings"
"time"

"golang.org/x/xerrors"

Expand All @@ -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 {
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading