fix: restore GitHub rate limit indicator (#2664)
This commit is contained in:
@@ -89,6 +89,7 @@ type CredentialResolver struct {
|
||||
legacyGit legacyTransportCredentialFactory
|
||||
ghToken ghAccountTokenResolver
|
||||
now func() time.Time
|
||||
rateTracker *RateTracker
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[credentialCacheKey]credentialCacheEntry
|
||||
@@ -125,6 +126,10 @@ func (r *CredentialResolver) SetLegacyTransportFactory(factory legacyTransportCr
|
||||
}
|
||||
}
|
||||
|
||||
func (r *CredentialResolver) SetRateTracker(tracker *RateTracker) {
|
||||
r.rateTracker = tracker
|
||||
}
|
||||
|
||||
func (r *CredentialResolver) SetInstallationProvider(provider installationCredentialProvider) {
|
||||
r.app = provider
|
||||
}
|
||||
@@ -431,6 +436,11 @@ func (r *CredentialResolver) resolveLegacy(
|
||||
(purpose == CredentialPurposeGitTransport && strings.TrimSpace(credential) == "") {
|
||||
return nil, ErrGitHubNotConfigured
|
||||
}
|
||||
tracker := r.rateTracker
|
||||
if tracker == nil {
|
||||
tracker = NewRateTracker(nil, nil)
|
||||
}
|
||||
wireRateTracker(client, tracker)
|
||||
login, _ := client.GetAuthenticatedUser(ctx)
|
||||
return &ResolvedCredential{
|
||||
Client: client,
|
||||
@@ -441,7 +451,7 @@ func (r *CredentialResolver) resolveLegacy(
|
||||
Source: ConnectionSourceLegacyShared,
|
||||
Login: login,
|
||||
},
|
||||
RateTracker: NewRateTracker(nil, nil),
|
||||
RateTracker: tracker,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,42 @@ func TestCredentialResolverUsesLegacyOnlyForMigratedSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyResolverUsesServiceRateTracker(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedConnectionWorkspaces(t, store, "workspace-legacy-rate-limit")
|
||||
connection := &WorkspaceConnection{
|
||||
WorkspaceID: "workspace-legacy-rate-limit",
|
||||
Source: ConnectionSourceLegacyShared,
|
||||
GitHubHost: defaultGitHubHost,
|
||||
Status: ConnectionStatusActive,
|
||||
CredentialGeneration: 1,
|
||||
}
|
||||
if err := store.UpsertWorkspaceConnection(context.Background(), connection); err != nil {
|
||||
t.Fatalf("seed workspace connection: %v", err)
|
||||
}
|
||||
|
||||
service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t))
|
||||
legacy := NewPATClient("legacy-token")
|
||||
legacy.username = "legacy-user"
|
||||
service.resolver.SetLegacyFactory(func(context.Context) (Client, string, error) {
|
||||
return legacy, AuthMethodPAT, nil
|
||||
})
|
||||
|
||||
resolved, err := service.resolver.Resolve(context.Background(), ResolveCredentialRequest{
|
||||
WorkspaceID: connection.WorkspaceID,
|
||||
Purpose: CredentialPurposeAutomation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.RateTracker != service.rateTracker {
|
||||
t.Fatalf("resolved tracker = %p, want service tracker %p", resolved.RateTracker, service.rateTracker)
|
||||
}
|
||||
if legacy.rateTracker != service.rateTracker {
|
||||
t.Fatalf("legacy client tracker = %p, want service tracker %p", legacy.rateTracker, service.rateTracker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialResolverNamedCLIUsesSelectedLogin(t *testing.T) {
|
||||
connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{
|
||||
"work": {WorkspaceID: "work", Source: ConnectionSourceGHCLI, GitHubHost: "github.com", Login: "work-user", Status: ConnectionStatusActive, CredentialGeneration: 2},
|
||||
|
||||
@@ -5,6 +5,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimitFetcher exposes the optional initial rate-limit probe supported by
|
||||
// authenticated GitHub clients. It is deliberately separate from Client so
|
||||
// in-memory and specialized clients do not need to implement a settings-only
|
||||
// operation.
|
||||
type RateLimitFetcher interface {
|
||||
FetchRateLimit(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Client defines the interface for interacting with the GitHub API.
|
||||
type Client interface {
|
||||
// IsAuthenticated checks if the client is authenticated with GitHub.
|
||||
|
||||
@@ -133,9 +133,19 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
|
||||
func (c *Controller) httpGetStatus(ctx *gin.Context) {
|
||||
workspaceID := ctx.Query("workspace_id")
|
||||
status, err := c.service.GetWorkspaceAuthStatus(
|
||||
ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx),
|
||||
var (
|
||||
status *WorkspaceAuthStatus
|
||||
err error
|
||||
)
|
||||
if ctx.Query("refresh_rate_limit") == "true" {
|
||||
status, err = c.service.RefreshWorkspaceRateLimit(
|
||||
ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx),
|
||||
)
|
||||
} else {
|
||||
status, err = c.service.GetWorkspaceAuthStatus(
|
||||
ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
writeGitHubAuthError(ctx, err)
|
||||
return
|
||||
|
||||
@@ -1121,22 +1121,6 @@ func firstArg(args []string) string {
|
||||
return args[0]
|
||||
}
|
||||
|
||||
// ghRateLimitResponse mirrors the GET /rate_limit JSON shape so we can seed
|
||||
// the tracker on startup and after CLI failures without parsing prose.
|
||||
type ghRateLimitResponse struct {
|
||||
Resources struct {
|
||||
Core ghRateLimitBucket `json:"core"`
|
||||
GraphQL ghRateLimitBucket `json:"graphql"`
|
||||
Search ghRateLimitBucket `json:"search"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
|
||||
type ghRateLimitBucket struct {
|
||||
Limit int `json:"limit"`
|
||||
Remaining int `json:"remaining"`
|
||||
Reset int64 `json:"reset"`
|
||||
}
|
||||
|
||||
// FetchRateLimit calls `gh api rate_limit` and seeds the tracker with the
|
||||
// returned snapshots. Best-effort: a failure (e.g. CLI absent, network) is
|
||||
// logged and ignored.
|
||||
@@ -1148,26 +1132,11 @@ func (c *GHClient) FetchRateLimit(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch rate limit: %w", err)
|
||||
}
|
||||
var raw ghRateLimitResponse
|
||||
if err := json.Unmarshal([]byte(out), &raw); err != nil {
|
||||
raw, err := decodeRateLimitResponse([]byte(out))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse rate_limit response: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record := func(resource Resource, b ghRateLimitBucket) {
|
||||
if b.Limit == 0 && b.Remaining == 0 && b.Reset == 0 {
|
||||
return
|
||||
}
|
||||
c.rateTracker.Record(RateSnapshot{
|
||||
Resource: resource,
|
||||
Limit: b.Limit,
|
||||
Remaining: b.Remaining,
|
||||
ResetAt: time.Unix(b.Reset, 0).UTC(),
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
record(ResourceCore, raw.Resources.Core)
|
||||
record(ResourceGraphQL, raw.Resources.GraphQL)
|
||||
record(ResourceSearch, raw.Resources.Search)
|
||||
recordRateLimitResources(c.rateTracker, raw.Resources)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -10,6 +11,39 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenClientFetchRateLimitSeedsAllBuckets(t *testing.T) {
|
||||
client, requests := newRecordingPATServer(t, map[string]string{
|
||||
"/rate_limit": `{"resources":{
|
||||
"core":{"limit":5000,"remaining":4321,"reset":2000000000},
|
||||
"graphql":{"limit":5000,"remaining":4900,"reset":2000000000},
|
||||
"search":{"limit":30,"remaining":25,"reset":2000000000}
|
||||
}}`,
|
||||
})
|
||||
tracker := NewRateTracker(nil, nil)
|
||||
client.WithRateTracker(tracker)
|
||||
|
||||
if err := client.FetchRateLimit(context.Background()); err != nil {
|
||||
t.Fatalf("FetchRateLimit: %v", err)
|
||||
}
|
||||
if len(*requests) != 1 || (*requests)[0].Method != http.MethodGet || (*requests)[0].Path != "/rate_limit" {
|
||||
t.Fatalf("requests = %+v, want one GET /rate_limit", *requests)
|
||||
}
|
||||
|
||||
for resource, wantRemaining := range map[Resource]int{
|
||||
ResourceCore: 4321,
|
||||
ResourceGraphQL: 4900,
|
||||
ResourceSearch: 25,
|
||||
} {
|
||||
snapshot, ok := tracker.Snapshot(resource)
|
||||
if !ok {
|
||||
t.Fatalf("missing %s rate-limit snapshot", resource)
|
||||
}
|
||||
if snapshot.Remaining != wantRemaining {
|
||||
t.Errorf("%s remaining = %d, want %d", resource, snapshot.Remaining, wantRemaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// patRequest records what a PATClient actually put on the wire so a test can
|
||||
// assert method, path, query, headers and body rather than only the decoded
|
||||
// result.
|
||||
|
||||
@@ -19,16 +19,24 @@ const rateLimitSeedTimeout = 5 * time.Second
|
||||
// implementation we ended up with, then seeds initial snapshots. The seed is
|
||||
// best-effort — a missing CLI or network blip should not block startup.
|
||||
func attachRateTracker(client Client, tracker *RateTracker, log *logger.Logger) {
|
||||
wireRateTracker(client, tracker)
|
||||
c, ok := client.(*GHClient)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
seedCtx, cancel := context.WithTimeout(context.Background(), rateLimitSeedTimeout)
|
||||
defer cancel()
|
||||
if err := c.FetchRateLimit(seedCtx); err != nil {
|
||||
log.Debug("seed gh rate limit failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func wireRateTracker(client Client, tracker *RateTracker) {
|
||||
switch c := client.(type) {
|
||||
case *PATClient:
|
||||
c.WithRateTracker(tracker)
|
||||
case *GHClient:
|
||||
c.WithRateTracker(tracker)
|
||||
seedCtx, cancel := context.WithTimeout(context.Background(), rateLimitSeedTimeout)
|
||||
defer cancel()
|
||||
if err := c.FetchRateLimit(seedCtx); err != nil {
|
||||
log.Debug("seed gh rate limit failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimitResponse mirrors the GitHub GET /rate_limit JSON shape. The
|
||||
// endpoint includes additional resource buckets, but these are the buckets
|
||||
// Kandev tracks and displays.
|
||||
type rateLimitResponse struct {
|
||||
Resources rateLimitResources `json:"resources"`
|
||||
}
|
||||
|
||||
type rateLimitResources struct {
|
||||
Core rateLimitBucket `json:"core"`
|
||||
GraphQL rateLimitBucket `json:"graphql"`
|
||||
Search rateLimitBucket `json:"search"`
|
||||
}
|
||||
|
||||
type rateLimitBucket struct {
|
||||
Limit int `json:"limit"`
|
||||
Remaining int `json:"remaining"`
|
||||
Reset int64 `json:"reset"`
|
||||
}
|
||||
|
||||
func recordRateLimitResources(tracker *RateTracker, resources rateLimitResources) {
|
||||
if tracker == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record := func(resource Resource, bucket rateLimitBucket) {
|
||||
if bucket.Limit == 0 && bucket.Remaining == 0 && bucket.Reset == 0 {
|
||||
return
|
||||
}
|
||||
tracker.Record(RateSnapshot{
|
||||
Resource: resource,
|
||||
Limit: bucket.Limit,
|
||||
Remaining: bucket.Remaining,
|
||||
ResetAt: time.Unix(bucket.Reset, 0).UTC(),
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
record(ResourceCore, resources.Core)
|
||||
record(ResourceGraphQL, resources.GraphQL)
|
||||
record(ResourceSearch, resources.Search)
|
||||
}
|
||||
|
||||
// FetchRateLimit calls GET /rate_limit and seeds the client's tracker with
|
||||
// the current REST, GraphQL, and search buckets.
|
||||
func (c *TokenClient) FetchRateLimit(ctx context.Context) error {
|
||||
if c.rateTracker == nil {
|
||||
return nil
|
||||
}
|
||||
var raw rateLimitResponse
|
||||
if err := c.get(ctx, "/rate_limit", &raw); err != nil {
|
||||
return fmt.Errorf("fetch rate limit: %w", err)
|
||||
}
|
||||
recordRateLimitResources(c.rateTracker, raw.Resources)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRateLimitResponse(data []byte) (rateLimitResponse, error) {
|
||||
var raw rateLimitResponse
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return rateLimitResponse{}, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -70,6 +70,8 @@ type RateTracker struct {
|
||||
snapshots map[Resource]RateSnapshot
|
||||
exhausted map[Resource]bool
|
||||
lastEmitted map[Resource]time.Time
|
||||
refreshMu sync.Mutex
|
||||
refreshDone chan struct{}
|
||||
bus bus.EventBus
|
||||
log *logger.Logger
|
||||
}
|
||||
@@ -144,6 +146,30 @@ func (r *RateTracker) All() map[Resource]RateSnapshot {
|
||||
return r.allLocked()
|
||||
}
|
||||
|
||||
func (r *RateTracker) beginRateLimitRefresh(force bool) (chan struct{}, bool) {
|
||||
r.refreshMu.Lock()
|
||||
defer r.refreshMu.Unlock()
|
||||
if r.refreshDone != nil {
|
||||
return r.refreshDone, false
|
||||
}
|
||||
if !force && hasCompleteRateLimitSnapshot(r) {
|
||||
return nil, false
|
||||
}
|
||||
done := make(chan struct{})
|
||||
r.refreshDone = done
|
||||
return done, true
|
||||
}
|
||||
|
||||
func (r *RateTracker) finishRateLimitRefresh(done chan struct{}) {
|
||||
r.refreshMu.Lock()
|
||||
defer r.refreshMu.Unlock()
|
||||
if r.refreshDone != done {
|
||||
return
|
||||
}
|
||||
r.refreshDone = nil
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (r *RateTracker) allLocked() map[Resource]RateSnapshot {
|
||||
out := make(map[Resource]RateSnapshot, len(r.snapshots))
|
||||
for k, v := range r.snapshots {
|
||||
|
||||
@@ -208,6 +208,7 @@ func NewService(client Client, authMethod string, secrets SecretProvider, store
|
||||
}
|
||||
if store != nil {
|
||||
service.resolver = NewCredentialResolver(store, secrets)
|
||||
service.resolver.SetRateTracker(service.rateTracker)
|
||||
service.resolver.SetLegacyFactory(func(ctx context.Context) (Client, string, error) {
|
||||
return NewClient(ctx, secrets, log)
|
||||
})
|
||||
|
||||
@@ -5,8 +5,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const workspaceRateLimitSeedTimeout = 5 * time.Second
|
||||
|
||||
type githubAppRuntime struct {
|
||||
registrationID string
|
||||
source DeploymentAppSource
|
||||
@@ -444,6 +449,23 @@ type WorkspaceAuthStatus struct {
|
||||
func (s *Service) GetWorkspaceAuthStatus(
|
||||
ctx context.Context,
|
||||
workspaceID, userID string,
|
||||
) (*WorkspaceAuthStatus, error) {
|
||||
return s.getWorkspaceAuthStatus(ctx, workspaceID, userID, false)
|
||||
}
|
||||
|
||||
// RefreshWorkspaceRateLimit returns the workspace status after fetching a
|
||||
// fresh GitHub rate-limit snapshot for the resolved automation credential.
|
||||
func (s *Service) RefreshWorkspaceRateLimit(
|
||||
ctx context.Context,
|
||||
workspaceID, userID string,
|
||||
) (*WorkspaceAuthStatus, error) {
|
||||
return s.getWorkspaceAuthStatus(ctx, workspaceID, userID, true)
|
||||
}
|
||||
|
||||
func (s *Service) getWorkspaceAuthStatus(
|
||||
ctx context.Context,
|
||||
workspaceID, userID string,
|
||||
refreshRateLimit bool,
|
||||
) (*WorkspaceAuthStatus, error) {
|
||||
if strings.TrimSpace(workspaceID) == "" {
|
||||
return nil, ErrGitHubWorkspaceRequired
|
||||
@@ -492,6 +514,7 @@ func (s *Service) GetWorkspaceAuthStatus(
|
||||
status.Automation.Actor = principalPointer(automation.Principal)
|
||||
status.Automation.Capabilities = automation.Capabilities
|
||||
status.Automation.MissingCapabilities = missingGitHubCapabilities(automation.Capabilities)
|
||||
s.seedWorkspaceRateLimit(ctx, automation, refreshRateLimit)
|
||||
status.Automation.RateLimit = rateLimitInfoForTracker(automation.RateTracker)
|
||||
status.RateLimit = status.Automation.RateLimit
|
||||
|
||||
@@ -499,6 +522,55 @@ func (s *Service) GetWorkspaceAuthStatus(
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *Service) seedWorkspaceRateLimit(
|
||||
ctx context.Context,
|
||||
resolved *ResolvedCredential,
|
||||
force bool,
|
||||
) {
|
||||
if resolved == nil || resolved.RateTracker == nil {
|
||||
return
|
||||
}
|
||||
fetcher, ok := resolved.Client.(RateLimitFetcher)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tracker := resolved.RateTracker
|
||||
for {
|
||||
done, shouldFetch := tracker.beginRateLimitRefresh(force)
|
||||
if shouldFetch {
|
||||
defer tracker.finishRateLimitRefresh(done)
|
||||
break
|
||||
}
|
||||
if done == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
if hasCompleteRateLimitSnapshot(tracker) {
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
seedCtx, cancel := context.WithTimeout(ctx, workspaceRateLimitSeedTimeout)
|
||||
defer cancel()
|
||||
if err := fetcher.FetchRateLimit(seedCtx); err != nil && s.logger != nil {
|
||||
s.logger.Debug("seed workspace GitHub rate limit failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func hasCompleteRateLimitSnapshot(tracker *RateTracker) bool {
|
||||
if tracker == nil {
|
||||
return false
|
||||
}
|
||||
all := tracker.All()
|
||||
_, hasCore := all[ResourceCore]
|
||||
_, hasGraphQL := all[ResourceGraphQL]
|
||||
_, hasSearch := all[ResourceSearch]
|
||||
return hasCore && hasGraphQL && hasSearch
|
||||
}
|
||||
|
||||
func (s *Service) workspacePersonalConnection(
|
||||
ctx context.Context,
|
||||
workspaceID, userID string,
|
||||
|
||||
@@ -3,9 +3,256 @@ package github
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rateLimitSeedClient struct {
|
||||
*MockClient
|
||||
tracker *RateTracker
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *rateLimitSeedClient) FetchRateLimit(context.Context) error {
|
||||
c.calls++
|
||||
now := time.Now().UTC()
|
||||
for _, snapshot := range []RateSnapshot{
|
||||
{Resource: ResourceCore, Remaining: 4321, Limit: 5000},
|
||||
{Resource: ResourceGraphQL, Remaining: 4322, Limit: 5000},
|
||||
{Resource: ResourceSearch, Remaining: 29, Limit: 30},
|
||||
} {
|
||||
snapshot.ResetAt = now.Add(time.Hour)
|
||||
snapshot.UpdatedAt = now
|
||||
c.tracker.Record(snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rateLimitAutomationProvider struct {
|
||||
client Client
|
||||
tracker *RateTracker
|
||||
}
|
||||
|
||||
type concurrentRateLimitSeedClient struct {
|
||||
*MockClient
|
||||
tracker *RateTracker
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
firstStarted chan struct{}
|
||||
secondStarted chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (c *concurrentRateLimitSeedClient) FetchRateLimit(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
c.calls++
|
||||
call := c.calls
|
||||
c.mu.Unlock()
|
||||
switch call {
|
||||
case 1:
|
||||
close(c.firstStarted)
|
||||
case 2:
|
||||
close(c.secondStarted)
|
||||
}
|
||||
select {
|
||||
case <-c.release:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, snapshot := range []RateSnapshot{
|
||||
{Resource: ResourceCore, Remaining: 4000 - call, Limit: 5000},
|
||||
{Resource: ResourceGraphQL, Remaining: 4001 - call, Limit: 5000},
|
||||
{Resource: ResourceSearch, Remaining: 28 - call, Limit: 30},
|
||||
} {
|
||||
snapshot.ResetAt = now.Add(time.Hour)
|
||||
snapshot.UpdatedAt = now
|
||||
c.tracker.Record(snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *concurrentRateLimitSeedClient) callCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.calls
|
||||
}
|
||||
|
||||
func (p rateLimitAutomationProvider) ResolveAutomation(
|
||||
context.Context,
|
||||
*WorkspaceConnection,
|
||||
ResolveCredentialRequest,
|
||||
) (*ResolvedCredential, error) {
|
||||
return &ResolvedCredential{
|
||||
Client: p.client,
|
||||
Capabilities: allTokenCapabilities(),
|
||||
Principal: AuthPrincipal{
|
||||
Kind: AuthPrincipalHuman,
|
||||
Source: ConnectionSourceGHCLI,
|
||||
Login: "octocat",
|
||||
},
|
||||
RateTracker: p.tracker,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestWorkspaceAuthStatusSeedsRateLimitOncePerCachedCredential(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedConnectionWorkspaces(t, store, "workspace-rate-limit")
|
||||
connection := &WorkspaceConnection{
|
||||
WorkspaceID: "workspace-rate-limit",
|
||||
Source: ConnectionSourceGHCLI,
|
||||
GitHubHost: defaultGitHubHost,
|
||||
Login: "octocat",
|
||||
Status: ConnectionStatusActive,
|
||||
CredentialGeneration: 1,
|
||||
}
|
||||
if err := store.UpsertWorkspaceConnection(context.Background(), connection); err != nil {
|
||||
t.Fatalf("seed workspace connection: %v", err)
|
||||
}
|
||||
|
||||
tracker := NewRateTracker(nil, nil)
|
||||
client := &rateLimitSeedClient{MockClient: NewMockClient(), tracker: tracker}
|
||||
service := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
|
||||
service.resolver = NewCredentialResolver(testConnectionReader{
|
||||
workspaces: map[string]*WorkspaceConnection{connection.WorkspaceID: connection},
|
||||
}, nil)
|
||||
service.resolver.SetAutomationProvider(rateLimitAutomationProvider{client: client, tracker: tracker})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
status, err := service.GetWorkspaceAuthStatus(context.Background(), connection.WorkspaceID, DefaultUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceAuthStatus call %d: %v", i+1, err)
|
||||
}
|
||||
if status.RateLimit == nil || status.RateLimit.Core == nil {
|
||||
t.Fatalf("call %d rate limit = %+v, want seeded core snapshot", i+1, status.RateLimit)
|
||||
}
|
||||
if status.RateLimit.Core.Remaining != 4321 {
|
||||
t.Errorf("call %d remaining = %d, want 4321", i+1, status.RateLimit.Core.Remaining)
|
||||
}
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Fatalf("FetchRateLimit calls = %d, want 1 for cached credential", client.calls)
|
||||
}
|
||||
|
||||
status, err := service.RefreshWorkspaceRateLimit(
|
||||
context.Background(), connection.WorkspaceID, DefaultUserID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshWorkspaceRateLimit: %v", err)
|
||||
}
|
||||
if status.RateLimit == nil || status.RateLimit.Core == nil {
|
||||
t.Fatalf("forced refresh rate limit = %+v, want seeded core snapshot", status.RateLimit)
|
||||
}
|
||||
if client.calls != 2 {
|
||||
t.Fatalf("FetchRateLimit calls after forced refresh = %d, want 2", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceAuthStatusCompletesPartialRateLimitSeed(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedConnectionWorkspaces(t, store, "workspace-partial-rate-limit")
|
||||
connection := &WorkspaceConnection{
|
||||
WorkspaceID: "workspace-partial-rate-limit",
|
||||
Source: ConnectionSourceGHCLI,
|
||||
GitHubHost: defaultGitHubHost,
|
||||
Login: "octocat",
|
||||
Status: ConnectionStatusActive,
|
||||
CredentialGeneration: 1,
|
||||
}
|
||||
if err := store.UpsertWorkspaceConnection(context.Background(), connection); err != nil {
|
||||
t.Fatalf("seed workspace connection: %v", err)
|
||||
}
|
||||
|
||||
tracker := NewRateTracker(nil, nil)
|
||||
tracker.Record(RateSnapshot{
|
||||
Resource: ResourceCore, Remaining: 4999, Limit: 5000,
|
||||
ResetAt: time.Now().Add(time.Hour).UTC(), UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
client := &rateLimitSeedClient{MockClient: NewMockClient(), tracker: tracker}
|
||||
service := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
|
||||
service.resolver = NewCredentialResolver(testConnectionReader{
|
||||
workspaces: map[string]*WorkspaceConnection{connection.WorkspaceID: connection},
|
||||
}, nil)
|
||||
service.resolver.SetAutomationProvider(rateLimitAutomationProvider{client: client, tracker: tracker})
|
||||
|
||||
status, err := service.GetWorkspaceAuthStatus(
|
||||
context.Background(), connection.WorkspaceID, DefaultUserID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceAuthStatus: %v", err)
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Fatalf("FetchRateLimit calls = %d, want 1 for a partial tracker", client.calls)
|
||||
}
|
||||
if status.RateLimit == nil || status.RateLimit.GraphQL == nil || status.RateLimit.Search == nil {
|
||||
t.Fatalf("rate limit = %+v, want all seeded buckets", status.RateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceRateLimitSeedSerializesConcurrentRefreshes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedConnectionWorkspaces(t, store, "workspace-concurrent-rate-limit")
|
||||
connection := &WorkspaceConnection{
|
||||
WorkspaceID: "workspace-concurrent-rate-limit",
|
||||
Source: ConnectionSourceGHCLI,
|
||||
GitHubHost: defaultGitHubHost,
|
||||
Login: "octocat",
|
||||
Status: ConnectionStatusActive,
|
||||
CredentialGeneration: 1,
|
||||
}
|
||||
if err := store.UpsertWorkspaceConnection(context.Background(), connection); err != nil {
|
||||
t.Fatalf("seed workspace connection: %v", err)
|
||||
}
|
||||
|
||||
tracker := NewRateTracker(nil, nil)
|
||||
client := &concurrentRateLimitSeedClient{
|
||||
MockClient: NewMockClient(),
|
||||
tracker: tracker,
|
||||
firstStarted: make(chan struct{}),
|
||||
secondStarted: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
service := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
|
||||
service.resolver = NewCredentialResolver(testConnectionReader{
|
||||
workspaces: map[string]*WorkspaceConnection{connection.WorkspaceID: connection},
|
||||
}, nil)
|
||||
service.resolver.SetAutomationProvider(rateLimitAutomationProvider{client: client, tracker: tracker})
|
||||
|
||||
regularDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.GetWorkspaceAuthStatus(context.Background(), connection.WorkspaceID, DefaultUserID)
|
||||
regularDone <- err
|
||||
}()
|
||||
<-client.firstStarted
|
||||
|
||||
forcedDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.RefreshWorkspaceRateLimit(context.Background(), connection.WorkspaceID, DefaultUserID)
|
||||
forcedDone <- err
|
||||
}()
|
||||
|
||||
secondStarted := false
|
||||
select {
|
||||
case <-client.secondStarted:
|
||||
secondStarted = true
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(client.release)
|
||||
if err := <-regularDone; err != nil {
|
||||
t.Fatalf("regular status: %v", err)
|
||||
}
|
||||
if err := <-forcedDone; err != nil {
|
||||
t.Fatalf("forced status: %v", err)
|
||||
}
|
||||
if secondStarted {
|
||||
t.Fatal("concurrent forced refresh started a duplicate rate-limit fetch")
|
||||
}
|
||||
if got := client.callCount(); got != 1 {
|
||||
t.Fatalf("FetchRateLimit calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAuthRegistryHotAddsAndInvalidatesRegistrationsIndependently(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t))
|
||||
|
||||
@@ -19,14 +19,20 @@ export function GitHubAccessHelp({
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
onOpen,
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content?: ReactNode;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const usesTouchDrawer = useTouchDrawer();
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleDrawerOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) onOpen?.();
|
||||
};
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -43,7 +49,7 @@ export function GitHubAccessHelp({
|
||||
const trigger = usesTouchDrawer ? (
|
||||
<DrawerTrigger asChild>{button}</DrawerTrigger>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<Tooltip onOpenChange={(nextOpen) => nextOpen && onOpen?.()}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-[320px] text-xs leading-relaxed">
|
||||
{content ?? description}
|
||||
@@ -52,7 +58,7 @@ export function GitHubAccessHelp({
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<Drawer open={open} onOpenChange={handleDrawerOpenChange}>
|
||||
{trigger}
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
|
||||
@@ -58,12 +58,16 @@ function snapshotsFromInfo(info: GitHubRateLimitInfo): GitHubRateLimitSnapshot[]
|
||||
return out;
|
||||
}
|
||||
|
||||
export function GitHubRateLimitDisplay({ info }: { info?: GitHubRateLimitInfo }) {
|
||||
export function GitHubRateLimitDisplay({
|
||||
info,
|
||||
onOpen,
|
||||
}: {
|
||||
info?: GitHubRateLimitInfo;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const now = useTickNow(30_000);
|
||||
if (!info) return null;
|
||||
const snapshots = snapshotsFromInfo(info);
|
||||
if (snapshots.length === 0) return null;
|
||||
const snapshots = info ? snapshotsFromInfo(info) : [];
|
||||
const exhausted = snapshots.filter((s) => isExhausted(s, now));
|
||||
|
||||
return (
|
||||
@@ -71,7 +75,12 @@ export function GitHubRateLimitDisplay({ info }: { info?: GitHubRateLimitInfo })
|
||||
label={t("github:showGithubApiLimits")}
|
||||
title={t("github:githubApiLimits")}
|
||||
description={t("github:currentGithubApiRateAndQuery")}
|
||||
content={<RateLimitDetails snapshots={snapshots} exhausted={exhausted} />}
|
||||
content={
|
||||
snapshots.length > 0 ? (
|
||||
<RateLimitDetails snapshots={snapshots} exhausted={exhausted} />
|
||||
) : undefined
|
||||
}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,13 @@ function automationActor(status: GitHubStatus): string | null {
|
||||
return status.automation?.actor?.login ?? null;
|
||||
}
|
||||
|
||||
function StatusLine({ status }: { status: GitHubStatus }) {
|
||||
function StatusLine({
|
||||
status,
|
||||
onRateLimitOpen,
|
||||
}: {
|
||||
status: GitHubStatus;
|
||||
onRateLimitOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const connection = status.automation;
|
||||
if (!connection) {
|
||||
@@ -99,7 +105,7 @@ function StatusLine({ status }: { status: GitHubStatus }) {
|
||||
{t(sourceLabelKeys[connection.source])}
|
||||
</Badge>
|
||||
{connection.status !== "active" && <Badge variant="outline">{connection.status}</Badge>}
|
||||
<GitHubRateLimitDisplay info={status.rate_limit} />
|
||||
<GitHubRateLimitDisplay info={status.rate_limit} onOpen={onRateLimitOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -120,15 +126,17 @@ function AutomationStatusSummary({
|
||||
status,
|
||||
app,
|
||||
taskAccess,
|
||||
onRateLimitOpen,
|
||||
}: {
|
||||
status: GitHubStatus;
|
||||
app?: GitHubAppRegistrationCatalogItem;
|
||||
taskAccess: Omit<TaskGitCredentialsState, "save">;
|
||||
onRateLimitOpen: () => void;
|
||||
}) {
|
||||
const appAutomation = status.automation?.source === "github_app_installation";
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<StatusLine status={status} />
|
||||
<StatusLine status={status} onRateLimitOpen={onRateLimitOpen} />
|
||||
<AutomationActorExplanation status={status} appAutomation={appAutomation} />
|
||||
{appAutomation && <AppRegistrationDetails app={app} />}
|
||||
<AutomationError status={status} />
|
||||
@@ -280,7 +288,12 @@ export function GitHubAutomationSettings({ workspaceId }: { workspaceId: string
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
data-testid="github-workspace-automation"
|
||||
>
|
||||
<AutomationStatusSummary status={status} app={activeApp} taskAccess={taskAccess} />
|
||||
<AutomationStatusSummary
|
||||
status={status}
|
||||
app={activeApp}
|
||||
taskAccess={taskAccess}
|
||||
onRateLimitOpen={refresh}
|
||||
/>
|
||||
<AutomationActions
|
||||
status={status}
|
||||
workspaceId={workspaceId}
|
||||
|
||||
@@ -2,6 +2,30 @@ import type { Page } from "@playwright/test";
|
||||
|
||||
const RATE_LIMIT_RESET_AT = "2030-01-01T00:00:00Z";
|
||||
|
||||
const REFRESHED_RATE_LIMIT = {
|
||||
core: {
|
||||
resource: "core",
|
||||
remaining: 3210,
|
||||
limit: 5000,
|
||||
reset_at: RATE_LIMIT_RESET_AT,
|
||||
updated_at: "2029-12-31T23:59:00Z",
|
||||
},
|
||||
graphql: {
|
||||
resource: "graphql",
|
||||
remaining: 4789,
|
||||
limit: 5000,
|
||||
reset_at: RATE_LIMIT_RESET_AT,
|
||||
updated_at: "2029-12-31T23:59:00Z",
|
||||
},
|
||||
search: {
|
||||
resource: "search",
|
||||
remaining: 24,
|
||||
limit: 30,
|
||||
reset_at: RATE_LIMIT_RESET_AT,
|
||||
updated_at: "2029-12-31T23:59:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
export async function stubGitHubRateLimits(page: Page, workspaceId: string) {
|
||||
await page.route("**/api/v1/github/status?*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
@@ -11,34 +35,15 @@ export async function stubGitHubRateLimits(page: Page, workspaceId: string) {
|
||||
}
|
||||
const response = await route.fetch();
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
const refreshed = url.searchParams.get("refresh_rate_limit") === "true";
|
||||
if (refreshed) {
|
||||
body.rate_limit = REFRESHED_RATE_LIMIT;
|
||||
} else {
|
||||
delete body.rate_limit;
|
||||
}
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: {
|
||||
...body,
|
||||
rate_limit: {
|
||||
core: {
|
||||
resource: "core",
|
||||
remaining: 4321,
|
||||
limit: 5000,
|
||||
reset_at: RATE_LIMIT_RESET_AT,
|
||||
updated_at: "2029-12-31T23:45:00Z",
|
||||
},
|
||||
graphql: {
|
||||
resource: "graphql",
|
||||
remaining: 4900,
|
||||
limit: 5000,
|
||||
reset_at: RATE_LIMIT_RESET_AT,
|
||||
updated_at: "2029-12-31T23:45:00Z",
|
||||
},
|
||||
search: {
|
||||
resource: "search",
|
||||
remaining: 25,
|
||||
limit: 30,
|
||||
reset_at: RATE_LIMIT_RESET_AT,
|
||||
updated_at: "2029-12-31T23:45:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
json: body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,11 +117,14 @@ test.describe("GitHub workspace settings", () => {
|
||||
await rateLimitHelp.hover();
|
||||
const rateLimitTooltip = testPage.getByRole("tooltip", { name: /API rate limit/ });
|
||||
await expect(rateLimitTooltip).toContainText(
|
||||
"API rate limit: 4,321 of 5,000 requests remaining",
|
||||
"API rate limit: 3,210 of 5,000 requests remaining",
|
||||
);
|
||||
await expect(rateLimitTooltip).toContainText(
|
||||
"GraphQL query limit: 4,900 of 5,000 points remaining",
|
||||
"GraphQL query limit: 4,789 of 5,000 points remaining",
|
||||
);
|
||||
await prCapture.screenshot("desktop-github-rate-limit-tooltip", {
|
||||
caption: "GitHub API rate limits refresh on hover",
|
||||
});
|
||||
|
||||
await automation.getByRole("button", { name: "Change connection" }).click();
|
||||
const dialog = testPage.getByRole("dialog", { name: "Change GitHub connection" });
|
||||
|
||||
@@ -55,11 +55,14 @@ test.describe("GitHub workspace settings on mobile", () => {
|
||||
await rateLimitHelp.tap();
|
||||
const rateLimitDrawer = testPage.getByRole("dialog", { name: "GitHub API limits" });
|
||||
await expect(rateLimitDrawer).toContainText(
|
||||
"API rate limit: 4,321 of 5,000 requests remaining",
|
||||
"API rate limit: 3,210 of 5,000 requests remaining",
|
||||
);
|
||||
await expect(rateLimitDrawer).toContainText(
|
||||
"GraphQL query limit: 4,900 of 5,000 points remaining",
|
||||
"GraphQL query limit: 4,789 of 5,000 points remaining",
|
||||
);
|
||||
await prCapture.screenshot("mobile-github-rate-limit-drawer", {
|
||||
caption: "GitHub API rate limits refresh in the mobile drawer",
|
||||
});
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
await automation.getByRole("button", { name: "Change connection" }).tap();
|
||||
|
||||
@@ -86,6 +86,13 @@ describe("useGitHubStatus workspace scoping", () => {
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(fetchGitHubStatusMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(fetchGitHubStatusMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
WORKSPACE_A,
|
||||
{ cache: "no-store" },
|
||||
true,
|
||||
);
|
||||
await act(async () =>
|
||||
second.resolve({
|
||||
workspace_id: WORKSPACE_A,
|
||||
|
||||
@@ -47,25 +47,28 @@ export function useGitHubStatus(requestedWorkspaceId?: string | null) {
|
||||
const resetGitHubStatus = useAppStore((state) => state.resetGitHubStatus);
|
||||
const invalidateSystemHealth = useAppStore((state) => state.invalidateSystemHealth);
|
||||
|
||||
const doFetch = useCallback(() => {
|
||||
if (!workspaceId) return;
|
||||
const version = nextRequestVersion(workspaceId);
|
||||
setGitHubStatusLoading(workspaceId, true);
|
||||
fetchGitHubStatus(workspaceId, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (isCurrentRequest(workspaceId, version)) {
|
||||
setGitHubStatus(workspaceId, normalizeGitHubStatus(response));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCurrentRequest(workspaceId, version)) setGitHubStatus(workspaceId, null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrentRequest(workspaceId, version)) {
|
||||
setGitHubStatusLoading(workspaceId, false);
|
||||
}
|
||||
});
|
||||
}, [setGitHubStatus, setGitHubStatusLoading, workspaceId]);
|
||||
const doFetch = useCallback(
|
||||
(refreshRateLimit = false) => {
|
||||
if (!workspaceId) return;
|
||||
const version = nextRequestVersion(workspaceId);
|
||||
setGitHubStatusLoading(workspaceId, true);
|
||||
fetchGitHubStatus(workspaceId, { cache: "no-store" }, refreshRateLimit)
|
||||
.then((response) => {
|
||||
if (isCurrentRequest(workspaceId, version)) {
|
||||
setGitHubStatus(workspaceId, normalizeGitHubStatus(response));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCurrentRequest(workspaceId, version)) setGitHubStatus(workspaceId, null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrentRequest(workspaceId, version)) {
|
||||
setGitHubStatusLoading(workspaceId, false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[setGitHubStatus, setGitHubStatusLoading, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
@@ -82,7 +85,7 @@ export function useGitHubStatus(requestedWorkspaceId?: string | null) {
|
||||
const refresh = useCallback(() => {
|
||||
invalidateSystemHealth();
|
||||
if (!workspaceId) return;
|
||||
doFetch();
|
||||
doFetch(true);
|
||||
}, [doFetch, invalidateSystemHealth, workspaceId]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -96,6 +96,16 @@ describe("workspace GitHub authentication", () => {
|
||||
expect(call?.[1]).toMatchObject({ cache: "no-store" });
|
||||
});
|
||||
|
||||
it("requests a fresh rate-limit snapshot when requested", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ workspace_id: workspaceID }));
|
||||
|
||||
await fetchGitHubStatus(workspaceID, { cache: "no-store" }, true);
|
||||
|
||||
expect(lastCallUrl()).toBe(
|
||||
"http://api.test/api/v1/github/status?workspace_id=ws%2F1&refresh_rate_limit=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects an exact named CLI account", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ workspace_id: "workspace one" }));
|
||||
|
||||
|
||||
@@ -12,8 +12,13 @@ import type {
|
||||
StartGitHubAppManifestResponse,
|
||||
} from "@/lib/types/github";
|
||||
|
||||
export async function fetchGitHubStatus(workspaceId: string, options?: ApiRequestOptions) {
|
||||
export async function fetchGitHubStatus(
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
refreshRateLimit = false,
|
||||
) {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
if (refreshRateLimit) query.set("refresh_rate_limit", "true");
|
||||
return fetchJson<GitHubStatusResponse>(`/api/v1/github/status?${query}`, options);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user