-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommitstatus.go
More file actions
266 lines (241 loc) · 8.29 KB
/
commitstatus.go
File metadata and controls
266 lines (241 loc) · 8.29 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
package github
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path"
"regexp"
"strings"
"time"
"github.com/Pix4D/go-kit/retry"
)
// StatusError is one of the possible errors returned when interacting with the
// "Commit Statuses" API https://docs.github.com/en/rest/commits/statuses
type StatusError struct {
What string
StatusCode int // The HTTP status code.
Details string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("%s\n%s", e.What, e.Details)
}
// GhDefaultHostname is the default GitHub hostname (used for git but not for the API)
const GhDefaultHostname = "github.com"
var localhostRegexp = regexp.MustCompile(`^127.0.0.1:[0-9]+$`)
type Target struct {
// Client is the http client
Client *http.Client
// Server is the GitHub API server.
Server string
// Retry controls the retry logic.
Retry retry.Retry
}
// CommitStatus is a wrapper to the GitHub API to set the commit status for a specific
// GitHub owner and repo.
// See also:
// - NewCommitStatus
// - https://docs.github.com/en/rest/commits/statuses
type CommitStatus struct {
target *Target
token string
owner string
repo string
context string
log *slog.Logger
}
// DefaultRetry returns a [retry.Retry] with the recommended values to be passed to
// [NewCommitStatus] for production. If you have special requirements, or for testing,
// you can override completely or partially.
func DefaultRetry(log *slog.Logger) retry.Retry {
upTo := 15 * time.Minute
return retry.Retry{
UpTo: upTo,
FirstDelay: 2 * time.Second,
// With an exponential backoff and a FirstDelay = 2s, the sequence will be:
// 2s 4s 8s 16s 32s 60s ... 60s, until reaching a cumulative delay of UpTo.
BackoffLimit: 1 * time.Minute,
Log: log,
}
}
// NewCommitStatus returns a CommitStatus object associated to a specific GitHub owner
// and repo.
// Parameter token is the personal OAuth token of a user that has write access to the
// repo. It only needs the repo:status scope.
// Parameter context is what created the status, for example "JOBNAME", or
// "PIPELINENAME/JOBNAME". The name comes from the GitHub API.
// Be careful when using PIPELINENAME: if that name is ephemeral, it will make it
// impossible to use GitHub repository branch protection rules.
//
// See also:
// - https://docs.github.com/en/rest/commits/statuses
func NewCommitStatus(
target *Target,
token, owner, repo, context string,
log *slog.Logger,
) CommitStatus {
return CommitStatus{
target: target,
token: token,
owner: owner,
repo: repo,
context: context,
log: log,
}
}
// AddRequest is the JSON object sent to the API.
type AddRequest struct {
State string `json:"state"`
TargetURL string `json:"target_url"`
Description string `json:"description"`
Context string `json:"context"`
}
// Add sets the commit state to the given sha, decorating it with targetURL and optional
// description.
// In case of transient errors or rate limiting by the backend, Add performs a certain
// number of attempts before giving up. The retry logic is configured in the Target.Retry
// parameter of NewCommitStatus.
// Parameter sha is the 40 hexadecimal digit sha associated to the commit to decorate.
// Parameter state is one of error, failure, pending, success.
// Parameter targetURL (optional) points to the specific process (for example, a CI build)
// that generated this state.
// Parameter description (optional) gives more information about the status.
// The returned error contains some diagnostic information to help troubleshooting.
//
// See also: https://docs.github.com/en/rest/commits/statuses#create-a-commit-status
func (cs CommitStatus) Add(ctx context.Context, sha, state, targetURL, description string) error {
// API: POST /repos/{owner}/{repo}/statuses/{sha}
url := cs.target.Server + path.Join("/repos", cs.owner, cs.repo, "statuses", sha)
reqBody := AddRequest{
State: state,
TargetURL: targetURL,
Description: description,
Context: cs.context,
}
reqBodyJSON, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("JSON encode: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(reqBodyJSON))
if err != nil {
return fmt.Errorf("create http request: %w", err)
}
req.Header.Set("Authorization", "token "+cs.token)
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("Content-Type", "application/json")
// The retryable unit of work.
workFn := func() (retry.Action, error) {
start := time.Now()
resp, err := cs.target.Client.Do(req)
if err != nil {
return retry.HardFail, fmt.Errorf("http client Do: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
elapsed := time.Since(start)
remaining := resp.Header.Get("X-RateLimit-Remaining")
limit := resp.Header.Get("X-RateLimit-Limit")
reset := resp.Header.Get("X-RateLimit-Reset")
contentType := resp.Header.Get("Content-Type")
cs.log.Debug(
"http-request",
"method", req.Method,
"url", req.URL,
"status", resp.StatusCode,
"duration", elapsed,
"rate-limit", limit,
"rate-limit-remaining", remaining,
"rate-limit-reset", reset,
)
if resp.StatusCode == http.StatusCreated {
return retry.Success, nil
}
body, _ := io.ReadAll(resp.Body)
buffer := body
if strings.Contains(strings.ToLower(contentType), "application/json") {
var foo map[string]any
if err := json.Unmarshal(body, &foo); err != nil {
return retry.HardFail, fmt.Errorf("normalizing JSON: unmarshal: %s", err)
}
buffer, err = json.Marshal(foo)
if err != nil {
return retry.HardFail, fmt.Errorf("normalizing JSON: marshal: %s", err)
}
}
ghErr := NewGitHubError(resp, errors.New(strings.TrimSpace(string(buffer))))
if TransientError(resp.StatusCode) {
return retry.SoftFail, ghErr
}
if RateLimited(ghErr) {
return retry.SoftFail, ghErr
}
return retry.HardFail, ghErr
}
if err := cs.target.Retry.Do(Backoff, workFn); err != nil {
return cs.explainError(err, state, sha, url)
}
return nil
}
// TODO: can we merge (at least partially) this function in GitHubError.Error ?
// As-is, it is redundant. On the other hand, GitHubError.Error is now public
// and used by other tools, so we must not merge hints specific to the
// Commit Status API.
func (cs CommitStatus) explainError(err error, state, sha, url string) error {
commonWhat := fmt.Sprintf(
"failed to add state %q for commit %s",
state,
sha[0:min(len(sha), 7)],
)
if ghErr, ok := errors.AsType[GitHubError](err); ok {
hint := "none"
switch ghErr.StatusCode {
case http.StatusNotFound:
hint = fmt.Sprintf(`one of the following happened:
1. The repo https://github.com/%s doesn't exist
2. The user who issued the token doesn't have write access to the repo
3. The token doesn't have scope repo:status`,
path.Join(cs.owner, cs.repo))
case http.StatusInternalServerError:
hint = "Github API is down"
case http.StatusUnauthorized:
hint = "Either wrong credentials or PAT expired (check your email for expiration notice)"
case http.StatusForbidden:
if ghErr.RateLimitRemaining == 0 {
hint = fmt.Sprintf(
"Rate limited but the wait time to reset would be longer than %v (Retry.UpTo)",
cs.target.Retry.UpTo,
)
}
}
return &StatusError{
What: fmt.Sprintf("%s: %d %s", commonWhat, ghErr.StatusCode,
http.StatusText(ghErr.StatusCode)),
StatusCode: ghErr.StatusCode,
Details: fmt.Sprintf("Body: %s\nHint: %s\nAction: %s %s\nOAuth: %s",
ghErr, hint, http.MethodPost, url, ghErr.OauthInfo),
}
}
return &StatusError{
What: fmt.Sprintf("%s: %s", commonWhat, err),
Details: fmt.Sprintf("Action: %s %s", http.MethodPost, url),
}
}
// ApiRoot constructs the root part of the GitHub API URL for a given hostname.
// Example:
// if hostname is github.com it returns https://api.github.com
// if hostname looks like a httptest server, it returns http://127.0.0.1:PORT
// otherwise, hostname is assumed to be of a Github Enterprise instance.
// For example, github.mycompany.org returns https://github.mycompany.org/api/v3
func ApiRoot(h string) string {
hostname := strings.ToLower(h)
if hostname == GhDefaultHostname {
return "https://api.github.com"
}
if localhostRegexp.MatchString(hostname) {
return fmt.Sprintf("http://%s", hostname)
}
return fmt.Sprintf("https://%s/api/v3", hostname)
}