Skip to content

Commit 5be58d3

Browse files
committed
Delay GitHub issue searches
Experience has shown that these searches can return outdated results if run immediately on status update webhook events. This has caused some PRs to get stuck in the merging state without being merged, because the last status update event has already been sent, but the search during that event did not show that the PR was ready to be merged. Because there will be no additional events for that PR (unless users manually use `!check`, for example) the PR will not get merged. A simpler solution would have been to simply sleep for the specified amount (2 seconds by default) and then do the search. But that approach would mean that the GitHub webhook HTTP request and its connection is unnecessarily kept open for the duration of the sleep + how long it then takes to finish the search + merge. This is not too much of an issue for the default 2 second delay, because Go's standard HTTP server handles concurrency nicely, and even thousands of concurrent requests (even if they're just sleeping) could be handled without a problem. However, if it turns out that 2 seconds isn't sufficient, and a longer period has to be used, then the described problem can start becoming an issue. Furthermore, the async approach is much nicer towards GitHub webhook service, which probably has to worry about too many connections that stay open for too long already. This approach does make the HTTP responses and tests a bit less valuable, because neither can capture the final success/failure any longer. Tests can still check that the async code sends all the right requests to GitHub etc, but without parsing the logs or mocking the delay mechanism, there's no way for the tests to verify the result.
1 parent b12833b commit 5be58d3

File tree

6 files changed

+129
-31
lines changed

6 files changed

+129
-31
lines changed

config.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"strconv"
5+
"time"
56

67
"github.com/deiwin/gonfigure"
78
)
@@ -10,22 +11,32 @@ var (
1011
portProperty = gonfigure.NewEnvProperty("PORT", "80")
1112
accessTokenProperty = gonfigure.NewRequiredEnvProperty("GITHUB_ACCESS_TOKEN")
1213
secretProperty = gonfigure.NewRequiredEnvProperty("GITHUB_SECRET")
14+
// In the format defined in time.ParseDuration. E.g. "300ms", "-1.5h" or "2h45m".
15+
githubAPIDelayProperty = gonfigure.NewEnvProperty("GITHUB_API_DELAY", "2s")
1316
)
1417

1518
type Config struct {
16-
Port int
17-
AccessToken string
18-
Secret string
19+
Port int
20+
AccessToken string
21+
Secret string
22+
GithubAPIDelay time.Duration
1923
}
2024

2125
func NewConfig() Config {
2226
port, err := strconv.Atoi(portProperty.Value())
2327
if err != nil {
2428
panic(err)
2529
}
30+
31+
githubAPIDelay, err := time.ParseDuration(githubAPIDelayProperty.Value())
32+
if err != nil {
33+
panic(err)
34+
}
35+
2636
return Config{
27-
Port: port,
28-
AccessToken: accessTokenProperty.Value(),
29-
Secret: secretProperty.Value(),
37+
Port: port,
38+
AccessToken: accessTokenProperty.Value(),
39+
Secret: secretProperty.Value(),
40+
GithubAPIDelay: githubAPIDelay,
3041
}
3142
}

github_review_helper_suite_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net/http/httptest"
1313
"strconv"
1414
"strings"
15+
"sync"
1516

1617
"github.com/google/go-github/github"
1718
grh "github.com/salemove/github-review-helper"
@@ -70,7 +71,8 @@ type WebhookTest func(WebhookTestContext)
7071
var TestWebhookHandler = func(test WebhookTest) bool {
7172
Describe("webhook handler", func() {
7273
var (
73-
conf grh.Config
74+
conf grh.Config
75+
asyncOperationWg *sync.WaitGroup
7476

7577
requestJSON = NewStringMemoizer(func() string {
7678
return ""
@@ -101,7 +103,9 @@ var TestWebhookHandler = func(test WebhookTest) bool {
101103
conf = grh.Config{
102104
Secret: "a-secret",
103105
}
104-
*handler = grh.CreateHandler(conf, *gitRepos, *pullRequests, *repositories, *issues, *search)
106+
asyncOperationWg = &sync.WaitGroup{}
107+
*handler = grh.CreateHandler(conf, *gitRepos, asyncOperationWg, *pullRequests,
108+
*repositories, *issues, *search)
105109
})
106110

107111
JustBeforeEach(func() {
@@ -134,6 +138,9 @@ var TestWebhookHandler = func(test WebhookTest) bool {
134138
var handle = func() {
135139
response := (*handler)(*responseRecorder, *request)
136140
response.WriteResponse(*responseRecorder)
141+
// The delay is set to 0 for tests. Wait for all of the operations
142+
// to finish to simplify test code.
143+
asyncOperationWg.Wait()
137144
}
138145

139146
test(WebhookTestContext{

handler.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ type Handler func(http.ResponseWriter, *http.Request) Response
99

1010
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1111
response := h(w, r)
12+
log.Println("Responding to the HTTP request with:")
13+
response.logResponse()
1214
response.WriteResponse(w)
1315
}
1416

1517
type Response interface {
1618
WriteResponse(http.ResponseWriter)
19+
logResponse()
1720
}
1821

1922
type ErrorResponse struct {
@@ -23,19 +26,33 @@ type ErrorResponse struct {
2326
}
2427

2528
func (r ErrorResponse) WriteResponse(w http.ResponseWriter) {
29+
http.Error(w, r.ErrorMessage, r.Code)
30+
}
31+
32+
func (r ErrorResponse) logResponse() {
2633
if r.Error != nil {
27-
log.Printf("%s: %v\n", r.ErrorMessage, r.Error)
34+
log.Printf("Error: %s: %v\n", r.ErrorMessage, r.Error)
2835
} else {
29-
log.Println(r.ErrorMessage)
36+
log.Printf("Error: %s\n", r.ErrorMessage)
3037
}
31-
http.Error(w, r.ErrorMessage, r.Code)
3238
}
3339

3440
type SuccessResponse struct {
3541
Message string
3642
}
3743

3844
func (r SuccessResponse) WriteResponse(w http.ResponseWriter) {
39-
log.Printf("Responding with a success message: %s\n", r.Message)
4045
w.Write([]byte(r.Message))
4146
}
47+
48+
func (r SuccessResponse) logResponse() {
49+
log.Printf("Success: %s\n", r.Message)
50+
}
51+
52+
// handleAsyncResponse provides consistent error/success logging for operations
53+
// that are left to continue working after the original HTTP request that
54+
// initiated the operation has been handled and closed.
55+
func handleAsyncResponse(response Response) {
56+
log.Println("Finishing an asynchronous operation with:")
57+
response.logResponse()
58+
}

main.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io/ioutil"
66
"net/http"
77
"os"
8+
"sync"
89
"time"
910

1011
"gopkg.in/tylerb/graceful.v1"
@@ -30,22 +31,25 @@ func main() {
3031
defer os.RemoveAll(reposDir)
3132

3233
gitRepos := git.NewRepos(reposDir)
34+
var asyncOperationWg sync.WaitGroup
3335

3436
mux := http.NewServeMux()
3537
mux.Handle("/", CreateHandler(
3638
conf,
3739
gitRepos,
40+
&asyncOperationWg,
3841
githubClient.PullRequests,
3942
githubClient.Repositories,
4043
githubClient.Issues,
4144
githubClient.Search,
4245
))
4346

4447
graceful.Run(fmt.Sprintf(":%d", conf.Port), 10*time.Second, mux)
48+
asyncOperationWg.Wait()
4549
}
4650

47-
func CreateHandler(conf Config, gitRepos git.Repos, pullRequests PullRequests, repositories Repositories,
48-
issues Issues, search Search) Handler {
51+
func CreateHandler(conf Config, gitRepos git.Repos, asyncOperationWg *sync.WaitGroup, pullRequests PullRequests,
52+
repositories Repositories, issues Issues, search Search) Handler {
4953
return func(w http.ResponseWriter, r *http.Request) Response {
5054
body, err := ioutil.ReadAll(r.Body)
5155
if err != nil {
@@ -61,7 +65,7 @@ func CreateHandler(conf Config, gitRepos git.Repos, pullRequests PullRequests, r
6165
case "pull_request":
6266
return handlePullRequestEvent(body, pullRequests, repositories)
6367
case "status":
64-
return handleStatusEvent(body, gitRepos, search, issues, pullRequests)
68+
return handleStatusEvent(body, conf, asyncOperationWg, gitRepos, search, issues, pullRequests)
6569
}
6670
return SuccessResponse{"Not an event I understand. Ignoring."}
6771
}
@@ -108,13 +112,19 @@ func handlePullRequestEvent(body []byte, pullRequests PullRequests, repositories
108112
return checkForFixupCommitsOnPREvent(pullRequestEvent, pullRequests, repositories)
109113
}
110114

111-
func handleStatusEvent(body []byte, gitRepos git.Repos, search Search, issues Issues,
112-
pullRequests PullRequests) Response {
115+
func handleStatusEvent(body []byte, conf Config, asyncOperationWg *sync.WaitGroup, gitRepos git.Repos, search Search,
116+
issues Issues, pullRequests PullRequests) Response {
113117
statusEvent, err := parseStatusEvent(body)
114118
if err != nil {
115119
return ErrorResponse{err, http.StatusInternalServerError, "Failed to parse the request's body"}
116120
} else if newPullRequestsPossiblyReadyForMerging(statusEvent) {
117-
return mergePullRequestsReadyForMerging(statusEvent, gitRepos, search, issues, pullRequests)
121+
delay(conf.GithubAPIDelay, func() Response {
122+
return mergePullRequestsReadyForMerging(statusEvent, gitRepos, search, issues, pullRequests)
123+
}, asyncOperationWg)
124+
return SuccessResponse{
125+
fmt.Sprintf("Status update might have caused a PR to become mergeable. Scheduled an operation "+
126+
"which will start in %s to check for mergeable PRs.", conf.GithubAPIDelay.String()),
127+
}
118128
}
119129
return SuccessResponse{"Status update does not affect any PRs mergeability. Ignoring."}
120130
}
@@ -179,3 +189,12 @@ func checkUserAuthorization(issueComment IssueComment, issues Issues, repositori
179189
}
180190
return nil, nil
181191
}
192+
193+
func delay(duration time.Duration, operation func() Response, asyncOperationWg *sync.WaitGroup) {
194+
asyncOperationWg.Add(1)
195+
time.AfterFunc(duration, func() {
196+
defer asyncOperationWg.Done()
197+
response := operation()
198+
handleAsyncResponse(response)
199+
})
200+
}

merge_command_state_update_test.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,11 @@ var _ = TestWebhookHandler(func(context WebhookTestContext) {
103103
mockSearchQuery(1).Return(emptyResult, emptyResponse, errors.New("arbitrary error"))
104104
})
105105

106-
It("fails with a gateway error", func() {
106+
// async errors are logged, but won't affect the outcome of
107+
// the HTTP request
108+
It("returns 200 OK", func() {
107109
handle()
108-
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
110+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
109111
})
110112
})
111113

@@ -148,9 +150,11 @@ var _ = TestWebhookHandler(func(context WebhookTestContext) {
148150
Return(emptyResult, emptyResponse, errArbitrary)
149151
})
150152

151-
It("fails with a gateway error", func() {
153+
// async errors are logged, but won't affect the outcome of
154+
// the HTTP request
155+
It("returns 200 OK", func() {
152156
handle()
153-
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
157+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
154158
})
155159
})
156160

@@ -176,7 +180,8 @@ var _ = TestWebhookHandler(func(context WebhookTestContext) {
176180
Return(pr, emptyResponse, noError)
177181
})
178182

179-
ItMergesPR(context, pr)
183+
isAsync := true
184+
ItMergesPR(context, pr, isAsync)
180185
})
181186
})
182187

merge_command_test.go

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ func commentMentioning(user string) func(issueComment *github.IssueComment) bool
218218
}
219219
}
220220

221-
var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
221+
var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest, opts ...bool) {
222222
var (
223223
handle = context.Handle
224224

@@ -230,6 +230,11 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
230230
issueAuthor string
231231
issueNumber int
232232
headRef string
233+
234+
// async errors are logged, but won't affect the outcome of the HTTP
235+
// request. The tests can only confirm that the right stubs were called
236+
// for async operations.
237+
isAsync bool
233238
)
234239
BeforeEach(func() {
235240
responseRecorder = *context.ResponseRecorder
@@ -240,6 +245,12 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
240245
issueAuthor = *pr.User.Login
241246
issueNumber = *pr.Number
242247
headRef = *pr.Head.Ref
248+
249+
if len(opts) == 0 {
250+
isAsync = false
251+
} else {
252+
isAsync = opts[0]
253+
}
243254
})
244255

245256
Context("with merge failing with an unknown error", func() {
@@ -260,7 +271,11 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
260271

261272
It("fails with a gateway error", func() {
262273
handle()
263-
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
274+
if isAsync {
275+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
276+
} else {
277+
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
278+
}
264279
})
265280
})
266281

@@ -306,7 +321,11 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
306321
Return(emptyResult, emptyResponse, noError)
307322

308323
handle()
309-
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
324+
if isAsync {
325+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
326+
} else {
327+
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
328+
}
310329
})
311330

312331
Context("with author notification failing", func() {
@@ -319,7 +338,11 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
319338

320339
It("fails with a gateway error", func() {
321340
handle()
322-
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
341+
if isAsync {
342+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
343+
} else {
344+
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
345+
}
323346
})
324347
})
325348
})
@@ -366,7 +389,11 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
366389

367390
It("fails with a gateway error", func() {
368391
handle()
369-
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
392+
if isAsync {
393+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
394+
} else {
395+
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
396+
}
370397
})
371398
})
372399

@@ -397,7 +424,11 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
397424

398425
It("fails with a gateway error", func() {
399426
handle()
400-
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
427+
if isAsync {
428+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
429+
} else {
430+
Expect(responseRecorder.Code).To(Equal(http.StatusBadGateway))
431+
}
401432
})
402433
})
403434

@@ -418,7 +449,11 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
418449

419450
It("fails with an internal error", func() {
420451
handle()
421-
Expect(responseRecorder.Code).To(Equal(http.StatusInternalServerError))
452+
if isAsync {
453+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
454+
} else {
455+
Expect(responseRecorder.Code).To(Equal(http.StatusInternalServerError))
456+
}
422457
})
423458
})
424459

@@ -439,7 +474,11 @@ var ItMergesPR = func(context WebhookTestContext, pr *github.PullRequest) {
439474

440475
It("fails with an internal error", func() {
441476
handle()
442-
Expect(responseRecorder.Code).To(Equal(http.StatusInternalServerError))
477+
if isAsync {
478+
Expect(responseRecorder.Code).To(Equal(http.StatusOK))
479+
} else {
480+
Expect(responseRecorder.Code).To(Equal(http.StatusInternalServerError))
481+
}
443482
})
444483
})
445484

0 commit comments

Comments
 (0)