Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/events.go

Line attribution on main · 714 line(s)

eb07ff56
Keenan Nemetz
8d ago
1 package server
2
3 // Event triggers: change requests, issues, comments, schedules, and manual runs.
4 //
5 // A push is a ref, so it can be reconciled from the refs. An event is a change to
6 // the metadata branch, so it is reconciled from that branch's commits: the cursor is
7 // the last commit whose events were applied, and every commit after it is read in
8 // order. That includes writes made by the browser, which never go through a Git
9 // push, and it keeps the spool a latency optimisation rather than the only path.
10 //
11 // A metadata event queues a run at the trusted branch tip (the change request's base,
12 // or the default branch). A change request's head is described in the payload and
13 // its changed files are listed there, but the head's code is not checked out: an
14 // event run must not be a way to execute an unapproved contributor tip.
15
16 import (
17 "context"
18 "encoding/json"
19 "fmt"
20 "strings"
21 "time"
22
d37d301f
Keenan Nemetz
16h ago
23 "rodent.software/vole/ci"
24 "rodent.software/vole/core/git"
25 "rodent.software/vole/core/id"
26 "rodent.software/vole/forge/access"
27 "rodent.software/vole/forge/repo"
28 "rodent.software/vole/metadata"
eb07ff56
Keenan Nemetz
8d ago
29 )
30
31 const (
32 metadataSeconds = 5 * time.Second
33 maxPayloadBody = 16 << 10
34 )
35
36 // eventPayload is the JSON a triggered step reads from VOLE_EVENT_PATH. It is
37 // deliberately data: the only place user text reaches a step is this file, not an
38 // interpolated command.
39 type eventPayload struct {
40 Event string `json:"event"`
41 Action string `json:"action"`
42 Repository string `json:"repository"`
43 Ref string `json:"ref,omitempty"`
44 Branch string `json:"branch,omitempty"`
45 Commit string `json:"commit,omitempty"`
46 Sender string `json:"sender,omitempty"`
47 BaseSHA string `json:"base_sha,omitempty"`
48 HeadSHA string `json:"head_sha,omitempty"`
49 ChangedFiles []string `json:"changed_files,omitempty"`
50 Issue *payloadIssue `json:"issue,omitempty"`
51 Change *payloadRequest `json:"change_request,omitempty"`
52 Comment *payloadComment `json:"comment,omitempty"`
53 CommentOn string `json:"comment_on,omitempty"`
54 Inputs map[string]string `json:"inputs,omitempty"`
55 Schedule string `json:"schedule,omitempty"`
56 }
57
58 type payloadIssue struct {
727df386
Keenan Nemetz
8d ago
59 ID string `json:"id"`
60 Title string `json:"title"`
61 State string `json:"state"`
62 Author string `json:"author"`
63 Labels []string `json:"labels,omitempty"`
64 Assignees []string `json:"assignees,omitempty"`
65 AssigneesAdded []string `json:"assignees_added,omitempty"`
66 AssigneesRemoved []string `json:"assignees_removed,omitempty"`
67 Mentions []string `json:"mentions,omitempty"`
68 Body string `json:"body,omitempty"`
69 BodyTruncated bool `json:"body_truncated,omitempty"`
70 URL string `json:"url"`
71 }
72
73 type payloadRequest struct {
eb07ff56
Keenan Nemetz
8d ago
74 ID string `json:"id"`
75 Title string `json:"title"`
76 State string `json:"state"`
77 Author string `json:"author"`
727df386
Keenan Nemetz
8d ago
78 Base string `json:"base"`
79 Head string `json:"head"`
80 HeadRef string `json:"head_ref,omitempty"`
81 Mentions []string `json:"mentions,omitempty"`
eb07ff56
Keenan Nemetz
8d ago
82 Body string `json:"body,omitempty"`
83 BodyTruncated bool `json:"body_truncated,omitempty"`
84 URL string `json:"url"`
85 }
86
87 type payloadComment struct {
727df386
Keenan Nemetz
8d ago
88 ID string `json:"id"`
89 Author string `json:"author"`
90 Body string `json:"body,omitempty"`
91 BodyTruncated bool `json:"body_truncated,omitempty"`
92 Mentions []string `json:"mentions,omitempty"`
93 ReplyTo string `json:"reply_to,omitempty"`
94 ReplyToAuthor string `json:"reply_to_author,omitempty"`
95 Deleted bool `json:"deleted,omitempty"`
96 URL string `json:"url"`
eb07ff56
Keenan Nemetz
8d ago
97 }
98
99 // watchMetadata applies metadata events at startup and then on a timer. A browser
100 // write also pokes the wake channel, so a comment does not wait for the tick.
101 func (s *Server) watchMetadata(ctx context.Context) {
102 s.reconcileMetadata(true)
103 t := time.NewTicker(metadataSeconds)
104 defer t.Stop()
105 for {
106 select {
107 case <-ctx.Done():
108 return
109 case <-t.C:
110 s.reconcileMetadata(false)
111 case <-s.metadataWake:
112 s.reconcileMetadata(false)
113 }
114 }
115 }
116
117 func (s *Server) wakeMetadata(repo string) {
118 select {
119 case s.metadataWake <- struct{}{}:
120 default:
121 }
122 }
123
124 // reconcileMetadata moves each repository's metadata cursor forward one commit at a
125 // time. A repository whose metadata branch has not been seen yet is baselined: an
126 // instance that starts on an old repository must not replay every issue it ever had.
127 func (s *Server) reconcileMetadata(baseline bool) {
128 repos, err := s.Repos.List()
129 if err != nil {
130 s.Log.Warn("ci: cannot list repositories for metadata events", "err", err)
131 return
132 }
133 now := time.Now().UTC()
134 for _, m := range repos {
135 tip, err := m.Git().RevParse(metadata.Ref)
136 if err != nil || tip == "" {
137 continue
138 }
139 cursors, err := s.Runs.Cursors(m.Name)
140 if err != nil {
141 s.Log.Warn("ci: cannot read metadata cursor", "repo", m.Name, "err", err)
142 continue
143 }
144 old, seen := cursors[metadata.Ref]
145 if !seen {
146 if baseline {
147 // Start from here rather than firing every event the repository
148 // accumulated before this instance first looked. A branch created later
149 // has no earlier events to lose.
150 if err := s.Runs.SetCursor(m.Name, metadata.Ref, tip, now); err != nil {
151 s.Log.Warn("ci: cannot baseline the metadata cursor", "repo", m.Name, "err", err)
152 }
153 continue
154 }
155 old = ""
156 }
157 if old == tip {
158 continue
159 }
160 if old != "" {
161 ancestor, err := m.Git().IsAncestor(old, tip)
162 if err != nil || !ancestor {
163 // A rewritten metadata branch is not a sequence of events to replay.
164 if err := s.Runs.SetCursor(m.Name, metadata.Ref, tip, now); err != nil {
165 s.Log.Warn("ci: cannot reset the metadata cursor", "repo", m.Name, "err", err)
166 }
167 continue
168 }
169 }
170 commits, err := m.Git().LogRange(old, tip)
171 if err != nil {
172 s.Log.Warn("ci: cannot read metadata commits", "repo", m.Name, "err", err)
173 continue
174 }
175 for _, c := range commits {
176 s.processMetadataCommit(m, c)
177 if err := s.Runs.SetCursor(m.Name, metadata.Ref, c.SHA, now); err != nil {
178 s.Log.Warn("ci: cannot record the metadata cursor", "repo", m.Name, "commit", c.SHA, "err", err)
179 }
180 }
181 }
182 }
183
184 // processMetadataRange applies the commits of one metadata push immediately, so a
185 // CLI comment does not wait for the reconciler's tick. The cursor is left to the
186 // reconciler: this is an optimisation, and the reconciler is what makes the range
187 // complete when a spool entry was never written.
188 func (s *Server) processMetadataRange(repoName, old, next string) {
189 if next == "" || next == git.ZeroSHA {
190 return
191 }
192 m, err := s.Repos.Open(repoName)
193 if err != nil {
194 return
195 }
196 if old != "" && old != git.ZeroSHA {
197 ancestor, err := m.Git().IsAncestor(old, next)
198 if err != nil || !ancestor {
199 return
200 }
201 }
202 commits, err := m.Git().LogRange(old, next)
203 if err != nil {
204 s.Log.Warn("ci: cannot read metadata commits from the spool", "repo", repoName, "err", err)
205 return
206 }
207 for _, c := range commits {
208 s.processMetadataCommit(m, c)
209 }
210 }
211
212 func (s *Server) processMetadataCommit(m *repo.Meta, c git.Commit) {
213 parents, err := m.Git().CommitParents(c.SHA)
214 if err != nil {
215 s.Log.Warn("ci: cannot read metadata commit parents", "repo", m.Name, "commit", c.SHA, "err", err)
216 return
217 }
218 old := ""
219 if len(parents) > 0 {
220 old = parents[0]
221 }
222 changed, err := changedPaths(m.Git(), old, c.SHA)
223 if err != nil {
224 s.Log.Warn("ci: cannot read metadata changes", "repo", m.Name, "commit", c.SHA, "err", err)
225 return
226 }
227 acl, _ := m.ACL()
228 seen := map[string]bool{}
229 for _, path := range changed {
230 issueID, commentID, kind, ok := metadataPathIDs(path)
231 if !ok {
232 continue
233 }
234 where := c.SHA + ":" + kind + ":" + issueID + ":" + commentID
235 if seen[where] {
236 continue
237 }
238 seen[where] = true
239 s.processMetadataPath(m, old, c, acl, issueID, commentID, kind)
240 }
241 }
242
243 // metadataPathIDs names the entity a metadata path belongs to. The paths are the
244 // public format, so this is the one place the server decides what an issue, a
245 // request, an approval and a comment look like on disk.
246 func metadataPathIDs(path string) (entityID, commentID, kind string, ok bool) {
247 parts := strings.Split(path, "/")
248 switch {
249 case len(parts) == 3 && parts[0] == metadata.IssuesDir && parts[2] == "issue.md":
250 if id.Valid(parts[1]) {
251 return parts[1], "", "issue", true
252 }
253 case len(parts) == 4 && parts[0] == metadata.IssuesDir && parts[2] == "comments" &&
254 strings.HasSuffix(parts[3], ".md"):
255 cid := strings.TrimSuffix(parts[3], ".md")
256 if id.Valid(parts[1]) && id.Valid(cid) {
257 return parts[1], cid, "issue_comment", true
258 }
b492b557
Keenan Nemetz
3d ago
259 case len(parts) == 3 && parts[0] == metadata.ChangesDir && parts[2] == "change.md":
eb07ff56
Keenan Nemetz
8d ago
260 if id.Valid(parts[1]) {
261 return parts[1], "", "request", true
262 }
b492b557
Keenan Nemetz
3d ago
263 case len(parts) == 4 && parts[0] == metadata.ChangesDir && parts[2] == "comments" &&
eb07ff56
Keenan Nemetz
8d ago
264 strings.HasSuffix(parts[3], ".md"):
265 cid := strings.TrimSuffix(parts[3], ".md")
266 if id.Valid(parts[1]) && id.Valid(cid) {
267 return parts[1], cid, "request_comment", true
268 }
b492b557
Keenan Nemetz
3d ago
269 case len(parts) == 4 && parts[0] == metadata.ChangesDir && parts[2] == "approvals":
eb07ff56
Keenan Nemetz
8d ago
270 if id.Valid(parts[1]) {
271 return parts[1], "", "approval", true
272 }
273 }
274 return "", "", "", false
275 }
276
277 func (s *Server) processMetadataPath(m *repo.Meta, old string, c git.Commit, acl *access.ACL, entityID, commentID, kind string) {
278 switch kind {
279 case "issue":
727df386
Keenan Nemetz
8d ago
280 s.issueEvent(m, old, c, acl, entityID)
eb07ff56
Keenan Nemetz
8d ago
281 case "issue_comment":
727df386
Keenan Nemetz
8d ago
282 s.commentEvent(m, old, c, acl, entityID, commentID, false)
eb07ff56
Keenan Nemetz
8d ago
283 case "request_comment":
727df386
Keenan Nemetz
8d ago
284 s.commentEvent(m, old, c, acl, entityID, commentID, true)
eb07ff56
Keenan Nemetz
8d ago
285 case "request", "approval":
286 s.changeRequestEvent(m, old, c, acl, entityID, kind == "request")
287 }
288 }
289
727df386
Keenan Nemetz
8d ago
290 func (s *Server) issueEvent(m *repo.Meta, old string, c git.Commit, acl *access.ACL, issueID string) {
eb07ff56
Keenan Nemetz
8d ago
291 oldIssue, _, _ := metadata.LoadIssueAtCommit(m.Git(), old, issueID)
292 newIssue, exists, err := metadata.LoadIssueAtCommit(m.Git(), c.SHA, issueID)
293 if err != nil || !exists {
294 return
295 }
727df386
Keenan Nemetz
8d ago
296 var oldAssignees []string
297 if oldIssue != nil {
298 oldAssignees = oldIssue.Assignees
299 }
300 added, removed := nameDiff(oldAssignees, newIssue.Assignees)
301 actions := make([]string, 0, 2)
eb07ff56
Keenan Nemetz
8d ago
302 switch {
303 case oldIssue == nil:
727df386
Keenan Nemetz
8d ago
304 actions = append(actions, "opened")
eb07ff56
Keenan Nemetz
8d ago
305 case oldIssue.State != newIssue.State:
306 if newIssue.State == "closed" {
727df386
Keenan Nemetz
8d ago
307 actions = append(actions, "closed")
eb07ff56
Keenan Nemetz
8d ago
308 } else {
727df386
Keenan Nemetz
8d ago
309 actions = append(actions, "reopened")
eb07ff56
Keenan Nemetz
8d ago
310 }
311 default:
727df386
Keenan Nemetz
8d ago
312 actions = append(actions, "edited")
313 }
314 // An assignment is also an edit, so an existing edited listener keeps seeing
315 // the change. A workflow that wants only assignments listens for assigned.
316 if oldIssue != nil && len(added) > 0 {
317 actions = append(actions, "assigned")
eb07ff56
Keenan Nemetz
8d ago
318 }
319 ref, branch, commit, err := s.defaultBranchCommit(m)
320 if err != nil {
321 s.Log.Warn("ci: cannot resolve the default branch for an issue event", "repo", m.Name, "err", err)
322 return
323 }
324 body, clipped := clipBody(newIssue.Description)
325 payload := eventPayload{
727df386
Keenan Nemetz
8d ago
326 Event: "issue", Repository: m.Name, Ref: ref, Branch: branch,
eb07ff56
Keenan Nemetz
8d ago
327 Commit: commit, Sender: c.Author,
328 Issue: &payloadIssue{
329 ID: newIssue.ID, Title: newIssue.Title, State: newIssue.State, Author: newIssue.Author,
330 Labels: newIssue.Labels, Assignees: newIssue.Assignees,
727df386
Keenan Nemetz
8d ago
331 AssigneesAdded: added, AssigneesRemoved: removed,
332 Mentions: metadata.Mentions(newIssue.Description),
333 Body: body, BodyTruncated: clipped,
ed03b3d1
Keenan Nemetz
3h ago
334 URL: "/" + m.Name + "/issues/" + newIssue.ID,
eb07ff56
Keenan Nemetz
8d ago
335 },
336 }
727df386
Keenan Nemetz
8d ago
337 for _, action := range actions {
338 s.queuePayload(m, "issue", action, ref, branch, "", commit, acl, payload,
339 c.SHA+":issue:"+action+":"+issueID)
340 }
341 }
342
343 // nameDiff reports which names were added to and removed from a set. Both lists
344 // keep the order the metadata writer stores, so the event is deterministic.
345 func nameDiff(before, after []string) (added, removed []string) {
346 had := make(map[string]bool, len(before))
347 for _, name := range before {
348 had[name] = true
349 }
350 have := make(map[string]bool, len(after))
351 for _, name := range after {
352 have[name] = true
353 if !had[name] {
354 added = append(added, name)
355 }
356 }
357 for _, name := range before {
358 if !have[name] {
359 removed = append(removed, name)
360 }
361 }
362 return added, removed
eb07ff56
Keenan Nemetz
8d ago
363 }
364
727df386
Keenan Nemetz
8d ago
365 func (s *Server) commentEvent(m *repo.Meta, old string, c git.Commit, acl *access.ACL, entityID, commentID string, request bool) {
eb07ff56
Keenan Nemetz
8d ago
366 var server string
367 if request {
368 server = "request"
369 } else {
370 server = "issue"
371 }
372 oldComment := findComment(m, old, entityID, commentID, request)
373 newComment := findComment(m, c.SHA, entityID, commentID, request)
374 if oldComment == nil && newComment == nil {
375 return
376 }
377 action := "edited"
378 switch {
379 case oldComment == nil:
380 action = "created"
381 case newComment == nil || newComment.Deleted && !oldComment.Deleted:
382 action = "deleted"
383 }
384 shown := newComment
385 if shown == nil {
386 shown = oldComment
387 }
388 ref, branch, commit, err := s.defaultBranchCommit(m)
389 if err != nil {
390 s.Log.Warn("ci: cannot resolve the default branch for a comment event", "repo", m.Name, "err", err)
391 return
392 }
393 var issue *payloadIssue
394 var change *payloadRequest
395 var url string
396 if request {
397 if req, ok, _ := metadata.LoadRequestAt(m.Git(), c.SHA, entityID); ok && req != nil {
ed03b3d1
Keenan Nemetz
3h ago
398 url = "/" + m.Name + "/changes/" + req.ID
eb07ff56
Keenan Nemetz
8d ago
399 body, clipped := clipBody(req.Description)
400 change = &payloadRequest{ID: req.ID, Title: req.Title, State: req.State, Author: req.Author,
727df386
Keenan Nemetz
8d ago
401 Base: req.Base, Head: req.Head, HeadRef: req.HeadRef,
402 Mentions: metadata.Mentions(req.Description), Body: body,
eb07ff56
Keenan Nemetz
8d ago
403 BodyTruncated: clipped, URL: url}
404 }
405 } else if iss, ok, _ := metadata.LoadIssueAtCommit(m.Git(), c.SHA, entityID); ok && iss != nil {
ed03b3d1
Keenan Nemetz
3h ago
406 url = "/" + m.Name + "/issues/" + iss.ID
eb07ff56
Keenan Nemetz
8d ago
407 body, clipped := clipBody(iss.Description)
408 issue = &payloadIssue{ID: iss.ID, Title: iss.Title, State: iss.State, Author: iss.Author,
727df386
Keenan Nemetz
8d ago
409 Labels: iss.Labels, Assignees: iss.Assignees, Mentions: metadata.Mentions(iss.Description),
410 Body: body, BodyTruncated: clipped, URL: url}
411 }
412 replyToAuthor := ""
413 if shown.ReplyTo != "" {
414 parent := findComment(m, c.SHA, entityID, shown.ReplyTo, request)
415 if parent == nil {
416 parent = findComment(m, old, entityID, shown.ReplyTo, request)
417 }
418 if parent != nil {
419 replyToAuthor = parent.Author
420 }
eb07ff56
Keenan Nemetz
8d ago
421 }
422 body, clipped := clipBody(shown.Body)
423 payload := eventPayload{
424 Event: "comment", Action: action, Repository: m.Name, Ref: ref, Branch: branch,
425 Commit: commit, Sender: c.Author, CommentOn: server, Issue: issue, Change: change,
426 Comment: &payloadComment{ID: shown.ID, Author: shown.Author, Body: body,
727df386
Keenan Nemetz
8d ago
427 BodyTruncated: clipped, Mentions: metadata.Mentions(shown.Body),
428 ReplyTo: shown.ReplyTo, ReplyToAuthor: replyToAuthor, Deleted: shown.Deleted,
eb07ff56
Keenan Nemetz
8d ago
429 URL: url + "#c-" + shown.ID},
430 }
727df386
Keenan Nemetz
8d ago
431 s.queuePayload(m, "comment", action, ref, branch, "", commit, acl, payload,
eb07ff56
Keenan Nemetz
8d ago
432 c.SHA+":comment:"+action+":"+entityID+":"+commentID)
433 }
434
435 func findComment(m *repo.Meta, commit, entityID, commentID string, request bool) *metadata.Comment {
436 if commit == "" {
437 return nil
438 }
439 if request {
440 req, ok, _ := metadata.LoadRequestAt(m.Git(), commit, entityID)
441 if !ok || req == nil {
442 return nil
443 }
444 for i := range req.Comments {
445 if req.Comments[i].ID == commentID {
446 return &req.Comments[i]
447 }
448 }
449 return nil
450 }
451 iss, ok, _ := metadata.LoadIssueAtCommit(m.Git(), commit, entityID)
452 if !ok || iss == nil {
453 return nil
454 }
455 for i := range iss.Comments {
456 if iss.Comments[i].ID == commentID {
457 return &iss.Comments[i]
458 }
459 }
460 return nil
461 }
462
463 func (s *Server) changeRequestEvent(m *repo.Meta, old string, c git.Commit, acl *access.ACL, requestID string, requestFile bool) {
464 oldReq, _, _ := metadata.LoadRequestAt(m.Git(), old, requestID)
465 newReq, exists, err := metadata.LoadRequestAt(m.Git(), c.SHA, requestID)
466 if err != nil || !exists || newReq == nil {
467 return
468 }
469 baseRef := "refs/heads/" + newReq.Base
470 base, err := m.Git().RevParse(baseRef)
471 if err != nil || base == "" {
472 s.Log.Warn("ci: cannot resolve a change's base for an event", "repo", m.Name, "request", requestID, "base", newReq.Base, "err", err)
473 return
474 }
475 changed, err := changedPaths(m.Git(), base, newReq.Head)
476 if err != nil {
477 s.Log.Warn("ci: cannot read a change's files for an event", "repo", m.Name, "request", requestID, "err", err)
478 return
479 }
480 body, clipped := clipBody(newReq.Description)
481 payload := eventPayload{
482 Event: "change_request", Repository: m.Name, Ref: baseRef, Branch: newReq.Base,
483 Commit: base, Sender: c.Author, BaseSHA: base, HeadSHA: newReq.Head,
484 ChangedFiles: changed,
485 Change: &payloadRequest{ID: newReq.ID, Title: newReq.Title, State: newReq.State,
486 Author: newReq.Author, Base: newReq.Base, Head: newReq.Head, HeadRef: newReq.HeadRef,
727df386
Keenan Nemetz
8d ago
487 Mentions: metadata.Mentions(newReq.Description),
ed03b3d1
Keenan Nemetz
3h ago
488 Body: body, BodyTruncated: clipped, URL: "/" + m.Name + "/changes/" + newReq.ID},
eb07ff56
Keenan Nemetz
8d ago
489 }
490 actions := []string{}
491 switch {
492 case oldReq == nil:
493 actions = append(actions, "opened")
494 case oldReq.State != newReq.State:
495 switch newReq.State {
496 case "merged":
497 actions = append(actions, "merged")
498 case "closed":
499 actions = append(actions, "closed")
500 case "open":
501 actions = append(actions, "updated")
502 }
503 case requestFile && (oldReq.Head != newReq.Head || oldReq.HeadRef != newReq.HeadRef ||
504 oldReq.Base != newReq.Base || oldReq.Title != newReq.Title ||
505 oldReq.Description != newReq.Description):
506 actions = append(actions, "updated")
507 }
508 required := 0
509 if acl != nil {
510 required = acl.RequiredApprovals()
511 }
512 if newReq.State == "open" && required > 0 && acl != nil {
513 nowReady := newReq.CurrentApprovalsBy(acl.EligibleApprover) >= required
514 wasReady := oldReq != nil && oldReq.State == "open" &&
515 oldReq.CurrentApprovalsBy(acl.EligibleApprover) >= required
516 if nowReady && !wasReady {
517 actions = append(actions, "ready")
518 }
519 }
520 for _, action := range actions {
727df386
Keenan Nemetz
8d ago
521 s.queuePayload(m, "change_request", action, baseRef, newReq.Base, "", base, acl, payload,
eb07ff56
Keenan Nemetz
8d ago
522 c.SHA+":change_request:"+action+":"+requestID)
523 }
524 }
525
526 func (s *Server) defaultBranchCommit(m *repo.Meta) (ref, branch, commit string, err error) {
527 branch = strings.TrimSpace(m.DefaultBranch)
528 if branch == "" {
529 branch = "main"
530 }
531 ref = "refs/heads/" + branch
532 commit, err = m.Git().RevParse(ref)
533 if err != nil {
534 return "", "", "", err
535 }
536 if commit == "" {
537 return "", "", "", fmt.Errorf("%s has no commits", ref)
538 }
539 return ref, branch, commit, nil
540 }
541
727df386
Keenan Nemetz
8d ago
542 func (s *Server) queuePayload(m *repo.Meta, trigger, action, ref, branch, tag, commit string, acl *access.ACL, payload eventPayload, occurrence string) {
eb07ff56
Keenan Nemetz
8d ago
543 payload.Event = trigger
544 payload.Action = action
545 body, err := json.Marshal(payload)
546 if err != nil {
547 s.Log.Warn("ci: cannot build an event payload", "repo", m.Name, "trigger", trigger, "err", err)
548 return
549 }
550 if len(body) > ci.MaxEventPayloadBytes {
551 s.Log.Warn("ci: event payload is too large", "repo", m.Name, "trigger", trigger,
552 "bytes", len(body), "limit", ci.MaxEventPayloadBytes)
553 return
554 }
727df386
Keenan Nemetz
8d ago
555 event := eventContext(trigger, action, payload, acl)
eb07ff56
Keenan Nemetz
8d ago
556 n := s.queue(m, m.Name, queueSpec{
557 ref: ref, branch: branch, tag: tag, commit: commit,
727df386
Keenan Nemetz
8d ago
558 trigger: trigger, action: action, event: event,
559 payload: string(body), occurrence: occurrence,
eb07ff56
Keenan Nemetz
8d ago
560 })
561 if n > 0 {
562 s.Log.Info("ci: queued an event run", "repo", m.Name, "trigger", trigger, "action", action, "runs", n)
563 }
564 }
565
727df386
Keenan Nemetz
8d ago
566 // eventContext takes the filter inputs from the pinned payload. The comment's own
567 // body is the subject of a comment event; the issue or change body is the subject
568 // of an issue or change event.
569 func eventContext(trigger, action string, payload eventPayload, acl *access.ACL) ci.EventContext {
570 event := ci.EventContext{Kind: trigger, Action: action, Sender: payload.Sender}
571 if acl != nil && payload.Sender != "" {
572 event.SenderRole = acl.RoleFor(payload.Sender)
573 }
574 switch {
575 case payload.Comment != nil:
576 event.Mentions = payload.Comment.Mentions
577 event.ReplyToAuthor = payload.Comment.ReplyToAuthor
578 case payload.Issue != nil:
579 event.Mentions = payload.Issue.Mentions
580 event.AssigneesAdded = payload.Issue.AssigneesAdded
581 case payload.Change != nil:
582 event.Mentions = payload.Change.Mentions
583 }
584 return event
585 }
586
eb07ff56
Keenan Nemetz
8d ago
587 func clipBody(body string) (string, bool) {
588 if len(body) <= maxPayloadBody {
589 return body, false
590 }
591 return body[:maxPayloadBody], true
592 }
593
594 // watchSchedules checks each repository's cron entries once a minute. The occurrence
595 // is the minute itself, so a second check inside the same minute is a no-op and a
596 // server that was down simply misses that slot rather than replaying it.
597 func (s *Server) watchSchedules(ctx context.Context) {
598 t := time.NewTicker(20 * time.Second)
599 defer t.Stop()
600 for {
601 select {
602 case <-ctx.Done():
603 return
604 case <-t.C:
605 s.runSchedules(time.Now().UTC())
606 }
607 }
608 }
609
610 func (s *Server) runSchedules(now time.Time) {
611 slot := now.Truncate(time.Minute)
612 repos, err := s.Repos.List()
613 if err != nil {
614 s.Log.Warn("ci: cannot list repositories for schedules", "err", err)
615 return
616 }
617 for _, m := range repos {
618 ref, branch, commit, err := s.defaultBranchCommit(m)
619 if err != nil {
620 continue
621 }
622 wfs, problems := ci.At(m.Git(), commit)
623 if len(problems) > 0 {
624 continue
625 }
626 for _, wf := range wfs {
627 for _, schedule := range wf.Schedules {
628 cron, err := ci.ParseCron(schedule.Cron)
629 if err != nil || !cron.Matches(slot) {
630 continue
631 }
632 payload := eventPayload{
633 Event: "schedule", Action: "scheduled", Repository: m.Name,
634 Ref: ref, Branch: branch, Commit: commit, Sender: "schedule",
635 Schedule: schedule.Cron,
636 }
637 s.queue(m, m.Name, queueSpec{
638 ref: ref, branch: branch, commit: commit, deployPages: true,
639 trigger: "schedule", action: "scheduled", payload: mustJSON(payload),
640 occurrence: "schedule:" + slot.Format(time.RFC3339),
641 })
642 }
643 }
644 }
645 }
646
647 // dispatchWorkflow queues one manual run of a workflow that declared
648 // workflow-dispatch. The caller has already checked the maintainer capability; this
649 // re-checks it so the SSH and browser surfaces cannot disagree.
650 func (s *Server) dispatchWorkflow(repoName, workflowPath, refName string, inputs map[string]string, actor string) (int, error) {
651 m, err := s.Repos.Open(repoName)
652 if err != nil {
653 return 0, err
654 }
655 acl, err := m.ACL()
656 if err != nil || acl == nil {
657 return 0, fmt.Errorf("cannot read access rules for %s", repoName)
658 }
659 if !acl.CanTriage(actor) && !(s.Identity != nil && s.Identity.IsAdmin(actor)) {
660 return 0, fmt.Errorf("%s may not run workflows in %s", actor, repoName)
661 }
662 refName = strings.TrimPrefix(strings.TrimSpace(refName), "refs/heads/")
663 if refName == "" {
664 refName = m.DefaultBranch
665 }
666 if refName == "" || !m.Git().ValidBranch(refName) {
667 return 0, fmt.Errorf("%s is not a branch in %s", refName, repoName)
668 }
669 ref := "refs/heads/" + refName
670 commit, err := m.Git().RevParse(ref)
671 if err != nil || commit == "" {
672 return 0, fmt.Errorf("%s does not exist", ref)
673 }
674 wfs, problems := ci.At(m.Git(), commit)
675 if len(problems) > 0 {
676 return 0, fmt.Errorf("the workflows at %s do not validate", refName)
677 }
678 var wf *ci.Workflow
679 for _, candidate := range wfs {
680 if candidate.Path == workflowPath {
681 wf = candidate
682 break
683 }
684 }
685 if wf == nil {
686 return 0, fmt.Errorf("%s is not a workflow at %s", workflowPath, refName)
687 }
688 if wf.Dispatch == nil {
689 return 0, fmt.Errorf("%s does not declare workflow-dispatch", workflowPath)
690 }
691 resolved, problems := wf.ResolveDispatchInputs(inputs)
692 if len(problems) > 0 {
693 return 0, fmt.Errorf("%s", strings.Join(problems, "; "))
694 }
695 runID, err := id.New(time.Now())
696 if err != nil {
697 return 0, err
698 }
699 payload := eventPayload{
700 Event: "workflow_dispatch", Action: "dispatched", Repository: m.Name,
701 Ref: ref, Branch: refName, Commit: commit, Sender: actor, Inputs: resolved,
702 }
703 n := s.queue(m, m.Name, queueSpec{
704 ref: ref, branch: refName, commit: commit, deployPages: true,
705 trigger: "workflow_dispatch", action: "dispatched", payload: mustJSON(payload),
706 occurrence: "dispatch:" + runID,
707 })
708 return n, nil
709 }
710
711 func mustJSON(payload eventPayload) string {
712 body, _ := json.Marshal(payload)
713 return string(body)
714 }