1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 package disapproval
16
17 import (
18 "context"
19 "fmt"
20 "sort"
21
22 "github.com/pkg/errors"
23 "github.com/rs/zerolog"
24
25 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/pull"
26
27 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/policy/common"
28 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/policy/predicate"
29 )
30
31 type Policy struct {
32 Predicates predicate.Predicates `yaml:"if,omitempty"`
33 Options Options `yaml:"options,omitempty"`
34 Requires Requires `yaml:"requires,omitempty"`
35 }
36
37 type Options struct {
38 Methods Methods `yaml:"methods,omitempty"`
39 }
40
41 type Methods struct {
42 Disapprove *common.Methods `yaml:"disapprove,omitempty"`
43 Revoke *common.Methods `yaml:"revoke,omitempty"`
44 }
45
46 func (opts *Options) GetDisapproveMethods() *common.Methods {
47 m := opts.Methods.Disapprove
48 if m == nil {
49 githubReview := true
50 m = &common.Methods{
51 Comments: []string{
52 ":-1:",
53 "👎",
54 },
55 GithubReview: &githubReview,
56 }
57 }
58
59 m.GithubReviewState = pull.ReviewChangesRequested
60 return m
61 }
62
63 func (opts *Options) GetRevokeMethods() *common.Methods {
64 m := opts.Methods.Revoke
65 if m == nil {
66 githubReview := true
67 m = &common.Methods{
68 Comments: []string{
69 ":+1:",
70 "👍",
71 },
72 GithubReview: &githubReview,
73 }
74 }
75
76 m.GithubReviewState = pull.ReviewApproved
77 return m
78 }
79
80 type Requires struct {
81 common.Actors `yaml:",inline"`
82 }
83
84 func (p *Policy) Trigger() common.Trigger {
85 t := common.TriggerCommit
86
87 if !p.Requires.IsEmpty() {
88 dm := p.Options.GetDisapproveMethods()
89 rm := p.Options.GetRevokeMethods()
90
91 if len(dm.Comments) > 0 || len(rm.Comments) > 0 {
92 t |= common.TriggerComment
93 }
94 if dm.GithubReview != nil && *dm.GithubReview || rm.GithubReview != nil && *rm.GithubReview {
95 t |= common.TriggerReview
96 }
97 }
98
99 for _, predicate := range p.Predicates.Predicates() {
100 t |= predicate.Trigger()
101 }
102
103 return t
104 }
105
106 func (p *Policy) Evaluate(ctx context.Context, prctx pull.Context) (res common.Result) {
107 log := zerolog.Ctx(ctx)
108
109 res.Name = "disapproval"
110 res.Status = common.StatusSkipped
111 res.Requires = p.Requires.Actors
112
113 var predicateResults []*common.PredicateResult
114
115 for _, p := range p.Predicates.Predicates() {
116 result, err := p.Evaluate(ctx, prctx)
117 if err != nil {
118 res.Error = errors.Wrap(err, "failed to evaluate predicate")
119 return
120 }
121 predicateResults = append(predicateResults, result)
122
123 if result.Satisfied {
124 log.Debug().Msgf("disapproving, predicate of type %T was satisfied", p)
125
126 res.Status = common.StatusDisapproved
127
128 desc := result.Description
129 res.StatusDescription = desc
130 if desc == "" {
131 res.StatusDescription = "A precondition of this rule was satisfied"
132 }
133 res.PredicateResults = []*common.PredicateResult{result}
134 return
135 }
136 }
137 res.PredicateResults = predicateResults
138 if p.Requires.IsEmpty() {
139 log.Debug().Msg("no users are allowed to disapprove; skipping")
140
141 res.StatusDescription = "No disapproval policy is specified or the policy is empty"
142 return
143 }
144
145 disapproved, msg, err := p.IsDisapproved(ctx, prctx)
146 if err != nil {
147 res.Error = errors.WithMessage(err, "failed to compute disapproval status")
148 return
149 }
150
151 res.StatusDescription = msg
152 if disapproved {
153 res.Status = common.StatusDisapproved
154 } else {
155 res.Status = common.StatusSkipped
156 }
157 return
158 }
159
160 func (p *Policy) IsDisapproved(ctx context.Context, prctx pull.Context) (disapproved bool, msg string, err error) {
161 disapproveMethods := p.Options.GetDisapproveMethods()
162 revokeMethods := p.Options.GetRevokeMethods()
163
164 disapprover, err := p.lastActor(ctx, prctx, disapproveMethods, "disapproval")
165 if err != nil {
166 return false, "", errors.WithMessage(err, "failed to get last disapprover")
167 }
168
169
170 if disapprover == nil {
171 msg = "No disapprovals"
172 return
173 }
174
175 revoker, err := p.lastActor(ctx, prctx, revokeMethods, "revocation")
176 if err != nil {
177 return false, "", errors.WithMessage(err, "failed to get last revoker")
178 }
179
180 switch {
181
182 case revoker == nil:
183 disapproved = true
184 msg = fmt.Sprintf("Disapproved by %s", disapprover.User)
185
186
187 case disapprover.CreatedAt.After(revoker.CreatedAt):
188 disapproved = true
189 msg = fmt.Sprintf("Disapproved by %s", disapprover.User)
190
191
192 default:
193 msg = fmt.Sprintf("Disapproval revoked by %s", revoker.User)
194 }
195 return
196 }
197
198 func (p *Policy) lastActor(ctx context.Context, prctx pull.Context, methods *common.Methods, kind string) (*common.Candidate, error) {
199 log := zerolog.Ctx(ctx)
200
201 candidates, err := methods.Candidates(ctx, prctx)
202 if err != nil {
203 return nil, err
204 }
205
206 log.Debug().Msgf("found %d %s candidates", len(candidates), kind)
207
208 candidates, err = p.filter(ctx, prctx, candidates)
209 if err != nil {
210 return nil, err
211 }
212
213 sort.Stable(common.CandidatesByCreationTime(candidates))
214
215 return last(candidates), nil
216 }
217
218 func (p *Policy) filter(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) ([]*common.Candidate, error) {
219 log := zerolog.Ctx(ctx)
220
221 var filtered []*common.Candidate
222 for _, c := range candidates {
223 ok, err := p.Requires.IsActor(ctx, prctx, c.User)
224 if err != nil {
225 return nil, errors.WithMessage(err, "failed to check candidate status")
226 }
227
228 if !ok {
229 log.Debug().Str("user", c.User).Msg("ignoring disapproval/revocation by non-whitelisted user")
230 continue
231 }
232
233 filtered = append(filtered, c)
234 }
235 return filtered, nil
236 }
237
238 func last(c []*common.Candidate) *common.Candidate {
239 if len(c) == 0 {
240 return nil
241 }
242 return c[len(c)-1]
243 }
244
View as plain text