feat(coderd): refactors github pr sync functionality (#22715)

- Adds `_API_BASE_URL` to `CODER_EXTERNAL_AUTH_CONFIG_`
- Extracts and refactors existing GitHub PR sync logic to new packages
`coderd/gitsync` and `coderd/externalauth/gitprovider`
- Associated wiring and tests

Created using Opus 4.6
This commit is contained in:
Cian Johnston
2026-03-10 18:46:01 +00:00
committed by GitHub
parent cbe46c816e
commit bc27274aba
31 changed files with 4311 additions and 708 deletions
+540
View File
@@ -0,0 +1,540 @@
package gitprovider
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/xerrors"
"github.com/coder/quartz"
)
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 {
apiBaseURL string
webBaseURL string
httpClient *http.Client
clock quartz.Clock
// Compiled per-instance to support GitHub Enterprise hosts.
pullRequestPathPattern *regexp.Regexp
repositoryHTTPSPattern *regexp.Regexp
repositorySSHPathPattern *regexp.Regexp
}
func newGitHub(apiBaseURL string, httpClient *http.Client, clock quartz.Clock) *githubProvider {
if apiBaseURL == "" {
apiBaseURL = defaultGitHubAPIBaseURL
}
apiBaseURL = strings.TrimRight(apiBaseURL, "/")
if httpClient == nil {
httpClient = http.DefaultClient
}
// Derive the web base URL from the API base URL.
// github.com: api.github.com → github.com
// GHE: ghes.corp.com/api/v3 → ghes.corp.com
webBaseURL := deriveWebBaseURL(apiBaseURL)
// Parse the host for regex construction.
host := extractHost(webBaseURL)
// Escape the host for use in regex patterns.
escapedHost := regexp.QuoteMeta(host)
return &githubProvider{
apiBaseURL: apiBaseURL,
webBaseURL: webBaseURL,
httpClient: httpClient,
clock: clock,
pullRequestPathPattern: regexp.MustCompile(
`^https://` + escapedHost + `/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/pull/([0-9]+)(?:[/?#].*)?$`,
),
repositoryHTTPSPattern: regexp.MustCompile(
`^https://` + escapedHost + `/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$`,
),
repositorySSHPathPattern: regexp.MustCompile(
`^(?:ssh://)?git@` + escapedHost + `[:/]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$`,
),
}
}
// deriveWebBaseURL converts a GitHub API base URL to the
// corresponding web base URL.
//
// github.com: https://api.github.com → https://github.com
// GHE: https://ghes.corp.com/api/v3 → https://ghes.corp.com
func deriveWebBaseURL(apiBaseURL string) string {
u, err := url.Parse(apiBaseURL)
if err != nil {
return "https://github.com"
}
// Standard github.com: API host is api.github.com.
if strings.EqualFold(u.Host, "api.github.com") {
return "https://github.com"
}
// GHE: strip /api/v3 path suffix.
u.Path = strings.TrimSuffix(u.Path, "/api/v3")
u.Path = strings.TrimSuffix(u.Path, "/")
return u.String()
}
// extractHost returns the host portion of a URL.
func extractHost(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return "github.com"
}
return u.Host
}
func (g *githubProvider) ParseRepositoryOrigin(raw string) (owner string, repo string, normalizedOrigin string, ok bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", "", false
}
matches := g.repositoryHTTPSPattern.FindStringSubmatch(raw)
if len(matches) != 3 {
matches = g.repositorySSHPathPattern.FindStringSubmatch(raw)
}
if len(matches) != 3 {
return "", "", "", false
}
owner = strings.TrimSpace(matches[1])
repo = strings.TrimSpace(matches[2])
repo = strings.TrimSuffix(repo, ".git")
if owner == "" || repo == "" {
return "", "", "", false
}
return owner, repo, fmt.Sprintf("%s/%s/%s", g.webBaseURL, url.PathEscape(owner), url.PathEscape(repo)), true
}
func (g *githubProvider) ParsePullRequestURL(raw string) (PRRef, bool) {
matches := g.pullRequestPathPattern.FindStringSubmatch(strings.TrimSpace(raw))
if len(matches) != 4 {
return PRRef{}, false
}
number, err := strconv.Atoi(matches[3])
if err != nil {
return PRRef{}, false
}
return PRRef{
Owner: matches[1],
Repo: matches[2],
Number: number,
}, true
}
func (g *githubProvider) NormalizePullRequestURL(raw string) string {
ref, ok := g.ParsePullRequestURL(strings.TrimRight(
strings.TrimSpace(raw),
"),.;",
))
if !ok {
return ""
}
return fmt.Sprintf("%s/%s/%s/pull/%d", g.webBaseURL, url.PathEscape(ref.Owner), url.PathEscape(ref.Repo), ref.Number)
}
// escapePathPreserveSlashes escapes each segment of a path
// individually, preserving `/` separators. This is needed for
// web URLs where GitHub expects literal slashes (e.g.
// /tree/feat/new-thing).
func escapePathPreserveSlashes(s string) string {
segments := strings.Split(s, "/")
for i, seg := range segments {
segments[i] = url.PathEscape(seg)
}
return strings.Join(segments, "/")
}
func (g *githubProvider) BuildBranchURL(owner string, repo string, 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,
url.PathEscape(owner),
url.PathEscape(repo),
escapePathPreserveSlashes(branch),
)
}
func (g *githubProvider) BuildRepositoryURL(owner string, repo string) string {
owner = strings.TrimSpace(owner)
repo = strings.TrimSpace(repo)
if owner == "" || repo == "" {
return ""
}
return fmt.Sprintf("%s/%s/%s", g.webBaseURL, url.PathEscape(owner), url.PathEscape(repo))
}
func (g *githubProvider) BuildPullRequestURL(ref PRRef) string {
if ref.Owner == "" || ref.Repo == "" || ref.Number <= 0 {
return ""
}
return fmt.Sprintf("%s/%s/%s/pull/%d", g.webBaseURL, url.PathEscape(ref.Owner), url.PathEscape(ref.Repo), ref.Number)
}
func (g *githubProvider) ResolveBranchPullRequest(
ctx context.Context,
token string,
ref BranchRef,
) (*PRRef, error) {
if ref.Owner == "" || ref.Repo == "" || ref.Branch == "" {
return nil, nil
}
query := url.Values{}
query.Set("state", "open")
query.Set("head", fmt.Sprintf("%s:%s", ref.Owner, ref.Branch))
query.Set("sort", "updated")
query.Set("direction", "desc")
query.Set("per_page", "1")
requestURL := fmt.Sprintf(
"%s/repos/%s/%s/pulls?%s",
g.apiBaseURL,
url.PathEscape(ref.Owner),
url.PathEscape(ref.Repo),
query.Encode(),
)
var pulls []struct {
HTMLURL string `json:"html_url"`
Number int `json:"number"`
}
if err := g.decodeJSON(ctx, requestURL, token, &pulls); err != nil {
return nil, err
}
if len(pulls) == 0 {
return nil, nil
}
prRef, ok := g.ParsePullRequestURL(pulls[0].HTMLURL)
if !ok {
return nil, nil
}
return &prRef, nil
}
func (g *githubProvider) FetchPullRequestStatus(
ctx context.Context,
token string,
ref PRRef,
) (*PRStatus, error) {
pullEndpoint := fmt.Sprintf(
"%s/repos/%s/%s/pulls/%d",
g.apiBaseURL,
url.PathEscape(ref.Owner),
url.PathEscape(ref.Repo),
ref.Number,
)
var pull struct {
State string `json:"state"`
Merged bool `json:"merged"`
Draft bool `json:"draft"`
Additions int32 `json:"additions"`
Deletions int32 `json:"deletions"`
ChangedFiles int32 `json:"changed_files"`
Head struct {
SHA string `json:"sha"`
} `json:"head"`
}
if err := g.decodeJSON(ctx, pullEndpoint, token, &pull); err != nil {
return nil, err
}
var reviews []struct {
ID int64 `json:"id"`
State string `json:"state"`
User struct {
Login string `json:"login"`
} `json:"user"`
}
// GitHub returns at most 100 reviews per page. We do not
// paginate because PRs with >100 reviews are extremely rare,
// and the cost of multiple API calls per refresh is not
// justified. If needed, pagination can be added later.
if err := g.decodeJSON(
ctx,
pullEndpoint+"/reviews?per_page=100",
token,
&reviews,
); err != nil {
return nil, err
}
state := PRState(strings.ToLower(strings.TrimSpace(pull.State)))
if pull.Merged {
state = PRStateMerged
}
return &PRStatus{
State: state,
Draft: pull.Draft,
HeadSHA: pull.Head.SHA,
DiffStats: DiffStats{
Additions: pull.Additions,
Deletions: pull.Deletions,
ChangedFiles: pull.ChangedFiles,
},
ChangesRequested: hasOutstandingChangesRequested(reviews),
FetchedAt: g.clock.Now().UTC(),
}, nil
}
func (g *githubProvider) FetchPullRequestDiff(
ctx context.Context,
token string,
ref PRRef,
) (string, error) {
requestURL := fmt.Sprintf(
"%s/repos/%s/%s/pulls/%d",
g.apiBaseURL,
url.PathEscape(ref.Owner),
url.PathEscape(ref.Repo),
ref.Number,
)
return g.fetchDiff(ctx, requestURL, token)
}
func (g *githubProvider) FetchBranchDiff(
ctx context.Context,
token string,
ref BranchRef,
) (string, error) {
if ref.Owner == "" || ref.Repo == "" || ref.Branch == "" {
return "", nil
}
var repository struct {
DefaultBranch string `json:"default_branch"`
}
repositoryURL := fmt.Sprintf(
"%s/repos/%s/%s",
g.apiBaseURL,
url.PathEscape(ref.Owner),
url.PathEscape(ref.Repo),
)
if err := g.decodeJSON(ctx, repositoryURL, token, &repository); err != nil {
return "", err
}
defaultBranch := strings.TrimSpace(repository.DefaultBranch)
if defaultBranch == "" {
return "", xerrors.New("github repository default branch is empty")
}
requestURL := fmt.Sprintf(
"%s/repos/%s/%s/compare/%s...%s",
g.apiBaseURL,
url.PathEscape(ref.Owner),
url.PathEscape(ref.Repo),
url.PathEscape(defaultBranch),
url.PathEscape(ref.Branch),
)
return g.fetchDiff(ctx, requestURL, token)
}
func (g *githubProvider) decodeJSON(
ctx context.Context,
requestURL string,
token string,
dest any,
) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return xerrors.Errorf("create github request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "coder-chat-diff-status")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := g.httpClient.Do(req)
if err != nil {
return xerrors.Errorf("execute github request: %w", err)
}
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.
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
if readErr != nil {
return xerrors.Errorf(
"github request failed with status %d",
resp.StatusCode,
)
}
return xerrors.Errorf(
"github request failed with status %d: %s",
resp.StatusCode,
strings.TrimSpace(string(body)),
)
}
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
return xerrors.Errorf("decode github response: %w", err)
}
return nil
}
func (g *githubProvider) fetchDiff(
ctx context.Context,
requestURL string,
token string,
) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return "", xerrors.Errorf("create github diff request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github.diff")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "coder-chat-diff")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := g.httpClient.Do(req)
if err != nil {
return "", xerrors.Errorf("execute github diff request: %w", err)
}
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)}
}
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
if readErr != nil {
return "", xerrors.Errorf("github diff request failed with status %d", resp.StatusCode)
}
return "", xerrors.Errorf(
"github diff request failed with status %d: %s",
resp.StatusCode,
strings.TrimSpace(string(body)),
)
}
// Read one extra byte beyond MaxDiffSize so we can detect
// whether the diff exceeds the limit. LimitReader stops us
// allocating an arbitrarily large buffer by accident.
buf, err := io.ReadAll(io.LimitReader(resp.Body, MaxDiffSize+1))
if err != nil {
return "", xerrors.Errorf("read github diff response: %w", err)
}
if len(buf) > MaxDiffSize {
return "", ErrDiffTooLarge
}
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
}
func hasOutstandingChangesRequested(
reviews []struct {
ID int64 `json:"id"`
State string `json:"state"`
User struct {
Login string `json:"login"`
} `json:"user"`
},
) bool {
type reviewerState struct {
reviewID int64
state string
}
statesByReviewer := make(map[string]reviewerState)
for _, review := range reviews {
login := strings.ToLower(strings.TrimSpace(review.User.Login))
if login == "" {
continue
}
state := strings.ToUpper(strings.TrimSpace(review.State))
switch state {
case "CHANGES_REQUESTED", "APPROVED", "DISMISSED":
default:
continue
}
current, exists := statesByReviewer[login]
if exists && current.reviewID > review.ID {
continue
}
statesByReviewer[login] = reviewerState{
reviewID: review.ID,
state: state,
}
}
for _, state := range statesByReviewer {
if state.state == "CHANGES_REQUESTED" {
return true
}
}
return false
}
@@ -0,0 +1,994 @@
package gitprovider_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"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 TestGitHubParseRepositoryOrigin(t *testing.T) {
t.Parallel()
gp := gitprovider.New("github", "", nil)
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 := gitprovider.New("github", "", nil)
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 := gitprovider.New("github", "", nil)
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 := gitprovider.New("github", "", nil)
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 := gitprovider.New("github", "", nil)
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 := gitprovider.New("github", "https://ghes.corp.com/api/v3", nil)
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 := gitprovider.New("unsupported", "", nil)
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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"}}`,
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"}}`,
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"}}`,
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"}}`,
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"}}`,
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"}}`,
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"}}`,
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"}}`,
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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, 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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
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 := gitprovider.New("github", "", nil)
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)
})
}
@@ -0,0 +1,179 @@
package gitprovider
import (
"context"
"fmt"
"net/http"
"time"
"golang.org/x/xerrors"
"github.com/coder/quartz"
)
// providerOptions holds optional configuration for provider
// construction.
type providerOptions struct {
clock quartz.Clock
}
// Option configures optional behavior for a Provider.
type Option func(*providerOptions)
// WithClock sets the clock used by the provider. Defaults to
// quartz.NewReal() if not provided.
func WithClock(c quartz.Clock) Option {
return func(o *providerOptions) {
o.clock = c
}
}
// PRState is the normalized state of a pull/merge request across
// all providers.
type PRState string
const (
PRStateOpen PRState = "open"
PRStateClosed PRState = "closed"
PRStateMerged PRState = "merged"
)
// PRRef identifies a pull request on any provider.
type PRRef struct {
// Owner is the repository owner / project / workspace.
Owner string
// Repo is the repository name or slug.
Repo string
// Number is the PR number / IID / index.
Number int
}
// BranchRef identifies a branch in a repository, used for
// branch-to-PR resolution.
type BranchRef struct {
Owner string
Repo string
Branch string
}
// DiffStats summarizes the size of a PR's changes.
type DiffStats struct {
Additions int32
Deletions int32
ChangedFiles int32
}
// PRStatus is the complete status of a pull/merge request.
// This is the universal return type that all providers populate.
type PRStatus struct {
// State is the PR's lifecycle state.
State PRState
// Draft indicates the PR is marked as draft/WIP.
Draft bool
// HeadSHA is the SHA of the head commit.
HeadSHA string
// DiffStats summarizes additions/deletions/files changed.
DiffStats DiffStats
// ChangesRequested is a convenience boolean: true if any
// reviewer's current state is "changes_requested".
ChangesRequested bool
// FetchedAt is when this status was fetched.
FetchedAt time.Time
}
// 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
// ErrDiffTooLarge is returned when a diff exceeds MaxDiffSize.
var ErrDiffTooLarge = xerrors.Errorf("diff exceeds maximum size of %d bytes", MaxDiffSize)
// Provider defines the interface that all Git hosting providers
// implement. Each method is designed to minimize API round-trips
// for the specific provider.
type Provider interface {
// FetchPullRequestStatus retrieves the complete status of a
// pull request in the minimum number of API calls for this
// provider.
FetchPullRequestStatus(ctx context.Context, token string, ref PRRef) (*PRStatus, error)
// ResolveBranchPullRequest finds the open PR (if any) for
// the given branch. Returns nil, nil if no open PR exists.
ResolveBranchPullRequest(ctx context.Context, token string, ref BranchRef) (*PRRef, error)
// FetchPullRequestDiff returns the raw unified diff for a
// pull request. This uses the PR's actual base branch (which
// may differ from the repo default branch, e.g. a PR
// targeting "staging" instead of "main"), so it matches what
// the provider shows on the PR's "Files changed" tab.
// Returns ErrDiffTooLarge if the diff exceeds MaxDiffSize.
FetchPullRequestDiff(ctx context.Context, token string, ref PRRef) (string, error)
// FetchBranchDiff returns the diff of a branch compared
// against the repository's default branch. This is the
// fallback when no pull request exists yet (e.g. the agent
// pushed a branch but hasn't opened a PR). Returns
// ErrDiffTooLarge if the diff exceeds MaxDiffSize.
FetchBranchDiff(ctx context.Context, token string, ref BranchRef) (string, error)
// ParseRepositoryOrigin parses a remote origin URL (HTTPS
// or SSH) into owner and repo components, returning the
// normalized HTTPS URL. Returns false if the URL does not
// match this provider.
ParseRepositoryOrigin(raw string) (owner, repo, normalizedOrigin string, ok bool)
// ParsePullRequestURL parses a pull request URL into a
// PRRef. Returns false if the URL does not match this
// provider.
ParsePullRequestURL(raw string) (PRRef, bool)
// NormalizePullRequestURL normalizes a pull request URL,
// stripping trailing punctuation, query strings, and
// fragments. Returns empty string if the URL does not
// match this provider.
NormalizePullRequestURL(raw string) string
// BuildBranchURL constructs a URL to view a branch on
// the provider's web UI.
BuildBranchURL(owner, repo, branch string) string
// BuildRepositoryURL constructs a URL to view a repository
// on the provider's web UI.
BuildRepositoryURL(owner, repo string) string
// BuildPullRequestURL constructs a URL to view a pull
// request on the provider's web UI.
BuildPullRequestURL(ref PRRef) string
}
// 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 {
o := providerOptions{}
for _, opt := range opts {
opt(&o)
}
if o.clock == nil {
o.clock = quartz.NewReal()
}
switch providerType {
case "github":
return newGitHub(apiBaseURL, httpClient, o.clock)
default:
// Other providers (gitlab, bitbucket-cloud, etc.) will be
// added here as they are implemented.
return nil
}
}
// RateLimitError indicates the git provider's API rate limit was hit.
type RateLimitError struct {
RetryAfter time.Time
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limited until %s", e.RetryAfter.Format(time.RFC3339))
}