-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathgithub.go
More file actions
289 lines (251 loc) · 10.4 KB
/
github.go
File metadata and controls
289 lines (251 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
package githubauth
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
// defaultBaseURL is the default GitHub API base URL.
defaultBaseURL = "https://api.github.com/"
// maxRetrySleep caps sleeps between retries so a misbehaving or hostile
// server cannot stall callers indefinitely via Retry-After.
maxRetrySleep = 60 * time.Second
// defaultThrottleBackoff is the fallback delay when GitHub returns 429
// without any retry hint header.
defaultThrottleBackoff = 1 * time.Second
)
// ErrRateLimited wraps errors returned when GitHub has throttled a request
// (HTTP 429 or 403 with rate-limit headers). Callers can branch with errors.Is.
var ErrRateLimited = errors.New("github API rate limited")
// InstallationTokenOptions specifies options for creating an installation token.
type InstallationTokenOptions struct {
// Repositories is a list of repository names that the token should have access to.
Repositories []string `json:"repositories,omitempty"`
// RepositoryIDs is a list of repository IDs that the token should have access to.
RepositoryIDs []int64 `json:"repository_ids,omitempty"`
// Permissions are the permissions granted to the access token.
Permissions *InstallationPermissions `json:"permissions,omitempty"`
}
// InstallationPermissions represents the permissions granted to an installation token.
type InstallationPermissions struct {
Actions *string `json:"actions,omitempty"`
Administration *string `json:"administration,omitempty"`
Checks *string `json:"checks,omitempty"`
Contents *string `json:"contents,omitempty"`
ContentReferences *string `json:"content_references,omitempty"`
Deployments *string `json:"deployments,omitempty"`
Environments *string `json:"environments,omitempty"`
Issues *string `json:"issues,omitempty"`
Metadata *string `json:"metadata,omitempty"`
Packages *string `json:"packages,omitempty"`
Pages *string `json:"pages,omitempty"`
PullRequests *string `json:"pull_requests,omitempty"`
RepositoryAnnouncementBanners *string `json:"repository_announcement_banners,omitempty"`
RepositoryHooks *string `json:"repository_hooks,omitempty"`
RepositoryProjects *string `json:"repository_projects,omitempty"`
SecretScanningAlerts *string `json:"secret_scanning_alerts,omitempty"`
Secrets *string `json:"secrets,omitempty"`
SecurityEvents *string `json:"security_events,omitempty"`
SingleFile *string `json:"single_file,omitempty"`
Statuses *string `json:"statuses,omitempty"`
VulnerabilityAlerts *string `json:"vulnerability_alerts,omitempty"`
Workflows *string `json:"workflows,omitempty"`
Members *string `json:"members,omitempty"`
OrganizationAdministration *string `json:"organization_administration,omitempty"`
OrganizationCustomRoles *string `json:"organization_custom_roles,omitempty"`
OrganizationAnnouncementBanners *string `json:"organization_announcement_banners,omitempty"`
OrganizationHooks *string `json:"organization_hooks,omitempty"`
OrganizationPlan *string `json:"organization_plan,omitempty"`
OrganizationProjects *string `json:"organization_projects,omitempty"`
OrganizationPackages *string `json:"organization_packages,omitempty"`
OrganizationSecrets *string `json:"organization_secrets,omitempty"`
OrganizationSelfHostedRunners *string `json:"organization_self_hosted_runners,omitempty"`
OrganizationUserBlocking *string `json:"organization_user_blocking,omitempty"`
TeamDiscussions *string `json:"team_discussions,omitempty"`
}
// InstallationToken represents a GitHub App installation token.
type InstallationToken struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Permissions *InstallationPermissions `json:"permissions,omitempty"`
Repositories []Repository `json:"repositories,omitempty"`
}
// Repository represents a GitHub repository.
type Repository struct {
ID *int64 `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
}
// githubClient is a simple GitHub API client for creating installation tokens.
type githubClient struct {
baseURL *url.URL
client *http.Client
retryOnThrottle bool
}
// newGitHubClient creates a new GitHub API client.
func newGitHubClient(httpClient *http.Client) *githubClient {
baseURL, _ := url.Parse(defaultBaseURL)
return &githubClient{
baseURL: baseURL,
client: httpClient,
retryOnThrottle: true,
}
}
// withEnterpriseURL sets the base URL for GitHub Enterprise Server.
func (c *githubClient) withEnterpriseURL(baseURL string) (*githubClient, error) {
base, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse base URL: %w", err)
}
if !strings.HasSuffix(base.Path, "/") {
base.Path += "/"
}
if !strings.HasSuffix(base.Path, "/api/v3/") &&
!strings.HasPrefix(base.Host, "api.") &&
!strings.Contains(base.Host, ".api.") {
base.Path += "api/v3/"
}
c.baseURL = base
return c, nil
}
// createInstallationToken creates an installation access token for a GitHub App.
// When retryOnThrottle is enabled, a single retry is performed on 429 or on 403
// responses that carry Retry-After / x-ratelimit-reset headers. The sleep is
// capped at maxRetrySleep and honors ctx cancellation.
//
// API documentation: https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app
func (c *githubClient) createInstallationToken(ctx context.Context, installationID int64, opts *InstallationTokenOptions) (*InstallationToken, error) {
endpoint := fmt.Sprintf("app/installations/%d/access_tokens", installationID)
u, err := c.baseURL.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse endpoint URL: %w", err)
}
var bodyBytes []byte
if opts != nil {
bodyBytes, err = json.Marshal(opts)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
}
token, delay, err := c.doCreateInstallationToken(ctx, u.String(), bodyBytes)
if err == nil {
return token, nil
}
if !c.retryOnThrottle || !errors.Is(err, ErrRateLimited) {
return nil, err
}
if sleepErr := sleepCtx(ctx, delay); sleepErr != nil {
return nil, sleepErr
}
token, _, err = c.doCreateInstallationToken(ctx, u.String(), bodyBytes)
return token, err
}
// doCreateInstallationToken performs a single POST attempt. On a throttled
// response it returns the desired retry delay in addition to the error so the
// caller can decide whether to retry. A zero delay indicates the error is not
// retryable.
func (c *githubClient) doCreateInstallationToken(ctx context.Context, reqURL string, bodyBytes []byte) (*InstallationToken, time.Duration, error) {
var body io.Reader
if bodyBytes != nil {
body = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, body)
if err != nil {
return nil, 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("failed to execute request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
var token InstallationToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return nil, 0, fmt.Errorf("failed to decode response: %w", err)
}
return &token, 0, nil
}
bodyResp, _ := io.ReadAll(resp.Body)
if delay, ok := c.throttleDelay(resp); ok {
return nil, delay, fmt.Errorf("%w: GitHub API returned status %d: %s", ErrRateLimited, resp.StatusCode, string(bodyResp))
}
return nil, 0, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(bodyResp))
}
// throttleDelay inspects a non-2xx response and reports the retry hint from
// GitHub's rate-limit headers. The bool is true when the response is considered
// retryable (429 always, 403 only when a parseable retry header is present).
// The returned duration is capped at maxRetrySleep. An unparseable header is
// treated as absent — a malformed hint must not silently flip a terminal 403
// into a retry.
func (c *githubClient) throttleDelay(resp *http.Response) (time.Duration, bool) {
if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode != http.StatusForbidden {
return 0, false
}
if v := resp.Header.Get("Retry-After"); v != "" {
if d, ok := parseRetryAfter(v, time.Now()); ok {
return capDelay(d), true
}
}
if v := resp.Header.Get("X-RateLimit-Reset"); v != "" {
if reset, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
return capDelay(time.Until(time.Unix(reset, 0))), true
}
}
if resp.StatusCode == http.StatusTooManyRequests {
return defaultThrottleBackoff, true
}
return 0, false
}
// parseRetryAfter accepts either integer seconds ("30") or an HTTP-date
// ("Fri, 31 Dec 1999 23:59:59 GMT"), per RFC 7231 §7.1.3. The bool is false
// when the value is neither form, so callers can distinguish "no hint" from
// "zero seconds".
func parseRetryAfter(v string, now time.Time) (time.Duration, bool) {
v = strings.TrimSpace(v)
if secs, err := strconv.Atoi(v); err == nil {
return time.Duration(secs) * time.Second, true
}
if t, err := http.ParseTime(v); err == nil {
return t.Sub(now), true
}
return 0, false
}
func capDelay(d time.Duration) time.Duration {
if d > maxRetrySleep {
return maxRetrySleep
}
if d < 0 {
return 0
}
return d
}
// sleepCtx sleeps for d or until ctx is cancelled, whichever comes first.
func sleepCtx(ctx context.Context, d time.Duration) error {
if d <= 0 {
return nil
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// Ptr is a helper function to create a pointer to a value.
// This is useful when constructing InstallationTokenOptions with permissions.
func Ptr[T any](v T) *T {
return &v
}