mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user