• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mindersec / minder / 25342198270

04 May 2026 08:36PM UTC coverage: 60.442% (+2.1%) from 58.3%
25342198270

Pull #6253

github

web-flow
Merge 537c7f40e into 1347d06af
Pull Request #6253: Update roadmap for 2026 (and possibly beyond)

20396 of 33745 relevant lines covered (60.44%)

38.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

59.2
/internal/engine/actions/alert/pull_request_comment/pull_request_comment.go
1
// SPDX-FileCopyrightText: Copyright 2024 The Minder Authors
2
// SPDX-License-Identifier: Apache-2.0
3

4
// Package pull_request_comment provides necessary interfaces and implementations for
5
// processing pull request comment alerts.
6
package pull_request_comment
7

8
import (
9
        "cmp"
10
        "context"
11
        "encoding/json"
12
        "errors"
13
        "fmt"
14
        "math"
15
        "strconv"
16
        "strings"
17
        "time"
18

19
        "github.com/google/go-github/v63/github"
20
        "github.com/rs/zerolog"
21
        "google.golang.org/protobuf/reflect/protoreflect"
22

23
        dbadapter "github.com/mindersec/minder/internal/adapters/db"
24
        "github.com/mindersec/minder/internal/db"
25
        "github.com/mindersec/minder/internal/engine/interfaces"
26
        pbinternal "github.com/mindersec/minder/internal/proto"
27
        "github.com/mindersec/minder/internal/util"
28
        pb "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
29
        enginerr "github.com/mindersec/minder/pkg/engine/errors"
30
        "github.com/mindersec/minder/pkg/profiles/models"
31
        provifv1 "github.com/mindersec/minder/pkg/providers/v1"
32
)
33

34
const (
35
        // AlertType is the type of the pull request comment alert engine
36
        AlertType = "pull_request_comment"
37
        // PrCommentMaxLength is the maximum length of the pull request comment
38
        // (this was derived from the limit of the GitHub API)
39
        PrCommentMaxLength = 65536
40
)
41

42
// Alert is the structure backing the noop alert
43
type Alert struct {
44
        actionType interfaces.ActionType
45
        gh         provifv1.ReviewPublisher
46
        reviewCfg  *pb.RuleType_Definition_Alert_AlertTypePRComment
47
        setting    models.ActionOpt
48
}
49

50
// PrCommentTemplateParams is the parameters for the PR comment templates
51
type PrCommentTemplateParams struct {
52
        // EvalErrorDetails is the details of the error that occurred during evaluation, which may be empty
53
        EvalErrorDetails string
54

55
        // EvalResult is the output of the evaluation, which may be empty
56
        EvalResultOutput any
57
}
58

59
type paramsPR struct {
60
        Owner      string
61
        Repo       string
62
        CommitSha  string
63
        Number     int
64
        Comment    string
65
        RuleName   string
66
        Event      string
67
        Metadata   *alertMetadata
68
        prevStatus *db.ListRuleEvaluationsByProfileIdRow
69
}
70

71
type alertMetadata struct {
72
        ReviewID       string     `json:"review_id,omitempty"`
73
        SubmittedAt    *time.Time `json:"submitted_at,omitempty"`
74
        PullRequestUrl *string    `json:"pull_request_url,omitempty"`
75
}
76

77
// NewPullRequestCommentAlert creates a new pull request comment alert action
78
func NewPullRequestCommentAlert(
79
        actionType interfaces.ActionType,
80
        reviewCfg *pb.RuleType_Definition_Alert_AlertTypePRComment,
81
        gh provifv1.ReviewPublisher,
82
        setting models.ActionOpt,
83
) (*Alert, error) {
7✔
84
        if actionType == "" {
7✔
85
                return nil, fmt.Errorf("action type cannot be empty")
×
86
        }
×
87

88
        return &Alert{
7✔
89
                actionType: actionType,
7✔
90
                gh:         gh,
7✔
91
                reviewCfg:  reviewCfg,
7✔
92
                setting:    setting,
7✔
93
        }, nil
7✔
94
}
95

96
// Class returns the action type of the PR comment alert engine
97
func (alert *Alert) Class() interfaces.ActionType {
×
98
        return alert.actionType
×
99
}
×
100

101
// Type returns the action subtype of the PR comment alert engine
102
func (*Alert) Type() string {
×
103
        return AlertType
×
104
}
×
105

106
// GetOnOffState returns the alert action state read from the profile
107
func (alert *Alert) GetOnOffState() models.ActionOpt {
×
108
        return models.ActionOptOrDefault(alert.setting, models.ActionOptOff)
×
109
}
×
110

111
// Do comments on a pull request
112
func (alert *Alert) Do(
113
        ctx context.Context,
114
        cmd interfaces.ActionCmd,
115
        entity protoreflect.ProtoMessage,
116
        params interfaces.ActionsParams,
117
        metadata *json.RawMessage,
118
) (json.RawMessage, error) {
7✔
119
        pr, ok := entity.(*pbinternal.PullRequest)
7✔
120
        if !ok {
7✔
121
                return nil, fmt.Errorf("expected pull request, got %T", entity)
×
122
        }
×
123

124
        commentParams, err := alert.getParamsForPRComment(ctx, pr, params, metadata)
7✔
125
        if err != nil {
7✔
126
                return nil, fmt.Errorf("error extracting parameters for PR comment: %w", err)
×
127
        }
×
128

129
        // Process the command based on the action setting
130
        switch alert.setting {
7✔
131
        case models.ActionOptOn:
7✔
132
                return alert.run(ctx, commentParams, cmd)
7✔
133
        case models.ActionOptDryRun:
×
134
                return alert.runDry(ctx, commentParams, cmd)
×
135
        case models.ActionOptOff, models.ActionOptUnknown:
×
136
                return nil, fmt.Errorf("unexpected action setting: %w", enginerr.ErrActionFailed)
×
137
        }
138
        return nil, enginerr.ErrActionSkipped
×
139
}
140

141
func (alert *Alert) run(ctx context.Context, params *paramsPR, cmd interfaces.ActionCmd) (json.RawMessage, error) {
7✔
142
        logger := zerolog.Ctx(ctx).With().
7✔
143
                Str("owner", params.Owner).
7✔
144
                Str("repo", params.Repo).
7✔
145
                Int("pr", params.Number).
7✔
146
                Logger()
7✔
147

7✔
148
        // Process the command
7✔
149
        switch cmd {
7✔
150
        // Create or update a review
151
        case interfaces.ActionCmdOn:
6✔
152
                existingReview, err := alert.findExistingReview(ctx, params)
6✔
153
                if err != nil {
6✔
154
                        return nil, fmt.Errorf("error searching for existing PR review: %w", err)
×
155
                }
×
156

157
                var reviewID int64
6✔
158
                if existingReview != nil {
7✔
159
                        reviewID = existingReview.GetID()
1✔
160
                        if _, err := alert.gh.UpdateReview(ctx, params.Owner, params.Repo, params.Number, reviewID, params.Comment); err != nil {
1✔
161
                                return nil, fmt.Errorf("error updating PR review: %w, %w", err, enginerr.ErrActionFailed)
×
162
                        }
×
163
                        logger.Info().Int64("review_id", reviewID).Msg("PR review updated")
1✔
164
                } else {
5✔
165
                        req := &github.PullRequestReviewRequest{
5✔
166
                                Body:  github.String(params.Comment),
5✔
167
                                Event: github.String(params.Event),
5✔
168
                        }
5✔
169
                        review, err := alert.gh.CreateReview(ctx, params.Owner, params.Repo, params.Number, req)
5✔
170
                        if err != nil {
6✔
171
                                return nil, fmt.Errorf("error creating PR review: %w, %w", err, enginerr.ErrActionFailed)
1✔
172
                        }
1✔
173
                        reviewID = review.GetID()
4✔
174
                        existingReview = review
4✔
175
                        logger.Info().Int64("review_id", reviewID).Msg("PR review created")
4✔
176
                }
177

178
                now := time.Now()
5✔
179
                newMeta, err := json.Marshal(alertMetadata{
5✔
180
                        ReviewID:       strconv.FormatInt(reviewID, 10),
5✔
181
                        SubmittedAt:    &now,
5✔
182
                        PullRequestUrl: existingReview.HTMLURL,
5✔
183
                })
5✔
184
                if err != nil {
5✔
185
                        return nil, fmt.Errorf("error marshalling alert metadata json: %w", err)
×
186
                }
×
187

188
                return newMeta, nil
5✔
189
        // Dismiss the review
190
        case interfaces.ActionCmdOff:
1✔
191
                existingReview, err := alert.findExistingReview(ctx, params)
1✔
192
                if err != nil {
1✔
193
                        return nil, fmt.Errorf("error searching for existing PR review: %w", err)
×
194
                }
×
195

196
                if existingReview == nil {
1✔
197
                        logger.Debug().Msg("No PR review to dismiss")
×
198
                        return nil, enginerr.ErrActionTurnedOff
×
199
                }
×
200

201
                reviewID := existingReview.GetID()
1✔
202
                if _, err := alert.gh.DismissReview(
1✔
203
                        ctx, params.Owner, params.Repo, params.Number, reviewID, &github.PullRequestReviewDismissalRequest{
1✔
204
                                Message: github.String("Dismissed due to alert being turned off"),
1✔
205
                        },
1✔
206
                ); err != nil {
1✔
207
                        if errors.Is(err, enginerr.ErrNotFound) {
×
208
                                return nil, fmt.Errorf("PR review already dismissed: %w, %w", err, enginerr.ErrActionTurnedOff)
×
209
                        }
×
210
                        return nil, fmt.Errorf("error dismissing PR review: %w, %w", err, enginerr.ErrActionFailed)
×
211
                }
212
                logger.Info().Int64("review_id", reviewID).Msg("PR review dismissed")
1✔
213
                return nil, enginerr.ErrActionTurnedOff
1✔
214
        case interfaces.ActionCmdDoNothing:
×
215
                // Return the previous alert status.
×
216
                return alert.runDoNothing(ctx, params)
×
217
        }
218
        return nil, enginerr.ErrActionSkipped
×
219
}
220

221
func (alert *Alert) findExistingReview(ctx context.Context, params *paramsPR) (*github.PullRequestReview, error) {
7✔
222
        // List reviews
7✔
223
        reviews, err := alert.gh.ListReviews(ctx, params.Owner, params.Repo, params.Number, nil)
7✔
224
        if err != nil {
7✔
225
                return nil, err
×
226
        }
×
227

228
        magicComment := fmt.Sprintf("<!-- minder-rule: %s -->", params.RuleName)
7✔
229
        for _, r := range reviews {
9✔
230
                if r.GetBody() != "" && strings.Contains(r.GetBody(), magicComment) {
4✔
231
                        return r, nil
2✔
232
                }
2✔
233
        }
234
        return nil, nil
5✔
235
}
236

237
// runDry runs the pull request comment action in dry run mode, which logs the comment that would be made
238
func (alert *Alert) runDry(ctx context.Context, params *paramsPR, cmd interfaces.ActionCmd) (json.RawMessage, error) {
×
239
        logger := zerolog.Ctx(ctx)
×
240

×
241
        // Process the command
×
242
        switch cmd {
×
243
        case interfaces.ActionCmdOn:
×
244
                body := github.String(params.Comment)
×
245
                logger.Info().Msgf("dry run: create a PR comment on PR %d in repo %s/%s with the following body: %s",
×
246
                        params.Number, params.Owner, params.Repo, *body)
×
247
                return nil, nil
×
248
        case interfaces.ActionCmdOff:
×
249
                if params.Metadata == nil || params.Metadata.ReviewID == "" {
×
250
                        // We cannot do anything without the PR review ID, so we assume that turning the alert off is a success
×
251
                        return nil, fmt.Errorf("no PR comment ID provided: %w", enginerr.ErrActionTurnedOff)
×
252
                }
×
253
                logger.Info().Msgf("dry run: dismiss PR comment %s on PR %d in repo %s/%s", params.Metadata.ReviewID,
×
254
                        params.Number, params.Owner, params.Repo)
×
255
        case interfaces.ActionCmdDoNothing:
×
256
                // Return the previous alert status.
×
257
                return alert.runDoNothing(ctx, params)
×
258

259
        }
260
        return nil, enginerr.ErrActionSkipped
×
261
}
262

263
// runDoNothing returns the previous alert status
264
func (*Alert) runDoNothing(ctx context.Context, params *paramsPR) (json.RawMessage, error) {
×
265
        logger := zerolog.Ctx(ctx).With().Str("repo", params.Repo).Logger()
×
266

×
267
        logger.Debug().Msg("Running do nothing")
×
268

×
269
        // Return the previous alert status.
×
270
        err := dbadapter.AlertStatusAsError(params.prevStatus)
×
271
        // If there is a valid alert metadata, return it too
×
272
        if params.prevStatus != nil {
×
273
                return params.prevStatus.AlertMetadata, err
×
274
        }
×
275
        // If there is no alert metadata, return nil as the metadata and the error
276
        return nil, err
×
277
}
278

279
// getParamsForPRComment extracts the details from the entity
280
func (alert *Alert) getParamsForPRComment(
281
        ctx context.Context,
282
        pr *pbinternal.PullRequest,
283
        params interfaces.ActionsParams,
284
        metadata *json.RawMessage,
285
) (*paramsPR, error) {
7✔
286
        logger := zerolog.Ctx(ctx)
7✔
287
        result := &paramsPR{
7✔
288
                prevStatus: params.GetEvalStatusFromDb(),
7✔
289
                Owner:      pr.GetRepoOwner(),
7✔
290
                Repo:       pr.GetRepoName(),
7✔
291
                CommitSha:  pr.GetCommitSha(),
7✔
292
        }
7✔
293

7✔
294
        // The GitHub Go API takes an int32, but our proto stores an int64; make sure we don't overflow
7✔
295
        // The PR number is an int in GitHub and Gitlab; in practice overflow will never happen.
7✔
296
        if pr.Number > math.MaxInt {
7✔
297
                return nil, fmt.Errorf("pr number is too large")
×
298
        }
×
299
        result.Number = int(pr.Number)
7✔
300

7✔
301
        commentTmpl, err := util.NewSafeHTMLTemplate(&alert.reviewCfg.ReviewMessage, "message")
7✔
302
        if err != nil {
7✔
303
                return nil, fmt.Errorf("cannot parse review message template: %w", err)
×
304
        }
×
305

306
        tmplParams := &PrCommentTemplateParams{
7✔
307
                EvalErrorDetails: dbadapter.ErrorAsEvalDetails(params.GetEvalErr()),
7✔
308
        }
7✔
309

7✔
310
        if params.GetEvalResult() != nil {
14✔
311
                tmplParams.EvalResultOutput = params.GetEvalResult().Output
7✔
312
        }
7✔
313

314
        comment, err := commentTmpl.Render(ctx, tmplParams, PrCommentMaxLength)
7✔
315
        if err != nil {
7✔
316
                return nil, fmt.Errorf("cannot execute title template: %w", err)
×
317
        }
×
318

319
        result.RuleName = params.GetRule().Name
7✔
320

7✔
321
        action := cmp.Or(alert.reviewCfg.GetAction(), "comment")
7✔
322
        if strings.ToLower(action) == "request_changes" {
8✔
323
                result.Event = "REQUEST_CHANGES"
1✔
324
        } else {
7✔
325
                result.Event = "COMMENT"
6✔
326
        }
6✔
327

328
        // Add magic comment to identify Minder reviews for this rule
329
        result.Comment = fmt.Sprintf("%s\n\n<!-- minder-rule: %s -->", comment, result.RuleName)
7✔
330

7✔
331
        // Unmarshal the existing alert metadata, if any
7✔
332
        if metadata != nil {
8✔
333
                meta := &alertMetadata{}
1✔
334
                err := json.Unmarshal(*metadata, meta)
1✔
335
                if err != nil {
1✔
336
                        // There's nothing saved apparently, so no need to fail here, but do log the error
×
337
                        logger.Debug().Msgf("error unmarshalling alert metadata: %v", err)
×
338
                } else {
1✔
339
                        result.Metadata = meta
1✔
340
                }
1✔
341
        }
342

343
        return result, nil
7✔
344
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc