-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathcommitstatus.go
More file actions
248 lines (224 loc) · 7.61 KB
/
commitstatus.go
File metadata and controls
248 lines (224 loc) · 7.61 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
// Package github implements the GitHub APIs used by Cogito (Commit status API).
//
// See the README and CONTRIBUTING files for additional information, caveats about GitHub
// API and imposed limits, and reference to official documentation.
package github
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path"
"regexp"
"strings"
"time"
"github.com/Pix4D/cogito/retry"
)
// StatusError is one of the possible errors returned by the github package.
type StatusError struct {
What string
StatusCode int
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
}
// 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() error {
start := time.Now()
resp, err := cs.target.Client.Do(req)
if err != nil {
return fmt.Errorf("http client Do: %w", err)
}
defer resp.Body.Close()
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 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 fmt.Errorf("normalizing JSON: unmarshal: %s", err)
}
buffer, err = json.Marshal(foo)
if err != nil {
return fmt.Errorf("normalizing JSON: marshal: %s", err)
}
}
return NewGitHubError(resp, errors.New(strings.TrimSpace(string(buffer))))
}
if err := cs.target.Retry.Do(Backoff, Classifier, 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)],
)
var ghErr GitHubError
if errors.As(err, &ghErr) {
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)
}