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

mindersec / minder / 24798705414

22 Apr 2026 07:37PM UTC coverage: 60.337% (-0.001%) from 60.338%
24798705414

push

github

web-flow
Decouple PR feedback actions from GitHub-specific provider (#6262)

* feat: decouple PR feedback actions from GitHub-specific provider

Add generic CommitStatusPublisher and ReviewPublisher interfaces to
pkg/providers/v1 so that commit_status alert and pull_request_comment
alert/remediate actions are no longer hardcoded to provinfv1.GitHub.

Previously both actions fell back silently to NoopAlert/NoopRemediate
with a misleading log message when used with any non-GitHub provider.
This change replaces the GitHub-specific type assertions with generic
interface casts, improving extensibility for future VCS providers.

Changes:
- Define CommitStatus and Review DTOs in pkg/providers/v1
- Define CommitStatusPublisher and ReviewPublisher interfaces
- Implement PublishCommitStatus, PublishReview, DismissPublishedReview
  on the GitHub client as adapter methods
- Add compile-time interface assertions on the GitHub client struct
- Refactor commit_status action to cast to CommitStatusPublisher
- Refactor pull_request_comment action to cast to ReviewPublisher
- Update alert.go and remediate.go to use the new generic interfaces
- Regenerate mocks and update unit tests to MockCommitStatusPublisher
  and MockReviewPublisher

* security: upgrade vulnerable dependencies

* feat(pr-feedback): implement advanced PR review life cycle

Narrow the scope of PR feedback to pull request comments by removing
commit statuses. Refactor provider interfaces to use standard GitHub
methods and implement create-or-update/dismiss logic for Minder reviews.

Co-Authored-By: Antigravity <noreply@google.com>

* fix: stabilize CI, resolve proto breaking changes and regenerate mocks

* fix: restore binary compatibility in proto, sync generation and tidy deps

* fix: synchronize all provider mocks after interface refactoring

* fix: regenerate all proto, mock, and swagger files with proper toolchain

Ran 'make bootstrap' to install buf, sqlc, mockgen, then 'make gen'
to regenerate all files f... (continued)

61 of 83 new or added lines in 4 files covered. (73.49%)

7 existing lines in 2 files now uncovered.

20287 of 33623 relevant lines covered (60.34%)

35.98 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
UNCOV
97
func (alert *Alert) Class() interfaces.ActionType {
×
UNCOV
98
        return alert.actionType
×
UNCOV
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✔
NEW
154
                        return nil, fmt.Errorf("error searching for existing PR review: %w", err)
×
UNCOV
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✔
NEW
161
                                return nil, fmt.Errorf("error updating PR review: %w, %w", err, enginerr.ErrActionFailed)
×
NEW
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✔
NEW
193
                        return nil, fmt.Errorf("error searching for existing PR review: %w", err)
×
UNCOV
194
                }
×
195

196
                if existingReview == nil {
1✔
NEW
197
                        logger.Debug().Msg("No PR review to dismiss")
×
NEW
198
                        return nil, enginerr.ErrActionTurnedOff
×
UNCOV
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) {
×
NEW
208
                                return nil, fmt.Errorf("PR review already dismissed: %w, %w", err, enginerr.ErrActionTurnedOff)
×
209
                        }
×
NEW
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✔
NEW
225
                return nil, err
×
NEW
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