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

mendersoftware / integration-test-runner / 1681953125

21 Feb 2025 08:24AM UTC coverage: 59.965% (-4.4%) from 64.328%
1681953125

Pull #357

gitlab-ci

danielskinstad
feat: run integration pipeline on demand

Allow you to run the full integration pipeline on a protected branch
when a trusted member runs `start integration pipeline`.

As of now, this will only run with main - and you cannot specify PRs to
run the pipeline with.

Ticket: QA-863

Signed-off-by: Daniel Skinstad Drabitzius <daniel.drabitzius@northern.tech>
Pull Request #357: wip: integration tests on demand

77 of 311 new or added lines in 7 files covered. (24.76%)

2 existing lines in 1 file now uncovered.

1736 of 2895 relevant lines covered (59.97%)

2.19 hits per line

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

65.07
/main_pullrequest.go
1
package main
2

3
import (
4
        "context"
5
        "fmt"
6
        "regexp"
7
        "strconv"
8
        "strings"
9
        "time"
10

11
        "github.com/gin-gonic/gin"
12
        "github.com/google/go-github/v28/github"
13
        "github.com/pkg/errors"
14
        "github.com/sirupsen/logrus"
15

16
        clientgithub "github.com/mendersoftware/integration-test-runner/client/github"
17
)
18

19
var (
20
        changelogPrefix = "Merging these commits will result in the following changelog entries:\n\n"
21
        warningHeader   = "\n\n## Warning\n\nGenerating changelogs also resulted in these warnings:\n\n"
22

23
        msgDetailsKubernetesLog = "see <a href=\"https://console.cloud.google.com/kubernetes/" +
24
                "deployment/us-east1/company-websites/default/test-runner-mender-io/logs?" +
25
                "project=gp-kubernetes-269000\">logs</a> for details."
26
)
27

28
type retryParams struct {
29
        retryFunc func() error
30
        compFunc  func(error) bool
31
}
32

33
const (
34
        doRetry bool = true
35
        noRetry      = false
36
)
37

38
func retryOnError(args retryParams) error {
1✔
39
        var maxBackoff int = 8 * 8
1✔
40
        err := args.retryFunc()
1✔
41
        i := 1
1✔
42
        for i <= maxBackoff && args.compFunc(err) {
1✔
43
                err = args.retryFunc()
×
44
                i = i * 2
×
45
                time.Sleep(time.Duration(i) * time.Second)
×
46
        }
×
47
        return err
1✔
48
}
49

50
func processGitHubPullRequest(
51
        ctx *gin.Context,
52
        pr *github.PullRequestEvent,
53
        githubClient clientgithub.Client,
54
        conf *config,
55
) error {
2✔
56

2✔
57
        var (
2✔
58
                prRef  string
2✔
59
                err    error
2✔
60
                action = pr.GetAction()
2✔
61
        )
2✔
62
        log := getCustomLoggerFromContext(ctx).
2✔
63
                WithField("pull", pr.GetNumber()).
2✔
64
                WithField("action", action)
2✔
65
        req := pr.GetPullRequest()
2✔
66

2✔
67
        // Do not run if the PR is a draft
2✔
68
        if req.GetDraft() {
2✔
69
                log.Infof(
×
70
                        "The PR: %s/%d is a draft. Do not run tests",
×
71
                        pr.GetRepo().GetName(),
×
72
                        pr.GetNumber(),
×
73
                )
×
74
                return nil
×
75
        }
×
76

77
        log.Debugf("Processing pull request action %s", action)
2✔
78
        switch action {
2✔
79
        case "opened", "reopened", "synchronize", "ready_for_review":
1✔
80
                // We always create a pr_* branch
1✔
81
                if prRef, err = syncPullRequestBranch(log, pr, conf); err != nil {
1✔
82
                        log.Errorf("Could not create PR branch: %s", err.Error())
×
83
                        msg := "There was an error syncing branches, " + msgDetailsKubernetesLog
×
84
                        postGitHubMessage(ctx, pr, log, msg)
×
85
                }
×
86
                //and we run a pipeline only for the pr_* branch
87
                if prRef != "" {
2✔
88
                        prNum := strconv.Itoa(pr.GetNumber())
1✔
89
                        prBranchName := "pr_" + prNum
1✔
90
                        isOrgMember := func() bool {
2✔
91
                                return githubClient.IsOrganizationMember(
1✔
92
                                        ctx,
1✔
93
                                        conf.githubOrganization,
1✔
94
                                        pr.Sender.GetLogin(),
1✔
95
                                )
1✔
96
                        }
1✔
97
                        err = retryOnError(retryParams{
1✔
98
                                retryFunc: func() error {
2✔
99
                                        return startPRPipeline(log, prBranchName, pr, conf, isOrgMember)
1✔
100
                                },
1✔
101
                                compFunc: func(compareError error) bool {
1✔
102
                                        re := regexp.MustCompile("Missing CI config file|" +
1✔
103
                                                "No stages / jobs for this pipeline")
1✔
104
                                        switch {
1✔
105
                                        case compareError == nil:
1✔
106
                                                return noRetry
1✔
107
                                        case re.MatchString(compareError.Error()):
×
NEW
108
                                                log.Infof("start client pipeline for PR '%d' is skipped", pr.Number)
×
109
                                                return noRetry
×
110
                                        default:
×
NEW
111
                                                log.Errorf("failed to start client pipeline for PR: %s", compareError)
×
112
                                                return doRetry
×
113
                                        }
114
                                },
115
                        })
116
                        if err != nil {
1✔
117
                                msg := "There was an error running your pipeline, " + msgDetailsKubernetesLog
×
118
                                postGitHubMessage(ctx, pr, log, msg)
×
119
                        }
×
120
                }
121

122
                handleChangelogComments(log, ctx, githubClient, pr, conf)
1✔
123

124
        case "closed":
1✔
125
                // Delete merged pr branches in GitLab
1✔
126
                if err := deleteStaleGitlabPRBranch(log, pr, conf); err != nil {
1✔
127
                        log.Errorf(
×
128
                                "Failed to delete the stale PR branch after the PR: %v was merged or closed. "+
×
129
                                        "Error: %v",
×
130
                                pr,
×
131
                                err,
×
132
                        )
×
133
                }
×
134

135
                // If the pr was merged, suggest cherry-picks
136
                if err := suggestCherryPicks(log, pr, githubClient, conf); err != nil {
1✔
137
                        log.Errorf("Failed to suggest cherry picks for the pr %v. Error: %v", pr, err)
×
138
                }
×
139
        }
140

141
        // Continue to the integration Pipeline only for organization members
142
        if member := githubClient.IsOrganizationMember(
2✔
143
                ctx,
2✔
144
                conf.githubOrganization,
2✔
145
                pr.Sender.GetLogin(),
2✔
146
        ); !member {
2✔
147
                log.Warnf(
×
148
                        "%s is making a pullrequest, but he/she is not a member of our organization, ignoring",
×
149
                        pr.Sender.GetLogin(),
×
150
                )
×
151
                return nil
×
152
        }
×
153

154
        // First check if the PR has been merged. If so, stop
155
        // the pipeline, and do nothing else.
156
        if err := stopBuildsOfStaleClientPRs(log, pr, conf); err != nil {
2✔
157
                log.Errorf(
×
158
                        "Failed to stop a stale build after the PR: %v was merged or closed. Error: %v",
×
159
                        pr,
×
160
                        err,
×
161
                )
×
162
        }
×
163

164
        // Keep the OS and Enterprise repos in sync
165
        if err := syncIfOSHasEnterpriseRepo(log, conf, pr); err != nil {
2✔
166
                log.Errorf("Failed to sync the OS and Enterprise repos: %s", err.Error())
×
167
        }
×
168

169
        // get the list of builds
170
        builds := parseClientPullRequest(log, conf, action, pr)
2✔
171
        log.Infof("%s:%d would trigger %d builds", pr.GetRepo().GetName(), pr.GetNumber(), len(builds))
2✔
172

2✔
173
        // do not start the builds, inform the user about the `start client pipeline` command instead
2✔
174
        if len(builds) > 0 {
3✔
175
                // Only comment, if not already commented on a PR
1✔
176
                botCommentString := ", Let me know if you want to start the integration pipeline by " +
1✔
177
                        "mentioning me and the command \""
1✔
178
                if getFirstMatchingBotCommentInPR(log, githubClient, pr, botCommentString, conf) == nil {
1✔
179

×
NEW
180
                        msg := "@" + pr.GetSender().GetLogin() + botCommentString +
×
NEW
181
                                commandStartClientPipeline + "\"."
×
NEW
182
                        // nolint:lll
×
183
                        msg += `
×
184

×
185
   ---
×
186

×
187
   <details>
×
188
   <summary>my commands and options</summary>
×
189
   <br />
×
190

×
191
   You can trigger a pipeline on multiple prs with:
×
NEW
192
   - mentioning me and ` + "`" + `start client pipeline --pr mender/127 --pr mender-connect/255` + "`" + `
×
193

×
194
   You can start a fast pipeline, disabling full integration tests with:
×
NEW
195
   - mentioning me and ` + "`" + `start client pipeline --fast` + "`" + `
×
196

×
197
   You can trigger GitHub->GitLab branch sync with:
×
198
   - mentioning me and ` + "`" + `sync` + "`" + `
×
199

×
200
   You can cherry pick to a given branch or branches with:
×
201
   - mentioning me and:
×
202
   ` + "```" + `
×
203
    cherry-pick to:
×
204
    * 1.0.x
×
205
    * 2.0.x
×
206
   ` + "```" + `
×
207
   </details>
×
208
   `
×
209
                        postGitHubMessage(ctx, pr, log, msg)
×
210
                } else {
1✔
211
                        log.Infof(
1✔
212
                                "I have already commented on the pr: %s/%d, no need to keep on nagging",
1✔
213
                                pr.GetRepo().GetName(), pr.GetNumber())
1✔
214
                }
1✔
215
        }
216

217
        return nil
2✔
218
}
219

220
func postGitHubMessage(
221
        ctx *gin.Context,
222
        pr *github.PullRequestEvent,
223
        log *logrus.Entry,
224
        msg string,
225
) {
×
226
        if err := githubClient.CreateComment(
×
227
                ctx,
×
228
                pr.GetOrganization().GetLogin(),
×
229
                pr.GetRepo().GetName(),
×
230
                pr.GetNumber(),
×
231
                &github.IssueComment{Body: github.String(msg)},
×
232
        ); err != nil {
×
233
                log.Infof("Failed to comment on the pr: %v, Error: %s", pr, err.Error())
×
234
        }
×
235
}
236

237
func getFirstMatchingBotCommentInPR(
238
        log *logrus.Entry,
239
        githubClient clientgithub.Client,
240
        pr *github.PullRequestEvent,
241
        botComment string,
242
        conf *config,
243
) *github.IssueComment {
13✔
244

13✔
245
        comments, err := githubClient.ListComments(
13✔
246
                context.Background(),
13✔
247
                conf.githubOrganization,
13✔
248
                pr.GetRepo().GetName(),
13✔
249
                pr.GetNumber(),
13✔
250
                &github.IssueListCommentsOptions{
13✔
251
                        Sort:      "created",
13✔
252
                        Direction: "asc",
13✔
253
                })
13✔
254
        if err != nil {
14✔
255
                log.Errorf("Failed to list the comments on PR: %s/%d, err: '%s'",
1✔
256
                        pr.GetRepo().GetName(), pr.GetNumber(), err)
1✔
257
                return nil
1✔
258
        }
1✔
259
        for _, comment := range comments {
22✔
260
                if comment.Body != nil &&
10✔
261
                        strings.Contains(*comment.Body, botComment) &&
10✔
262
                        comment.User != nil &&
10✔
263
                        comment.User.Login != nil &&
10✔
264
                        *comment.User.Login == githubBotName {
18✔
265
                        return comment
8✔
266
                }
8✔
267
        }
268
        return nil
5✔
269
}
270

271
func handleChangelogComments(
272
        log *logrus.Entry,
273
        ctx *gin.Context,
274
        githubClient clientgithub.Client,
275
        pr *github.PullRequestEvent,
276
        conf *config,
277
) {
1✔
278
        // It would be semantically correct to update the integration repo
1✔
279
        // here. However, this step is carried out on every PR update, causing a
1✔
280
        // big amount of "git fetch" requests, which both reduces performance,
1✔
281
        // and could result in rate limiting. Instead, we assume that the
1✔
282
        // integration repo is recent enough, since it is still updated when
1✔
283
        // doing mender-qa builds.
1✔
284
        //
1✔
285
        // // First update integration repo.
1✔
286
        // err := updateIntegrationRepo(conf)
1✔
287
        // if err != nil {
1✔
288
        //         log.Errorf("Could not update integration repo: %s", err.Error())
1✔
289
        //         // Should still be safe to continue though.
1✔
290
        // }
1✔
291

1✔
292
        // Only do changelog commenting for mendersoftware repositories.
1✔
293
        if pr.GetPullRequest().GetBase().GetRepo().GetOwner().GetLogin() != "mendersoftware" {
1✔
294
                log.Info("Not a mendersoftware repository. Ignoring.")
×
295
                return
×
296
        }
×
297

298
        changelogText, warningText, err := fetchChangelogTextForPR(log, pr, conf)
1✔
299
        if err != nil {
1✔
300
                log.Errorf("Error while fetching changelog text: %s", err.Error())
×
301
                return
×
302
        }
×
303

304
        updatePullRequestChangelogComments(log, ctx, githubClient, pr, conf,
1✔
305
                changelogText, warningText)
1✔
306
}
307

308
func fetchChangelogTextForPR(
309
        log *logrus.Entry,
310
        pr *github.PullRequestEvent,
311
        conf *config,
312
) (string, string, error) {
1✔
313

1✔
314
        repo := pr.GetPullRequest().GetBase().GetRepo().GetName()
1✔
315
        baseSHA := pr.GetPullRequest().GetBase().GetSHA()
1✔
316
        headSHA := pr.GetPullRequest().GetHead().GetSHA()
1✔
317
        baseRef := pr.GetPullRequest().GetBase().GetRef()
1✔
318
        headRef := pr.GetPullRequest().GetHead().GetRef()
1✔
319
        versionRange := fmt.Sprintf(
1✔
320
                "%s..%s",
1✔
321
                baseSHA,
1✔
322
                headSHA,
1✔
323
        )
1✔
324

1✔
325
        log.Debugf("Getting changelog for repo (%s) and range (%s)", repo, versionRange)
1✔
326

1✔
327
        // Generate the changelog text for this PR.
1✔
328
        changelogText, warningText, err := getChangelogText(
1✔
329
                repo, versionRange, conf)
1✔
330
        if err != nil {
1✔
331
                err = errors.Wrap(err, "Not able to get changelog text")
×
332
        }
×
333

334
        // Replace SHAs with the original ref names, so that the changelog text
335
        // does not change on every commit amend. The reason we did not use ref
336
        // names to begin with is that they may live in personal forks, so it
337
        // complicates the fetching mechanism. SHAs however, are always present
338
        // in the repository you are merging into.
339
        //
340
        // Fetching changelogs online from personal forks is pretty unlikely to
341
        // be useful outside of the integration-test-runner niche (better to use
342
        // the local version), therefore we do this replacement instead of
343
        // making the changelog-generator "fork aware".
344
        changelogText = strings.ReplaceAll(changelogText, baseSHA, baseRef)
1✔
345
        changelogText = strings.ReplaceAll(changelogText, headSHA, headRef)
1✔
346

1✔
347
        log.Debugf("Prepared changelog text: %s", changelogText)
1✔
348
        log.Debugf("Got warning text: %s", warningText)
1✔
349

1✔
350
        return changelogText, warningText, err
1✔
351
}
352

353
func assembleCommentText(changelogText, warningText string) string {
21✔
354
        commentText := changelogPrefix + changelogText
21✔
355
        if warningText != "" {
25✔
356
                commentText += warningHeader + warningText
4✔
357
        }
4✔
358
        return commentText
21✔
359
}
360

361
func updatePullRequestChangelogComments(
362
        log *logrus.Entry,
363
        ctx *gin.Context,
364
        githubClient clientgithub.Client,
365
        pr *github.PullRequestEvent,
366
        conf *config,
367
        changelogText string,
368
        warningText string,
369
) {
11✔
370
        var err error
11✔
371

11✔
372
        commentText := assembleCommentText(changelogText, warningText)
11✔
373
        emptyChangelog := (changelogText == "" ||
11✔
374
                strings.HasSuffix(changelogText, "### Changelogs\n\n"))
11✔
375

11✔
376
        comment := getFirstMatchingBotCommentInPR(log, githubClient, pr, changelogPrefix, conf)
11✔
377
        if comment != nil {
17✔
378
                // There is a previous comment about changelog.
6✔
379
                if *comment.Body == commentText {
9✔
380
                        log.Debugf("The changelog hasn't changed (comment ID: %d). Leave it alone.",
3✔
381
                                comment.ID)
3✔
382
                        return
3✔
383
                } else {
6✔
384
                        log.Debugf("Deleting old changelog comment (comment ID: %d).",
3✔
385
                                comment.ID)
3✔
386
                        err = githubClient.DeleteComment(
3✔
387
                                ctx,
3✔
388
                                conf.githubOrganization,
3✔
389
                                pr.GetRepo().GetName(),
3✔
390
                                *comment.ID,
3✔
391
                        )
3✔
392
                        if err != nil {
3✔
393
                                log.Errorf("Could not delete changelog comment: %s",
×
394
                                        err.Error())
×
395
                        }
×
396
                }
397
        } else if emptyChangelog {
7✔
398
                log.Info("Changelog is empty, and there is no previous changelog comment. Stay silent.")
2✔
399
                return
2✔
400
        }
2✔
401

402
        commentBody := &github.IssueComment{
6✔
403
                Body: &commentText,
6✔
404
        }
6✔
405
        err = githubClient.CreateComment(
6✔
406
                ctx,
6✔
407
                conf.githubOrganization,
6✔
408
                pr.GetRepo().GetName(),
6✔
409
                pr.GetNumber(),
6✔
410
                commentBody,
6✔
411
        )
6✔
412
        if err != nil {
6✔
413
                log.Errorf("Could not post changelog comment: %s. Comment text: %s",
×
414
                        err.Error(), commentText)
×
415
        }
×
416
}
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

© 2025 Coveralls, Inc