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

mindersec / minder / 23960171115

03 Apr 2026 07:58PM UTC coverage: 58.311% (-0.004%) from 58.315%
23960171115

Pull #6266

github

web-flow
Merge 21a008cc2 into 3cf20fd96
Pull Request #6266: Decouple pkg/engine/errors from internal/db using adapter layer

55 of 114 new or added lines in 9 files covered. (48.25%)

161 existing lines in 2 files now uncovered.

19221 of 32963 relevant lines covered (58.31%)

36.31 hits per line

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

56.07
/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
        "context"
10
        "encoding/json"
11
        "errors"
12
        "fmt"
13
        "math"
14
        "strconv"
15
        "time"
16

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

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

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

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

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

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

57
type paramsPR struct {
58
        Owner      string
59
        Repo       string
60
        CommitSha  string
61
        Number     int
62
        Comment    string
63
        Metadata   *alertMetadata
64
        prevStatus *db.ListRuleEvaluationsByProfileIdRow
65
}
66

67
type alertMetadata struct {
68
        ReviewID       string     `json:"review_id,omitempty"`
69
        SubmittedAt    *time.Time `json:"submitted_at,omitempty"`
70
        PullRequestUrl *string    `json:"pull_request_url,omitempty"`
71
}
72

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

84
        return &Alert{
5✔
85
                actionType: actionType,
5✔
86
                gh:         gh,
5✔
87
                reviewCfg:  reviewCfg,
5✔
88
                setting:    setting,
5✔
89
        }, nil
5✔
90
}
91

92
// Class returns the action type of the PR comment alert engine
93
func (alert *Alert) Class() interfaces.ActionType {
1✔
94
        return alert.actionType
1✔
95
}
1✔
96

97
// Type returns the action subtype of the PR comment alert engine
98
func (*Alert) Type() string {
×
99
        return AlertType
×
100
}
×
101

102
// GetOnOffState returns the alert action state read from the profile
103
func (alert *Alert) GetOnOffState() models.ActionOpt {
×
104
        return models.ActionOptOrDefault(alert.setting, models.ActionOptOff)
×
105
}
×
106

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

120
        commentParams, err := alert.getParamsForPRComment(ctx, pr, params, metadata)
5✔
121
        if err != nil {
5✔
122
                return nil, fmt.Errorf("error extracting parameters for PR comment: %w", err)
×
123
        }
×
124

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

137
func (alert *Alert) run(ctx context.Context, params *paramsPR, cmd interfaces.ActionCmd) (json.RawMessage, error) {
5✔
138
        logger := zerolog.Ctx(ctx)
5✔
139

5✔
140
        // Process the command
5✔
141
        switch cmd {
5✔
142
        // Create a review
143
        case interfaces.ActionCmdOn:
4✔
144
                review := &github.PullRequestReviewRequest{
4✔
145
                        CommitID: github.String(params.CommitSha),
4✔
146
                        Event:    github.String("COMMENT"),
4✔
147
                        Body:     github.String(params.Comment),
4✔
148
                }
4✔
149

4✔
150
                r, err := alert.gh.CreateReview(
4✔
151
                        ctx,
4✔
152
                        params.Owner,
4✔
153
                        params.Repo,
4✔
154
                        params.Number,
4✔
155
                        review,
4✔
156
                )
4✔
157
                if err != nil {
5✔
158
                        return nil, fmt.Errorf("error creating PR review: %w, %w", err, enginerr.ErrActionFailed)
1✔
159
                }
1✔
160

161
                newMeta, err := json.Marshal(alertMetadata{
3✔
162
                        ReviewID:       strconv.FormatInt(r.GetID(), 10),
3✔
163
                        SubmittedAt:    r.SubmittedAt.GetTime(),
3✔
164
                        PullRequestUrl: r.PullRequestURL,
3✔
165
                })
3✔
166
                if err != nil {
3✔
167
                        return nil, fmt.Errorf("error marshalling alert metadata json: %w", err)
×
168
                }
×
169

170
                logger.Info().Int64("review_id", *r.ID).Msg("PR review created")
3✔
171
                return newMeta, nil
3✔
172
        // Dismiss the review
173
        case interfaces.ActionCmdOff:
1✔
174
                if params.Metadata == nil || params.Metadata.ReviewID == "" {
1✔
175
                        // We cannot do anything without the PR review ID, so we assume that turning the alert off is a success
×
176
                        return nil, fmt.Errorf("no PR comment ID provided: %w", enginerr.ErrActionTurnedOff)
×
177
                }
×
178

179
                reviewID, err := strconv.ParseInt(params.Metadata.ReviewID, 10, 64)
1✔
180
                if err != nil {
1✔
181
                        zerolog.Ctx(ctx).Error().Err(err).Str("review_id", params.Metadata.ReviewID).Msg("failed to parse review_id")
×
182
                        return nil, fmt.Errorf("no PR comment ID provided: %w, %w", err, enginerr.ErrActionTurnedOff)
×
183
                }
×
184

185
                _, err = alert.gh.DismissReview(ctx, params.Owner, params.Repo, params.Number, reviewID,
1✔
186
                        &github.PullRequestReviewDismissalRequest{
1✔
187
                                Message: github.String("Dismissed due to alert being turned off"),
1✔
188
                        })
1✔
189
                if err != nil {
1✔
190
                        if errors.Is(err, enginerr.ErrNotFound) {
×
191
                                // There's no PR review with that ID anymore.
×
192
                                // We exit by stating that the action was turned off.
×
193
                                return nil, fmt.Errorf("PR comment already dismissed: %w, %w", err, enginerr.ErrActionTurnedOff)
×
194
                        }
×
195
                        return nil, fmt.Errorf("error dismissing PR comment: %w, %w", err, enginerr.ErrActionFailed)
×
196
                }
197
                logger.Info().Str("review_id", params.Metadata.ReviewID).Msg("PR comment dismissed")
1✔
198
                // Success - return ErrActionTurnedOff to indicate the action was successful
1✔
199
                return nil, fmt.Errorf("%s : %w", alert.Class(), enginerr.ErrActionTurnedOff)
1✔
200
        case interfaces.ActionCmdDoNothing:
×
201
                // Return the previous alert status.
×
202
                return alert.runDoNothing(ctx, params)
×
203
        }
204
        return nil, enginerr.ErrActionSkipped
×
205
}
206

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

×
211
        // Process the command
×
212
        switch cmd {
×
213
        case interfaces.ActionCmdOn:
×
214
                body := github.String(params.Comment)
×
215
                logger.Info().Msgf("dry run: create a PR comment on PR %d in repo %s/%s with the following body: %s",
×
216
                        params.Number, params.Owner, params.Repo, *body)
×
217
                return nil, nil
×
218
        case interfaces.ActionCmdOff:
×
219
                if params.Metadata == nil || params.Metadata.ReviewID == "" {
×
220
                        // We cannot do anything without the PR review ID, so we assume that turning the alert off is a success
×
221
                        return nil, fmt.Errorf("no PR comment ID provided: %w", enginerr.ErrActionTurnedOff)
×
222
                }
×
223
                logger.Info().Msgf("dry run: dismiss PR comment %s on PR %d in repo %s/%s", params.Metadata.ReviewID,
×
224
                        params.Number, params.Owner, params.Repo)
×
225
        case interfaces.ActionCmdDoNothing:
×
226
                // Return the previous alert status.
×
227
                return alert.runDoNothing(ctx, params)
×
228

229
        }
230
        return nil, enginerr.ErrActionSkipped
×
231
}
232

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

×
237
        logger.Debug().Msg("Running do nothing")
×
238

×
239
        // Return the previous alert status.
×
NEW
240
        err := dbadapter.AlertStatusAsError(params.prevStatus)
×
241
        // If there is a valid alert metadata, return it too
×
242
        if params.prevStatus != nil {
×
243
                return params.prevStatus.AlertMetadata, err
×
244
        }
×
245
        // If there is no alert metadata, return nil as the metadata and the error
246
        return nil, err
×
247
}
248

249
// getParamsForPRComment extracts the details from the entity
250
func (alert *Alert) getParamsForPRComment(
251
        ctx context.Context,
252
        pr *pbinternal.PullRequest,
253
        params interfaces.ActionsParams,
254
        metadata *json.RawMessage,
255
) (*paramsPR, error) {
5✔
256
        logger := zerolog.Ctx(ctx)
5✔
257
        result := &paramsPR{
5✔
258
                prevStatus: params.GetEvalStatusFromDb(),
5✔
259
                Owner:      pr.GetRepoOwner(),
5✔
260
                Repo:       pr.GetRepoName(),
5✔
261
                CommitSha:  pr.GetCommitSha(),
5✔
262
        }
5✔
263

5✔
264
        // The GitHub Go API takes an int32, but our proto stores an int64; make sure we don't overflow
5✔
265
        // The PR number is an int in GitHub and Gitlab; in practice overflow will never happen.
5✔
266
        if pr.Number > math.MaxInt {
5✔
267
                return nil, fmt.Errorf("pr number is too large")
×
268
        }
×
269
        result.Number = int(pr.Number)
5✔
270

5✔
271
        commentTmpl, err := util.NewSafeHTMLTemplate(&alert.reviewCfg.ReviewMessage, "message")
5✔
272
        if err != nil {
5✔
273
                return nil, fmt.Errorf("cannot parse review message template: %w", err)
×
274
        }
×
275

276
        tmplParams := &PrCommentTemplateParams{
5✔
277
                EvalErrorDetails: dbadapter.ErrorAsEvalDetails(params.GetEvalErr()),
5✔
278
        }
5✔
279

5✔
280
        if params.GetEvalResult() != nil {
10✔
281
                tmplParams.EvalResultOutput = params.GetEvalResult().Output
5✔
282
        }
5✔
283

284
        comment, err := commentTmpl.Render(ctx, tmplParams, PrCommentMaxLength)
5✔
285
        if err != nil {
5✔
286
                return nil, fmt.Errorf("cannot execute title template: %w", err)
×
287
        }
×
288

289
        result.Comment = comment
5✔
290

5✔
291
        // Unmarshal the existing alert metadata, if any
5✔
292
        if metadata != nil {
6✔
293
                meta := &alertMetadata{}
1✔
294
                err := json.Unmarshal(*metadata, meta)
1✔
295
                if err != nil {
1✔
296
                        // There's nothing saved apparently, so no need to fail here, but do log the error
×
297
                        logger.Debug().Msgf("error unmarshalling alert metadata: %v", err)
×
298
                } else {
1✔
299
                        result.Metadata = meta
1✔
300
                }
1✔
301
        }
302

303
        return result, nil
5✔
304
}
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