-
Notifications
You must be signed in to change notification settings - Fork 178
/
Copy pathgit_pr_opener.go
442 lines (415 loc) · 12.8 KB
/
git_pr_opener.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
package directives
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/xeipuuv/gojsonschema"
kargoapi "github.com/akuity/kargo/api/v1alpha1"
"github.com/akuity/kargo/internal/controller/git"
"github.com/akuity/kargo/internal/credentials"
"github.com/akuity/kargo/internal/gitprovider"
_ "github.com/akuity/kargo/internal/gitprovider/azure" // Azure provider registration
_ "github.com/akuity/kargo/internal/gitprovider/github" // GitHub provider registration
_ "github.com/akuity/kargo/internal/gitprovider/gitlab" // GitLab provider registration
)
// stateKeyPRNumber is the key used to store the PR number in the shared State.
const stateKeyPRNumber = "prNumber"
func init() {
builtins.RegisterPromotionStepRunner(
newGitPROpener(),
&StepRunnerPermissions{AllowCredentialsDB: true},
)
}
// gitPROpener is an implementation of the PromotionStepRunner interface that
// opens a pull request.
type gitPROpener struct {
schemaLoader gojsonschema.JSONLoader
}
// newGitPROpener returns an implementation of the PromotionStepRunner interface
// that opens a pull request.
func newGitPROpener() PromotionStepRunner {
r := &gitPROpener{}
r.schemaLoader = getConfigSchemaLoader(r.Name())
return r
}
// Name implements the PromotionStepRunner interface.
func (g *gitPROpener) Name() string {
return "git-open-pr"
}
// RunPromotionStep implements the PromotionStepRunner interface.
func (g *gitPROpener) RunPromotionStep(
ctx context.Context,
stepCtx *PromotionStepContext,
) (PromotionStepResult, error) {
if err := g.validate(stepCtx.Config); err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, err
}
cfg, err := ConfigToStruct[GitOpenPRConfig](stepCtx.Config)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("could not convert config into git-open-pr config: %w", err)
}
return g.runPromotionStep(ctx, stepCtx, cfg)
}
// validate validates gitPROpener configuration against a JSON schema.
func (g *gitPROpener) validate(cfg Config) error {
return validate(g.schemaLoader, gojsonschema.NewGoLoader(cfg), g.Name())
}
func (g *gitPROpener) runPromotionStep(
ctx context.Context,
stepCtx *PromotionStepContext,
cfg GitOpenPRConfig,
) (PromotionStepResult, error) {
// Short-circuit if shared state has output from a previous execution of this
// step that contains a PR number.
prNumber, err := g.getPRNumber(stepCtx, stepCtx.SharedState)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("error getting PR number from shared state: %w", err)
}
if prNumber != -1 {
return PromotionStepResult{
Status: kargoapi.PromotionPhaseSucceeded,
Output: map[string]any{
stateKeyPRNumber: prNumber,
},
}, nil
}
sourceBranch, err := g.getSourceBranch(stepCtx.SharedState, cfg)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("error determining source branch: %w", err)
}
var repoCreds *git.RepoCredentials
creds, found, err := stepCtx.CredentialsDB.Get(
ctx,
stepCtx.Project,
credentials.TypeGit,
cfg.RepoURL,
)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("error getting credentials for %s: %w", cfg.RepoURL, err)
}
if found {
repoCreds = &git.RepoCredentials{
Username: creds.Username,
Password: creds.Password,
SSHPrivateKey: creds.SSHPrivateKey,
}
}
repo, err := git.Clone(
cfg.RepoURL,
&git.ClientOptions{
Credentials: repoCreds,
InsecureSkipTLSVerify: cfg.InsecureSkipTLSVerify,
},
&git.CloneOptions{
Depth: 1,
Branch: sourceBranch,
},
)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("error cloning %s: %w", cfg.RepoURL, err)
}
defer repo.Close()
gpOpts := &gitprovider.Options{
InsecureSkipTLSVerify: cfg.InsecureSkipTLSVerify,
}
if repoCreds != nil {
gpOpts.Token = repoCreds.Password
}
if cfg.Provider != nil {
gpOpts.Name = string(*cfg.Provider)
}
gitProvider, err := gitprovider.New(cfg.RepoURL, gpOpts)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("error creating git provider service: %w", err)
}
// If a PR somehow exists that is identical to the one we would open, we can
// potentially just adopt it.
pr, err := g.getExistingPR(
ctx,
repo,
gitProvider,
cfg.TargetBranch,
)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("error determining if pull request already exists: %w", err)
}
if pr != nil && (pr.Open || pr.Merged) { // Excludes PR that is both closed AND unmerged
return PromotionStepResult{
Status: kargoapi.PromotionPhaseSucceeded,
Output: map[string]any{
stateKeyPRNumber: pr.Number,
},
}, nil
}
// If we get to here, we either did not find an existing PR like the one we're
// about to create, or we found one that is closed and not merged, which means
// we're free to create a new one.
// Get the title from the commit message of the head of the source branch
// BEFORE we move on to ensuring the existence of the target branch because
// that may involve creating a new branch and committing to it.
commitMsg, err := repo.CommitMessage(sourceBranch)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, fmt.Errorf(
"error getting commit message from head of branch %s: %w",
sourceBranch, err,
)
}
if err = g.ensureRemoteTargetBranch(
repo,
cfg.TargetBranch,
cfg.CreateTargetBranch,
); err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, fmt.Errorf(
"error ensuring existence of remote branch %s: %w",
cfg.TargetBranch, err,
)
}
var title string
if cfg.Title != "" {
title = cfg.Title
} else {
title = strings.Split(commitMsg, "\n")[0]
}
description := commitMsg
if stepCtx.UIBaseURL != "" {
description = fmt.Sprintf(
"%s\n\n[View in Kargo UI](%s/project/%s/stage/%s)",
description,
stepCtx.UIBaseURL,
stepCtx.Project,
stepCtx.Stage,
)
}
if pr, err = gitProvider.CreatePullRequest(
ctx,
&gitprovider.CreatePullRequestOpts{
Head: sourceBranch,
Base: cfg.TargetBranch,
Title: title,
Description: description,
Labels: cfg.Labels,
},
); err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("error creating pull request: %w", err)
}
return PromotionStepResult{
Status: kargoapi.PromotionPhaseSucceeded,
Output: map[string]any{
stateKeyPRNumber: pr.Number,
},
}, nil
}
// getPRNumber checks shared state for output from a previous execution of this
// step. If any is found and it contains a PR number, that number is returned.
// 0 is returned if no PR number is found in the shared state. An error is
// returned if the PR number is found but is neither an int64 nor a float64.
func (g *gitPROpener) getPRNumber(
stepCtx *PromotionStepContext,
sharedState State,
) (int64, error) {
stepOutput, exists := sharedState.Get(stepCtx.Alias)
if !exists {
return -1, nil
}
stepOutputMap, ok := stepOutput.(map[string]any)
if !ok {
return -1, fmt.Errorf(
"output from step with alias %q is not a map[string]any",
stepCtx.Alias,
)
}
prNumberAny, exists := stepOutputMap[stateKeyPRNumber]
if !exists {
return -1, nil
}
// If the state was rehydrated from PromotionStatus, which makes use of
// apiextensions.JSON, the PR number will be a float64. Otherwise, it will be
// an int64. We need to handle both cases.
switch prNumber := prNumberAny.(type) {
case int64:
return prNumber, nil
case float64:
return int64(prNumber), nil
default:
return -1, fmt.Errorf(
"PR number in output from step with alias %q is not an int64",
stepCtx.Alias,
)
}
}
func (g *gitPROpener) getSourceBranch(
sharedState State,
cfg GitOpenPRConfig,
) (string, error) {
sourceBranch := cfg.SourceBranch
if cfg.SourceBranchFromStep != "" {
stepOutput, exists := sharedState.Get(cfg.SourceBranchFromStep)
if !exists {
return "", fmt.Errorf(
"no output found from step with alias %q",
cfg.SourceBranchFromStep,
)
}
stepOutputMap, ok := stepOutput.(map[string]any)
if !ok {
return "", fmt.Errorf(
"output from step with alias %q is not a map[string]any",
cfg.SourceBranchFromStep,
)
}
sourceBranchAny, exists := stepOutputMap[stateKeyBranch]
if !exists {
return "", fmt.Errorf(
"no branch found in output from step with alias %q",
cfg.SourceBranchFromStep,
)
}
if sourceBranch, ok = sourceBranchAny.(string); !ok {
return "", fmt.Errorf(
"branch name in output from step with alias %q is not a string",
cfg.SourceBranchFromStep,
)
}
}
return sourceBranch, nil
}
// ensureRemoteTargetBranch ensures the existence of a remote branch. If the
// branch does not exist, an empty orphaned branch is created and pushed to the
// remote.
func (g *gitPROpener) ensureRemoteTargetBranch(
repo git.Repo,
branch string, create bool,
) error {
exists, err := repo.RemoteBranchExists(branch)
if err != nil {
return fmt.Errorf(
"error checking if remote branch %q of repo %s exists: %w",
branch, repo.URL(), err,
)
}
if exists {
return nil
}
if !create {
return fmt.Errorf(
"remote branch %q does not exist in repo %s", branch, repo.URL(),
)
}
if err = repo.CreateOrphanedBranch(branch); err != nil {
return fmt.Errorf(
"error creating orphaned branch %q in repo %s: %w",
branch, repo.URL(), err,
)
}
if err = repo.Commit(
"Initial commit",
&git.CommitOptions{AllowEmpty: true},
); err != nil {
return fmt.Errorf(
"error making initial commit to new branch %q of repo %s: %w",
branch, repo.URL(), err,
)
}
if err = repo.Push(&git.PushOptions{TargetBranch: branch}); err != nil {
return fmt.Errorf(
"error pushing initial commit to new branch %q to repo %s: %w",
branch, repo.URL(), err,
)
}
return nil
}
// getExistingPR searches for an existing pull request from the head of the
// repo's current branch to the target branch. If a PR is found, it is returned.
// If no PR is found, nil is returned.
func (g *gitPROpener) getExistingPR(
ctx context.Context,
repo git.Repo,
gitProv gitprovider.Interface,
targetBranch string,
) (*gitprovider.PullRequest, error) {
commitID, err := repo.LastCommitID()
if err != nil {
return nil, fmt.Errorf("error getting last commit ID: %w", err)
}
sourceBranch, err := repo.CurrentBranch()
if err != nil {
return nil, fmt.Errorf("error getting current branch: %w", err)
}
// Find any existing PRs that are identical to the one we might open.
prs, err := gitProv.ListPullRequests(
ctx,
&gitprovider.ListPullRequestOptions{
BaseBranch: targetBranch,
HeadBranch: sourceBranch,
HeadCommit: commitID,
},
)
if err != nil {
return nil, fmt.Errorf("error listing pull requests: %w", err)
}
if len(prs) == 0 {
return nil, nil
}
// If promotion names are incorporated into PR source branches, it's highly
// unlikely that we would have found more than one PR matching the search
// criteria. Accounting for the possibility of users specifying their own
// source branch names using an expression, although still unlikely, there is
// somewhat more of a possibility of multiple PRs being found. In this case,
// we need to determine which PR is best to "adopt" as a proxy for the PR we
// would have otherwise opened. This requires sorting the PRs in a particular
// order.
g.sortPullRequests(prs)
return &prs[0], nil
}
// sortPullRequests is a specialized sorting function that sorts pull requests
// in the following order: open PRs first, then closed PRs that have been
// merged, then closed PRs that have not been merged. Within each of those
// categories, PRs are sorted by creation time in descending order.
func (g *gitPROpener) sortPullRequests(prs []gitprovider.PullRequest) {
slices.SortFunc(prs, func(lhs, rhs gitprovider.PullRequest) int {
switch {
case lhs.Open && !rhs.Open:
// If the first PR is open and the second is not, the first PR should
// come first.
return -1
case rhs.Open && !lhs.Open:
// If the second PR is open and the first is not, the second PR should
// come first.
return 1
case !lhs.Open && !rhs.Open:
// If both PRs are closed, one is merged and one is not, the merged PR
// should come first.
if lhs.Merged && !rhs.Merged {
return -1
}
if rhs.Merged && !lhs.Merged {
return 1
}
// If we get to here, both PRs are closed and neither is merged. Fall
// through to the default case.
fallthrough
default:
// If we get to here, both PRs are open or both are closed and neither is
// merged. The most recently opened PR should come first.
var ltime time.Time
if lhs.CreatedAt != nil {
ltime = *lhs.CreatedAt
}
var rtime time.Time
if rhs.CreatedAt != nil {
rtime = *rhs.CreatedAt
}
return rtime.Compare(ltime)
}
})
}