mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +00:00
579daaff70
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
976 lines
28 KiB
Go
976 lines
28 KiB
Go
package gitprovider_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
func TestGitHubParseRepositoryOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
gp, err := gitprovider.New("github", "", nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
expectOK bool
|
|
expectOwner string
|
|
expectRepo string
|
|
expectNormalized string
|
|
}{
|
|
{
|
|
name: "HTTPS URL",
|
|
raw: "https://github.com/coder/coder",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNormalized: "https://github.com/coder/coder",
|
|
},
|
|
{
|
|
name: "HTTPS URL with .git",
|
|
raw: "https://github.com/coder/coder.git",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNormalized: "https://github.com/coder/coder",
|
|
},
|
|
{
|
|
name: "HTTPS URL with trailing slash",
|
|
raw: "https://github.com/coder/coder/",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNormalized: "https://github.com/coder/coder",
|
|
},
|
|
{
|
|
name: "SSH URL",
|
|
raw: "git@github.com:coder/coder.git",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNormalized: "https://github.com/coder/coder",
|
|
},
|
|
{
|
|
name: "SSH URL without .git",
|
|
raw: "git@github.com:coder/coder",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNormalized: "https://github.com/coder/coder",
|
|
},
|
|
{
|
|
name: "SSH URL with ssh:// prefix",
|
|
raw: "ssh://git@github.com/coder/coder.git",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNormalized: "https://github.com/coder/coder",
|
|
},
|
|
{
|
|
name: "GitLab URL does not match",
|
|
raw: "https://gitlab.com/coder/coder",
|
|
expectOK: false,
|
|
},
|
|
{
|
|
name: "Empty string",
|
|
raw: "",
|
|
expectOK: false,
|
|
},
|
|
{
|
|
name: "Not a URL",
|
|
raw: "not-a-url",
|
|
expectOK: false,
|
|
},
|
|
{
|
|
name: "Hyphenated owner and repo",
|
|
raw: "https://github.com/my-org/my-repo.git",
|
|
expectOK: true,
|
|
expectOwner: "my-org",
|
|
expectRepo: "my-repo",
|
|
expectNormalized: "https://github.com/my-org/my-repo",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
owner, repo, normalized, ok := gp.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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubParsePullRequestURL(t *testing.T) {
|
|
t.Parallel()
|
|
gp, err := gitprovider.New("github", "", nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
expectOK bool
|
|
expectOwner string
|
|
expectRepo string
|
|
expectNumber int
|
|
}{
|
|
{
|
|
name: "Standard PR URL",
|
|
raw: "https://github.com/coder/coder/pull/123",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNumber: 123,
|
|
},
|
|
{
|
|
name: "PR URL with query string",
|
|
raw: "https://github.com/coder/coder/pull/456?diff=split",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNumber: 456,
|
|
},
|
|
{
|
|
name: "PR URL with fragment",
|
|
raw: "https://github.com/coder/coder/pull/789#discussion",
|
|
expectOK: true,
|
|
expectOwner: "coder",
|
|
expectRepo: "coder",
|
|
expectNumber: 789,
|
|
},
|
|
{
|
|
name: "Not a PR URL",
|
|
raw: "https://github.com/coder/coder",
|
|
expectOK: false,
|
|
},
|
|
{
|
|
name: "Issue URL (not PR)",
|
|
raw: "https://github.com/coder/coder/issues/123",
|
|
expectOK: false,
|
|
},
|
|
{
|
|
name: "GitLab MR URL",
|
|
raw: "https://gitlab.com/coder/coder/-/merge_requests/123",
|
|
expectOK: false,
|
|
},
|
|
{
|
|
name: "Empty string",
|
|
raw: "",
|
|
expectOK: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ref, ok := gp.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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubNormalizePullRequestURL(t *testing.T) {
|
|
t.Parallel()
|
|
gp, err := gitprovider.New("github", "", nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "Already normalized",
|
|
raw: "https://github.com/coder/coder/pull/123",
|
|
expected: "https://github.com/coder/coder/pull/123",
|
|
},
|
|
{
|
|
name: "With trailing punctuation",
|
|
raw: "https://github.com/coder/coder/pull/123).",
|
|
expected: "https://github.com/coder/coder/pull/123",
|
|
},
|
|
{
|
|
name: "With query string",
|
|
raw: "https://github.com/coder/coder/pull/123?diff=split",
|
|
expected: "https://github.com/coder/coder/pull/123",
|
|
},
|
|
{
|
|
name: "With whitespace",
|
|
raw: " https://github.com/coder/coder/pull/123 ",
|
|
expected: "https://github.com/coder/coder/pull/123",
|
|
},
|
|
{
|
|
name: "Not a PR URL",
|
|
raw: "https://example.com",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "Empty string",
|
|
raw: "",
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
result := gp.NormalizePullRequestURL(tt.raw)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubBuildBranchURL(t *testing.T) {
|
|
t.Parallel()
|
|
gp, err := gitprovider.New("github", "", nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
tests := []struct {
|
|
name string
|
|
owner string
|
|
repo string
|
|
branch string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "Simple branch",
|
|
owner: "coder",
|
|
repo: "coder",
|
|
branch: "main",
|
|
expected: "https://github.com/coder/coder/tree/main",
|
|
},
|
|
{
|
|
name: "Branch with slash",
|
|
owner: "coder",
|
|
repo: "coder",
|
|
branch: "feat/new-thing",
|
|
expected: "https://github.com/coder/coder/tree/feat/new-thing",
|
|
},
|
|
{
|
|
name: "Empty owner",
|
|
owner: "",
|
|
repo: "coder",
|
|
branch: "main",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "Empty repo",
|
|
owner: "coder",
|
|
repo: "",
|
|
branch: "main",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "Empty branch",
|
|
owner: "coder",
|
|
repo: "coder",
|
|
branch: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "Branch with slashes",
|
|
owner: "my-org",
|
|
repo: "my-repo",
|
|
branch: "feat/new-thing",
|
|
expected: "https://github.com/my-org/my-repo/tree/feat/new-thing",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
result := gp.BuildBranchURL(tt.owner, tt.repo, tt.branch)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubBuildPullRequestURL(t *testing.T) {
|
|
t.Parallel()
|
|
gp, err := gitprovider.New("github", "", nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
tests := []struct {
|
|
name string
|
|
ref gitprovider.PRRef
|
|
expected string
|
|
}{
|
|
{
|
|
name: "Valid PR ref",
|
|
ref: gitprovider.PRRef{Owner: "coder", Repo: "coder", Number: 123},
|
|
expected: "https://github.com/coder/coder/pull/123",
|
|
},
|
|
{
|
|
name: "Empty owner",
|
|
ref: gitprovider.PRRef{Owner: "", Repo: "coder", Number: 123},
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "Empty repo",
|
|
ref: gitprovider.PRRef{Owner: "coder", Repo: "", Number: 123},
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "Zero number",
|
|
ref: gitprovider.PRRef{Owner: "coder", Repo: "coder", Number: 0},
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "Negative number",
|
|
ref: gitprovider.PRRef{Owner: "coder", Repo: "coder", Number: -1},
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
result := gp.BuildPullRequestURL(tt.ref)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubEnterpriseURLs(t *testing.T) {
|
|
t.Parallel()
|
|
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) {
|
|
t.Parallel()
|
|
owner, repo, normalized, ok := gp.ParseRepositoryOrigin("https://ghes.corp.com/org/repo.git")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "org", owner)
|
|
assert.Equal(t, "repo", repo)
|
|
assert.Equal(t, "https://ghes.corp.com/org/repo", normalized)
|
|
})
|
|
|
|
t.Run("ParseRepositoryOrigin SSH", func(t *testing.T) {
|
|
t.Parallel()
|
|
owner, repo, normalized, ok := gp.ParseRepositoryOrigin("git@ghes.corp.com:org/repo.git")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "org", owner)
|
|
assert.Equal(t, "repo", repo)
|
|
assert.Equal(t, "https://ghes.corp.com/org/repo", normalized)
|
|
})
|
|
|
|
t.Run("ParsePullRequestURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
ref, ok := gp.ParsePullRequestURL("https://ghes.corp.com/org/repo/pull/42")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "org", ref.Owner)
|
|
assert.Equal(t, "repo", ref.Repo)
|
|
assert.Equal(t, 42, ref.Number)
|
|
})
|
|
|
|
t.Run("NormalizePullRequestURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
result := gp.NormalizePullRequestURL("https://ghes.corp.com/org/repo/pull/42?x=y")
|
|
assert.Equal(t, "https://ghes.corp.com/org/repo/pull/42", result)
|
|
})
|
|
|
|
t.Run("BuildBranchURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
result := gp.BuildBranchURL("org", "repo", "main")
|
|
assert.Equal(t, "https://ghes.corp.com/org/repo/tree/main", result)
|
|
})
|
|
|
|
t.Run("BuildPullRequestURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
result := gp.BuildPullRequestURL(gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 42})
|
|
assert.Equal(t, "https://ghes.corp.com/org/repo/pull/42", result)
|
|
})
|
|
|
|
t.Run("github.com URLs do not match GHE instance", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, _, _, ok := gp.ParseRepositoryOrigin("https://github.com/coder/coder")
|
|
assert.False(t, ok, "github.com HTTPS URL should not match GHE instance")
|
|
|
|
_, _, _, ok = gp.ParseRepositoryOrigin("git@github.com:coder/coder.git")
|
|
assert.False(t, ok, "github.com SSH URL should not match GHE instance")
|
|
|
|
_, ok = gp.ParsePullRequestURL("https://github.com/coder/coder/pull/123")
|
|
assert.False(t, ok, "github.com PR URL should not match GHE instance")
|
|
})
|
|
}
|
|
|
|
func TestNewUnsupportedProvider(t *testing.T) {
|
|
t.Parallel()
|
|
gp, err := gitprovider.New("unsupported", "", nil)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, gp, "unsupported provider type should return nil")
|
|
}
|
|
|
|
func TestGitHubRatelimit_403WithResetHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resetTime := time.Now().Add(60 * time.Second)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("X-Ratelimit-Reset", fmt.Sprintf("%d", resetTime.Unix()))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"message": "API rate limit exceeded"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
_, err = gp.FetchPullRequestStatus(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
var rlErr *gitprovider.RateLimitError
|
|
require.True(t, errors.As(err, &rlErr), "error should be *RateLimitError, got: %T", err)
|
|
assert.WithinDuration(t, resetTime.Add(gitprovider.RateLimitPadding), rlErr.RetryAfter, 2*time.Second)
|
|
}
|
|
|
|
func TestGitHubRatelimit_429WithRetryAfter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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": "secondary rate limit"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
_, err = gp.FetchPullRequestStatus(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
var rlErr *gitprovider.RateLimitError
|
|
require.True(t, errors.As(err, &rlErr), "error should be *RateLimitError, got: %T", err)
|
|
|
|
// Retry-After: 120 means ~120s from now.
|
|
expected := time.Now().Add(120 * time.Second)
|
|
assert.WithinDuration(t, expected.Add(gitprovider.RateLimitPadding), rlErr.RetryAfter, 5*time.Second)
|
|
}
|
|
|
|
func TestGitHubRatelimit_403NormalError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"message": "Bad credentials"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
_, err = gp.FetchPullRequestStatus(
|
|
context.Background(),
|
|
"bad-token",
|
|
gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
var rlErr *gitprovider.RateLimitError
|
|
assert.False(t, errors.As(err, &rlErr), "error should NOT be *RateLimitError")
|
|
assert.Contains(t, err.Error(), "403")
|
|
}
|
|
|
|
func TestGitHubFetchPullRequestDiff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const smallDiff = "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1 +1 @@\n-old\n+new\n"
|
|
|
|
t.Run("OK", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte(smallDiff))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
diff, err := gp.FetchPullRequestDiff(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, smallDiff, diff)
|
|
})
|
|
|
|
t.Run("ExactlyMaxSize", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
exactDiff := string(make([]byte, gitprovider.MaxDiffSize))
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte(exactDiff))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
diff, err := gp.FetchPullRequestDiff(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Len(t, diff, gitprovider.MaxDiffSize)
|
|
})
|
|
|
|
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("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
_, err = gp.FetchPullRequestDiff(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1},
|
|
)
|
|
assert.ErrorIs(t, err, gitprovider.ErrDiffTooLarge)
|
|
})
|
|
}
|
|
|
|
func TestFetchPullRequestDiff_Ratelimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Retry-After", "60")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"message": "rate limit"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
_, err = gp.FetchPullRequestDiff(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
var rlErr *gitprovider.RateLimitError
|
|
require.True(t, errors.As(err, &rlErr), "error should be *RateLimitError, got: %T", err)
|
|
expected := time.Now().Add(60 * time.Second)
|
|
assert.WithinDuration(t, expected.Add(gitprovider.RateLimitPadding), rlErr.RetryAfter, 5*time.Second)
|
|
}
|
|
|
|
func TestFetchBranchDiff_Ratelimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/compare/") {
|
|
// Second request: compare endpoint returns 429.
|
|
w.Header().Set("Retry-After", "60")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"message": "rate limit"}`))
|
|
return
|
|
}
|
|
// First request: repo metadata.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gp)
|
|
|
|
_, err = gp.FetchBranchDiff(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.BranchRef{Owner: "org", Repo: "repo", Branch: "feat"},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
var rlErr *gitprovider.RateLimitError
|
|
require.True(t, errors.As(err, &rlErr), "error should be *RateLimitError, got: %T", err)
|
|
expected := time.Now().Add(60 * time.Second)
|
|
assert.WithinDuration(t, expected.Add(gitprovider.RateLimitPadding), rlErr.RetryAfter, 5*time.Second)
|
|
}
|
|
|
|
func TestFetchPullRequestStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type review struct {
|
|
ID int64 `json:"id"`
|
|
State string `json:"state"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
makeReview := func(id int64, state, login string) review {
|
|
r := review{ID: id, State: state}
|
|
r.User.Login = login
|
|
return r
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
pullJSON string
|
|
reviews []review
|
|
expectedState gitprovider.PRState
|
|
expectedDraft bool
|
|
changesRequested bool
|
|
}{
|
|
{
|
|
name: "OpenPR/NoReviews",
|
|
pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`,
|
|
reviews: []review{},
|
|
expectedState: gitprovider.PRStateOpen,
|
|
expectedDraft: false,
|
|
changesRequested: false,
|
|
},
|
|
{
|
|
name: "OpenPR/SingleChangesRequested",
|
|
pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`,
|
|
reviews: []review{makeReview(1, "CHANGES_REQUESTED", "alice")},
|
|
expectedState: gitprovider.PRStateOpen,
|
|
changesRequested: true,
|
|
},
|
|
{
|
|
name: "OpenPR/ChangesRequestedThenApproved",
|
|
pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`,
|
|
reviews: []review{
|
|
makeReview(1, "CHANGES_REQUESTED", "alice"),
|
|
makeReview(2, "APPROVED", "alice"),
|
|
},
|
|
expectedState: gitprovider.PRStateOpen,
|
|
changesRequested: false,
|
|
},
|
|
{
|
|
name: "OpenPR/ChangesRequestedThenDismissed",
|
|
pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`,
|
|
reviews: []review{
|
|
makeReview(1, "CHANGES_REQUESTED", "alice"),
|
|
makeReview(2, "DISMISSED", "alice"),
|
|
},
|
|
expectedState: gitprovider.PRStateOpen,
|
|
changesRequested: false,
|
|
},
|
|
{
|
|
name: "OpenPR/MultipleReviewersMixed",
|
|
pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`,
|
|
reviews: []review{
|
|
makeReview(1, "APPROVED", "alice"),
|
|
makeReview(2, "CHANGES_REQUESTED", "bob"),
|
|
},
|
|
expectedState: gitprovider.PRStateOpen,
|
|
changesRequested: true,
|
|
},
|
|
{
|
|
name: "OpenPR/CommentedDoesNotAffect",
|
|
pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`,
|
|
reviews: []review{
|
|
makeReview(1, "COMMENTED", "alice"),
|
|
},
|
|
expectedState: gitprovider.PRStateOpen,
|
|
changesRequested: false,
|
|
},
|
|
{
|
|
name: "MergedPR",
|
|
pullJSON: `{"state":"closed","merged":true,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`,
|
|
reviews: []review{},
|
|
expectedState: gitprovider.PRStateMerged,
|
|
changesRequested: false,
|
|
},
|
|
{
|
|
name: "DraftPR",
|
|
pullJSON: `{"state":"open","merged":false,"draft":true,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`,
|
|
reviews: []review{},
|
|
expectedState: gitprovider.PRStateOpen,
|
|
expectedDraft: true,
|
|
changesRequested: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reviewsJSON, err := json.Marshal(tc.reviews)
|
|
require.NoError(t, err)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v3/repos/owner/repo/pulls/1/reviews", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(reviewsJSON)
|
|
})
|
|
mux.HandleFunc("/api/v3/repos/owner/repo/pulls/1", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(tc.pullJSON))
|
|
})
|
|
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
before := time.Now().UTC()
|
|
status, err := gp.FetchPullRequestStatus(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.PRRef{Owner: "owner", Repo: "repo", Number: 1},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, tc.expectedState, status.State)
|
|
assert.Equal(t, tc.expectedDraft, status.Draft)
|
|
assert.Equal(t, tc.changesRequested, status.ChangesRequested)
|
|
assert.Equal(t, "abc123", status.HeadSHA)
|
|
assert.Equal(t, "feature-branch", status.HeadBranch)
|
|
assert.Equal(t, int32(10), status.DiffStats.Additions)
|
|
assert.Equal(t, int32(5), status.DiffStats.Deletions)
|
|
assert.Equal(t, int32(3), status.DiffStats.ChangedFiles)
|
|
assert.False(t, status.FetchedAt.IsZero())
|
|
assert.True(t, !status.FetchedAt.Before(before), "FetchedAt should be >= test start time")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveBranchPullRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("Found", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var srvURL string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify query parameters.
|
|
assert.Equal(t, "open", r.URL.Query().Get("state"))
|
|
assert.Equal(t, "owner:feat", r.URL.Query().Get("head"))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Use the test server's URL so ParsePullRequestURL
|
|
// matches the provider's derived web host.
|
|
htmlURL := fmt.Sprintf("https://%s/owner/repo/pull/42",
|
|
strings.TrimPrefix(strings.TrimPrefix(srvURL, "http://"), "https://"))
|
|
_, _ = w.Write([]byte(fmt.Sprintf(`[{"html_url":%q,"number":42}]`, htmlURL)))
|
|
}))
|
|
defer srv.Close()
|
|
srvURL = srv.URL
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
prRef, err := gp.ResolveBranchPullRequest(
|
|
context.Background(),
|
|
"test-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, 42, prRef.Number)
|
|
})
|
|
|
|
t.Run("NoneOpen", 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(`[]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
prRef, err := gp.ResolveBranchPullRequest(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, prRef)
|
|
})
|
|
|
|
t.Run("InvalidHTMLURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// If html_url can't be parsed as a PR URL, ResolveBranchPullRequest
|
|
// returns nil, nil.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"html_url":"not-a-valid-url","number":42}]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
prRef, err := gp.ResolveBranchPullRequest(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, prRef)
|
|
})
|
|
}
|
|
|
|
func TestFetchBranchDiff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const smallDiff = "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1 +1 @@\n-old\n+new\n"
|
|
|
|
t.Run("OK", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/compare/") {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte(smallDiff))
|
|
return
|
|
}
|
|
// Repo metadata.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
diff, err := gp.FetchBranchDiff(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.BranchRef{Owner: "org", Repo: "repo", Branch: "feat"},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, smallDiff, 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("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
_, err = gp.FetchBranchDiff(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.BranchRef{Owner: "org", Repo: "repo", Branch: "feat"},
|
|
)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "default branch is empty")
|
|
})
|
|
|
|
t.Run("DiffTooLarge", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
oversizeDiff := string(make([]byte, gitprovider.MaxDiffSize+1024))
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/compare/") {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte(oversizeDiff))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, gp)
|
|
|
|
_, err = gp.FetchBranchDiff(
|
|
context.Background(),
|
|
"test-token",
|
|
gitprovider.BranchRef{Owner: "org", Repo: "repo", Branch: "feat"},
|
|
)
|
|
assert.ErrorIs(t, err, gitprovider.ErrDiffTooLarge)
|
|
})
|
|
}
|
|
|
|
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, 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)
|
|
}
|