-
Notifications
You must be signed in to change notification settings - Fork 7
/
Copy pathslack.go
423 lines (366 loc) · 15 KB
/
slack.go
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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/zaiminc/gocat/deploy"
"github.com/zaiminc/gocat/slackcmd"
)
// SlackListener is a http.Handler that can handle slack events.
// See https://api.slack.com/apis/connections/events-api for more details about events.
type SlackListener struct {
client *slack.Client
verificationToken string
projectList *ProjectList
userList *UserList
interactorFactory *InteractorFactory
coordinator *deploy.Coordinator
}
func (s SlackListener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(r.Body); err != nil {
fmt.Printf("[ERROR] Failed to read request body: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
body := buf.String()
header := r.Header
if header.Get("X-Slack-Retry-Num") != "" {
slackRetryNum, _ := strconv.Atoi(header.Get("X-Slack-Retry-Num"))
if slackRetryNum > 0 {
return
}
}
eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionVerifyToken(&slackevents.TokenComparator{VerificationToken: s.verificationToken}))
if err != nil {
fmt.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if eventsAPIEvent.Type == slackevents.URLVerification {
var r *slackevents.ChallengeResponse
err = json.Unmarshal([]byte(body), &r)
if err != nil {
fmt.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text")
if _, err := w.Write([]byte(r.Challenge)); err != nil {
fmt.Printf("[ERROR] Failed to write challenge response: %s", err)
w.WriteHeader(http.StatusInternalServerError)
}
return
}
if eventsAPIEvent.Type == slackevents.CallbackEvent {
innerEvent := eventsAPIEvent.InnerEvent
switch ev := innerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
if err := s.handleMessageEvent(ev); err != nil {
log.Println("[ERROR] ", err)
w.WriteHeader(http.StatusInternalServerError)
}
}
}
}
func (s *SlackListener) handleMessageEvent(ev *slackevents.AppMentionEvent) error {
// Only response mention to bot. Ignore else.
log.Print(ev.Text)
if regexp.MustCompile(`help`).MatchString(ev.Text) {
if _, _, err := s.client.PostMessage(ev.Channel, s.helpMessage()); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if regexp.MustCompile(`ls`).MatchString(ev.Text) {
if _, _, err := s.client.PostMessage(ev.Channel, s.projectListMessage()); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if regexp.MustCompile(`reload`).MatchString(ev.Text) {
s.projectList.Reload()
s.userList.Reload()
section := slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", "Deploy Projects and Users is Reloaded", false, false), nil, nil)
if _, _, err := s.client.PostMessage(ev.Channel, slack.MsgOptionBlocks(section)); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
s.projectList.Reload()
s.userList.Reload()
if match := regexp.MustCompile(`deploy ([0-9a-zA-Z-]+) (staging|production|sandbox|stg|pro|prd) branch`).FindAllStringSubmatch(ev.Text, -1); match != nil {
log.Println("[INFO] Deploy command is Called")
commands := strings.Split(match[0][0], " ")
target, err := s.projectList.FindByAlias(commands[1])
if err != nil {
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(ev.Channel, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
phase := s.toPhase(commands[2])
interactor := s.interactorFactory.Get(target, phase)
blocks, err := interactor.BranchList(target, phase)
if err != nil {
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(ev.Channel, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if _, _, err := s.client.PostMessage(ev.Channel, slack.MsgOptionBlocks(blocks...)); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if match := regexp.MustCompile(`deploy ([0-9a-zA-Z-]+) (staging|production|sandbox|stg|pro|prd)`).FindAllStringSubmatch(ev.Text, -1); match != nil {
log.Println("[INFO] Deploy command is Called")
commands := strings.Split(match[0][0], " ")
target, err := s.projectList.FindByAlias(commands[1])
if err != nil {
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(ev.Channel, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
phase := s.toPhase(commands[2])
if msg, locked := s.checkDeploymentLock(target.ID, phase, ev.User, ev.Channel); locked {
if _, _, err := s.client.PostMessage(ev.Channel, msg); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
interactor := s.interactorFactory.Get(target, phase)
blocks, err := interactor.Request(target, phase, target.DefaultBranch(), ev.User, ev.Channel)
if err != nil {
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(ev.Channel, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if _, _, err := s.client.PostMessage(ev.Channel, slack.MsgOptionBlocks(blocks...)); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if regexp.MustCompile(`deploy staging`).MatchString(ev.Text) {
msgOpt := s.SelectDeployTarget("staging")
if _, _, err := s.client.PostMessage(ev.Channel, msgOpt); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if regexp.MustCompile(`deploy production`).MatchString(ev.Text) {
msgOpt := s.SelectDeployTarget("production")
if _, _, err := s.client.PostMessage(ev.Channel, msgOpt); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if regexp.MustCompile(`deploy sandbox`).MatchString(ev.Text) {
msgOpt := s.SelectDeployTarget("sandbox")
if _, _, err := s.client.PostMessage(ev.Channel, msgOpt); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
if cmd, _ := slackcmd.Parse(ev.Text); cmd != nil {
log.Printf("[INFO] %s command is Called", cmd.Name())
return s.runCommand(cmd, ev.User, ev.Channel)
}
if _, _, err := s.client.PostMessage(ev.Channel, s.errorMessage("Invalid command. Say `@bot help` to see the usage guide")); err != nil {
log.Println("[INFO] invalid command", ev.Text)
}
return nil
}
func (s *SlackListener) helpMessage() slack.MsgOption {
deployMasterText := slack.NewTextBlockObject("mrkdwn", "*masterのデプロイ*\n`@bot-name deploy api staging`\napiの部分はその他アプリケーションに置換可能です。stagingの部分はproductionやsandboxに置換可能です。\nコマンド入力後にデプロイするかの確認ボタンが出てきます。", false, false)
deployMasterSection := slack.NewSectionBlock(deployMasterText, nil, nil)
deployBranchText := slack.NewTextBlockObject("mrkdwn", "*ブランチのデプロイ*\n`@bot-name deploy api staging branch`\napiの部分はその他アプリケーションに置換可能です。stagingの部分はproductionやsandboxに置換可能です。\nブランチを選択するドロップダウンが出てきます。\nブランチ選択後にデプロイするかの確認ボタンが出てきます。", false, false)
deployBranchSection := slack.NewSectionBlock(deployBranchText, nil, nil)
deployText := slack.NewTextBlockObject("mrkdwn", "*デプロイ対象の選択をSlackのUIから選択するデプロイ手法*\n`@bot-name deploy staging`\nstagingの部分はproductionやsandboxに置換可能です。\nデプロイ対象の選択後にデプロイするブランチの選択肢が出てきます。", false, false)
deploySection := slack.NewSectionBlock(deployText, nil, nil)
lockText := slack.NewTextBlockObject("mrkdwn", "*デプロイロックをとる*\n`@bot-name lock api staging for REASON`\napiの部分はその他アプリケーションに置換可能です。stagingの部分はproductionやsandboxに置換可能です。\nREASON部分にロックする理由を指定する必要があります。", false, false)
lockSection := slack.NewSectionBlock(lockText, nil, nil)
unlockText := slack.NewTextBlockObject("mrkdwn", "*デプロイロックを解除する*\n`@bot-name unlock api staging`\napiの部分はその他アプリケーションに置換可能です。stagingの部分はproductionやsandboxに置換可能です。", false, false)
unlockSection := slack.NewSectionBlock(unlockText, nil, nil)
describeLocksText := slack.NewTextBlockObject("mrkdwn", "*デプロイロックの状態を確認する*\n`@bot-name describe locks`\nデプロイロックの状態を確認します。", false, false)
describeLocksSection := slack.NewSectionBlock(describeLocksText, nil, nil)
return slack.MsgOptionBlocks(
deployMasterSection,
deployBranchSection,
deploySection,
lockSection,
unlockSection,
describeLocksSection,
CloseButton(),
)
}
func (s *SlackListener) projectListMessage() slack.MsgOption {
text := ""
for _, pj := range s.projectList.Items {
text = text + fmt.Sprintf("*%s* (%s)\n", pj.ID, pj.GitHubRepository())
}
listText := slack.NewTextBlockObject("mrkdwn", text, false, false)
listSection := slack.NewSectionBlock(listText, nil, nil)
return slack.MsgOptionBlocks(
listSection,
CloseButton(),
)
}
// SelectDeployTarget デプロイ対象を選択するボタンを表示する
func (s *SlackListener) SelectDeployTarget(phase string) slack.MsgOption {
headerText := slack.NewTextBlockObject("mrkdwn", ":cat:", false, false)
headerSection := slack.NewSectionBlock(headerText, nil, nil)
sections := make([]slack.Block, len(s.projectList.Items)+2)
sections[0] = headerSection
for i, pj := range s.projectList.Items {
sections[i+1] = createDeployButtonSection(pj, phase)
}
sections[len(sections)-1] = CloseButton()
return slack.MsgOptionBlocks(sections...)
}
func createDeployButtonSection(pj DeployProject, phaseName string) *slack.SectionBlock {
action := "branchlist"
if pj.DisableBranchDeploy {
action = "request"
}
phase := pj.FindPhase(phaseName)
txt := slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*%s* (%s)", pj.ID, pj.GitHubRepository()), false, false)
btnTxt := slack.NewTextBlockObject("plain_text", "Deploy", false, false)
btn := slack.NewButtonBlockElement("", fmt.Sprintf("deploy_%s_%s|%s_%s", phase.Kind, action, pj.ID, phase.Name), btnTxt)
section := slack.NewSectionBlock(txt, nil, slack.NewAccessory(btn))
return section
}
// runCommand runs the given command.
//
// triggeredBy is the ID of the Slack user who triggered the command,
// and replyIn is the ID of the Slack channel to reply to.
func (s *SlackListener) runCommand(cmd slackcmd.Command, triggeredBy string, replyIn string) error {
var msgOpt slack.MsgOption
user := s.userList.FindBySlackUserID(triggeredBy)
switch cmd := cmd.(type) {
case *slackcmd.Lock:
msgOpt = s.lock(cmd, user, replyIn)
case *slackcmd.Unlock:
msgOpt = s.unlock(cmd, user, replyIn)
case *slackcmd.DescribeLocks:
msgOpt = s.describeLocks()
default:
panic("unreachable")
}
if _, _, err := s.client.PostMessage(replyIn, msgOpt); err != nil {
log.Println("[ERROR] ", err)
}
return nil
}
// lock locks the given project and environment, and replies to the given channel.
func (s *SlackListener) lock(cmd *slackcmd.Lock, triggeredBy User, replyIn string) slack.MsgOption {
if err := s.validateProjectEnvUser(cmd.Project, cmd.Env, triggeredBy, replyIn); err != nil {
return s.errorMessage(err.Error())
}
if err := s.coordinator.Lock(context.Background(), cmd.Project, cmd.Env, triggeredBy.SlackDisplayName, cmd.Reason); err != nil {
return s.errorMessage(err.Error())
}
return s.infoMessage(fmt.Sprintf("Locked %s %s", cmd.Project, cmd.Env))
}
// unlock unlocks the given project and environment, and replies to the given channel.
func (s *SlackListener) unlock(cmd *slackcmd.Unlock, triggeredBy User, replyIn string) slack.MsgOption {
if err := s.validateProjectEnvUser(cmd.Project, cmd.Env, triggeredBy, replyIn); err != nil {
return s.errorMessage(err.Error())
}
if err := s.coordinator.Unlock(context.Background(), cmd.Project, cmd.Env, triggeredBy.SlackDisplayName, triggeredBy.IsAdmin()); err != nil {
return s.errorMessage(err.Error())
}
return s.infoMessage(fmt.Sprintf("Unlocked %s %s", cmd.Project, cmd.Env))
}
// describeLocks describes the locks of all projects and environments, and replies to the given channel.
func (s *SlackListener) describeLocks() slack.MsgOption {
projects, err := s.coordinator.DescribeLocks(context.Background())
if err != nil {
return s.errorMessage(err.Error())
}
msg := deploy.FormatProjectDescs(projects)
return s.infoMessage(msg)
}
func (s *SlackListener) checkDeploymentLock(projectID, env string, triggeredBy string, replyIn string) (slack.MsgOption, bool) {
locks, err := s.coordinator.FetchLocks(context.Background(), projectID, env)
if err != nil {
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(replyIn, s.infoMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return nil, false
}
if len(locks) == 0 {
// Missing lock means it has never been locked.
return nil, false
}
lock, ok := locks[projectID][env]
if !ok {
// Missing lock means it has never been locked.
return nil, false
}
user := s.userList.FindBySlackUserID(triggeredBy)
if lock.Locked && lock.LockHistory[len(lock.LockHistory)-1].User != user.SlackDisplayName {
cause := fmt.Sprintf("locked by %s", lock.LockHistory[len(lock.LockHistory)-1].User)
return s.infoMessage(fmt.Sprintf("Deployment failed: %s", cause)), true
}
return nil, false
}
func (s *SlackListener) validateProjectEnvUser(projectID, env string, user User, replyIn string) error {
pj, err := s.projectList.FindByAlias(projectID)
if err != nil {
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(replyIn, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return fmt.Errorf("find by alias %q: %w", projectID, err)
}
if phase := pj.FindPhase(env); phase.None() {
err = fmt.Errorf("phase %s is not found", env)
log.Println("[ERROR] ", err)
if _, _, err := s.client.PostMessage(replyIn, s.errorMessage(err.Error())); err != nil {
log.Println("[ERROR] ", err)
}
return fmt.Errorf("find phase %q: %w", env, err)
}
if !user.IsDeveloper() {
return fmt.Errorf("you are not allowed to lock/unlock projects: %q is missing the Developer role", user.SlackDisplayName)
}
return nil
}
func (s *SlackListener) infoMessage(message string) slack.MsgOption {
txt := slack.NewTextBlockObject("mrkdwn", message, false, false)
section := slack.NewSectionBlock(txt, nil, nil)
return slack.MsgOptionBlocks(section)
}
func (s *SlackListener) errorMessage(message string) slack.MsgOption {
txt := slack.NewTextBlockObject("mrkdwn", message, false, false)
section := slack.NewSectionBlock(txt, nil, nil)
return slack.MsgOptionBlocks(section)
}
func (s *SlackListener) toPhase(str string) string {
switch str {
case "pro", "prd", "production":
return "production"
case "stg", "staging":
return "staging"
case "sandbox":
return "sandbox"
default:
return "staging"
}
}