Skip to content

Commit f0042c7

Browse files
zakiskchmouel
authored andcommitted
feat: Add GitOps command support for tags in GitLab
This commit introduces the ability to trigger, retest, and cancel pipeline runs on GitLab tags using GitOps comments on commits. Key features: - Support `/test tag:<tagname>`, `/retest tag:<tagname>`, and `/cancel tag:<tagname>` GitOps comments on commits - Validate that the commented commit matches the tag before processing the request - Enable retriggering pipelines on existing tagged commits without creating new tags Testing: - Add comprehensive unit tests covering success and error scenarios - Add end-to-end test validating the complete workflow from tag creation to pipeline execution Use cases: - Release management: retrigger pipelines on tagged releases - Troubleshooting: re-run failed pipelines on existing tags - Flexible CI/CD workflows for version-specific builds https://issues.redhat.com/browse/SRVKP-8612 Signed-off-by: Zaki Shaikh <zashaikh@redhat.com>
1 parent b42d36d commit f0042c7

File tree

4 files changed

+230
-12
lines changed

4 files changed

+230
-12
lines changed

docs/content/docs/guide/gitops_commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ The PipelineRun will be restarted regardless of the annotations if the comment `
124124

125125
### Triggering PipelineRun on Git tags
126126

127-
{{< support_matrix github_app="true" github_webhook="true" gitea="false" gitlab="false" bitbucket_cloud="false" bitbucket_server="false" >}}
127+
{{< support_matrix github_app="true" github_webhook="true" gitea="false" gitlab="true" bitbucket_cloud="false" bitbucket_server="false" >}}
128128

129129
You can retrigger a PipelineRun against a specific Git tag by commenting on
130130
the tagged commit using a GitOps command. Pipelines-as-Code will resolve the

pkg/provider/gitlab/parse_payload.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,20 @@ func (v *Provider) handleCommitCommentEvent(ctx context.Context, event *gitlab.C
256256
var (
257257
branchName string
258258
prName string
259+
tagName string
259260
err error
260261
)
261262

263+
// since we're going to make an API call to ensure that the commit is HEAD of the branch
264+
// therefore we need to initialize GitLab client here
265+
processedEvent, err = v.initGitLabClient(ctx, processedEvent)
266+
if err != nil {
267+
return processedEvent, err
268+
}
269+
262270
// get PipelineRun name from comment if it does contain e.g. `/test pr7`
263271
if provider.IsTestRetestComment(event.ObjectAttributes.Note) {
264-
prName, branchName, err = opscomments.GetPipelineRunAndBranchNameFromTestComment(event.ObjectAttributes.Note)
272+
prName, branchName, tagName, err = provider.GetPipelineRunAndBranchOrTagNameFromTestComment(event.ObjectAttributes.Note)
265273
if err != nil {
266274
return processedEvent, err
267275
}
@@ -270,23 +278,32 @@ func (v *Provider) handleCommitCommentEvent(ctx context.Context, event *gitlab.C
270278

271279
if provider.IsCancelComment(event.ObjectAttributes.Note) {
272280
action = "cancellation"
273-
prName, branchName, err = opscomments.GetPipelineRunAndBranchNameFromCancelComment(event.ObjectAttributes.Note)
281+
prName, branchName, tagName, err = provider.GetPipelineRunAndBranchOrTagNameFromCancelComment(event.ObjectAttributes.Note)
274282
if err != nil {
275283
return processedEvent, err
276284
}
277285
processedEvent.CancelPipelineRuns = true
278286
processedEvent.TargetCancelPipelineRun = prName
279287
}
280288

281-
if branchName == "" {
282-
branchName = processedEvent.HeadBranch
289+
if tagName != "" {
290+
tagPath := fmt.Sprintf("refs/tags/%s", tagName)
291+
tag, _, err := v.gitlabClient.Tags.GetTag(v.sourceProjectID, tagName)
292+
if err != nil {
293+
return processedEvent, fmt.Errorf("error getting tag %s: %w", tagName, err)
294+
}
295+
296+
if tag.Commit.ID != processedEvent.SHA {
297+
return processedEvent, fmt.Errorf("provided SHA %s is not the tagged commit for the tag %s", processedEvent.SHA, tagName)
298+
}
299+
300+
processedEvent.HeadBranch = tagPath
301+
processedEvent.BaseBranch = tagPath
302+
return processedEvent, nil
283303
}
284304

285-
// since we're going to make an API call to ensure that the commit is HEAD of the branch
286-
// therefore we need to initialize GitLab client here
287-
processedEvent, err = v.initGitLabClient(ctx, processedEvent)
288-
if err != nil {
289-
return processedEvent, err
305+
if branchName == "" {
306+
branchName = processedEvent.HeadBranch
290307
}
291308

292309
// check if the commit on which comment is made, is HEAD commit of the branch

pkg/provider/gitlab/parse_payload_test.go

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,15 @@ func TestParsePayload(t *testing.T) {
201201
wantErrMsg: "error parse_payload: the repository in event payload must not be nil",
202202
},
203203
{
204-
name: "bad/commit comment wrong branch keyword",
204+
name: "bad/commit comment wrong branch keyword",
205+
fields: fields{sourceProjectID: 200},
205206
args: args{
206207
event: gitlab.EventTypeNote,
207208
payload: sample.CommitNoteEventAsJSON("/test brrranch:fix", "create", "{}"),
208209
},
209-
wantErrMsg: "the GitOps comment brrranch does not contain a branch word",
210+
wantErrMsg: "does not contain a branch or tag word",
211+
wantKubeClient: true,
212+
wantClient: true,
210213
},
211214
{
212215
name: "good/commit comment /test all pipelineruns",
@@ -307,6 +310,82 @@ func TestParsePayload(t *testing.T) {
307310
wantKubeClient: true,
308311
wantClient: true,
309312
},
313+
{
314+
name: "good/commit comment /test on a tag",
315+
fields: fields{sourceProjectID: 200},
316+
args: args{
317+
event: gitlab.EventTypeNote,
318+
payload: sample.CommitNoteEventAsJSON("/test tag:v1.0.0", "create", "{}"),
319+
},
320+
want: &info.Event{
321+
EventType: opscomments.TestSingleCommentEventType.String(),
322+
TriggerTarget: triggertype.Push,
323+
Organization: "hello/this/is/me/ze",
324+
Repository: "project",
325+
HeadBranch: "refs/tags/v1.0.0",
326+
BaseBranch: "refs/tags/v1.0.0",
327+
},
328+
wantKubeClient: true,
329+
wantClient: true,
330+
},
331+
{
332+
name: "good/commit comment /retest on a tag",
333+
fields: fields{sourceProjectID: 200},
334+
args: args{
335+
event: gitlab.EventTypeNote,
336+
payload: sample.CommitNoteEventAsJSON("/retest tag:v1.0.0", "create", "{}"),
337+
},
338+
want: &info.Event{
339+
EventType: opscomments.RetestSingleCommentEventType.String(),
340+
TriggerTarget: triggertype.Push,
341+
Organization: "hello/this/is/me/ze",
342+
Repository: "project",
343+
HeadBranch: "refs/tags/v1.0.0",
344+
BaseBranch: "refs/tags/v1.0.0",
345+
},
346+
wantKubeClient: true,
347+
wantClient: true,
348+
},
349+
{
350+
name: "good/commit comment /cancel on a tag",
351+
fields: fields{sourceProjectID: 200},
352+
args: args{
353+
event: gitlab.EventTypeNote,
354+
payload: sample.CommitNoteEventAsJSON("/cancel tag:v1.0.0", "create", "{}"),
355+
},
356+
want: &info.Event{
357+
EventType: opscomments.CancelCommentSingleEventType.String(),
358+
TriggerTarget: triggertype.Push,
359+
Organization: "hello/this/is/me/ze",
360+
Repository: "project",
361+
HeadBranch: "refs/tags/v1.0.0",
362+
BaseBranch: "refs/tags/v1.0.0",
363+
},
364+
wantKubeClient: true,
365+
wantClient: true,
366+
},
367+
{
368+
name: "bad/commit comment tag does not exist",
369+
fields: fields{sourceProjectID: 200},
370+
args: args{
371+
event: gitlab.EventTypeNote,
372+
payload: sample.CommitNoteEventAsJSON("/test tag:nonexistent", "create", "{}"),
373+
},
374+
wantErrMsg: "error getting tag nonexistent",
375+
wantKubeClient: true,
376+
wantClient: true,
377+
},
378+
{
379+
name: "bad/commit comment SHA does not match tag commit",
380+
fields: fields{sourceProjectID: 200},
381+
args: args{
382+
event: gitlab.EventTypeNote,
383+
payload: sample.CommitNoteEventAsJSON("/test tag:v1.0.0-mismatch", "create", "{}"),
384+
},
385+
wantErrMsg: "provided SHA sha is not the tagged commit for the tag v1.0.0-mismatch",
386+
wantKubeClient: true,
387+
wantClient: true,
388+
},
310389
}
311390
for _, tt := range tests {
312391
t.Run(tt.name, func(t *testing.T) {
@@ -375,6 +454,32 @@ func TestParsePayload(t *testing.T) {
375454
bytes, _ := json.Marshal(branch)
376455
_, _ = rw.Write(bytes)
377456
})
457+
// Mock tag API for v1.0.0 (valid tag with matching SHA)
458+
mux.HandleFunc("/projects/200/repository/tags/v1.0.0",
459+
func(rw http.ResponseWriter, _ *http.Request) {
460+
tag := &gitlab.Tag{
461+
Name: "v1.0.0",
462+
Commit: &gitlab.Commit{ID: "sha"},
463+
}
464+
bytes, _ := json.Marshal(tag)
465+
_, _ = rw.Write(bytes)
466+
})
467+
// Mock tag API for v1.0.0-mismatch (tag with non-matching SHA)
468+
mux.HandleFunc("/projects/200/repository/tags/v1.0.0-mismatch",
469+
func(rw http.ResponseWriter, _ *http.Request) {
470+
tag := &gitlab.Tag{
471+
Name: "v1.0.0-mismatch",
472+
Commit: &gitlab.Commit{ID: "different-sha"},
473+
}
474+
bytes, _ := json.Marshal(tag)
475+
_, _ = rw.Write(bytes)
476+
})
477+
// Mock tag API for nonexistent (return 404)
478+
mux.HandleFunc("/projects/200/repository/tags/nonexistent",
479+
func(rw http.ResponseWriter, _ *http.Request) {
480+
rw.WriteHeader(http.StatusNotFound)
481+
_, _ = rw.Write([]byte(`{"message":"404 Tag Not Found"}`))
482+
})
378483
defer tearDown()
379484
}
380485

@@ -398,6 +503,12 @@ func TestParsePayload(t *testing.T) {
398503
if tt.want.TargetCancelPipelineRun != "" {
399504
assert.Equal(t, tt.want.TargetCancelPipelineRun, got.TargetCancelPipelineRun)
400505
}
506+
if tt.want.HeadBranch != "" {
507+
assert.Equal(t, tt.want.HeadBranch, got.HeadBranch)
508+
}
509+
if tt.want.BaseBranch != "" {
510+
assert.Equal(t, tt.want.BaseBranch, got.BaseBranch)
511+
}
401512
}
402513
})
403514
}

test/gitlab_push_gitops_command_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,93 @@ func TestGitlabGitOpsCommandCancelOnPush(t *testing.T) {
166166
err = wait.UntilPipelineRunHasReason(ctx, runcnx.Clients, v1.PipelineRunReasonCancelled, waitOpts)
167167
assert.NilError(t, err)
168168
}
169+
170+
func TestGitlabGitOpsCommandTestOnTag(t *testing.T) {
171+
targetNs := names.SimpleNameGenerator.RestrictLengthWithRandomSuffix("pac-e2e-ns")
172+
ctx := context.Background()
173+
runcnx, opts, glprovider, err := tgitlab.Setup(ctx)
174+
assert.NilError(t, err)
175+
ctx, err = cctx.GetControllerCtxInfo(ctx, runcnx)
176+
assert.NilError(t, err)
177+
runcnx.Clients.Log.Info("Testing with Gitlab")
178+
projectinfo, resp, err := glprovider.Client().Projects.GetProject(opts.ProjectID, nil)
179+
assert.NilError(t, err)
180+
if resp != nil && resp.StatusCode == http.StatusNotFound {
181+
t.Errorf("Repository %s not found in %s", opts.Organization, opts.Repo)
182+
}
183+
184+
tagName := "v1.0.0"
185+
comment := "/test tag:" + tagName
186+
sha := ""
187+
targetBranch := "release-" + tagName
188+
numberOfPRs := 1
189+
190+
err = tgitlab.CreateCRD(ctx, projectinfo, runcnx, opts, targetNs, nil)
191+
assert.NilError(t, err)
192+
193+
runcnx.Clients.Log.Infof("Repository %s has been created successfully", targetNs)
194+
195+
tag, resp, err := glprovider.Client().Tags.GetTag(opts.ProjectID, tagName)
196+
if err != nil && resp.StatusCode == http.StatusNotFound {
197+
runcnx.Clients.Log.Infof("Tag %s not found in repository %s", tagName, projectinfo.Name)
198+
runcnx.Clients.Log.Infof("Creating tag %s in repository %s", tagName, projectinfo.Name)
199+
200+
entries, err := payload.GetEntries(map[string]string{
201+
".tekton/pipelinerun-on-tag.yaml": "testdata/pipelinerun-on-tag.yaml",
202+
}, targetNs, "refs/tags/*", triggertype.Push.String(), map[string]string{})
203+
assert.NilError(t, err)
204+
205+
cloneURL, err := scm.MakeGitCloneURL(projectinfo.WebURL, "git", *glprovider.Token)
206+
assert.NilError(t, err)
207+
208+
scmOpts := &scm.Opts{
209+
GitURL: cloneURL,
210+
Log: runcnx.Clients.Log,
211+
WebURL: projectinfo.WebURL,
212+
TargetRefName: targetBranch,
213+
BaseRefName: projectinfo.DefaultBranch,
214+
CommitTitle: "Test GitOps Commands on Tag - " + targetNs,
215+
}
216+
_ = scm.PushFilesToRefGit(t, scmOpts, entries)
217+
218+
branch, _, err := glprovider.Client().Branches.GetBranch(opts.ProjectID, targetBranch)
219+
assert.NilError(t, err)
220+
221+
sha = branch.Commit.ID
222+
223+
_, _, err = glprovider.Client().Tags.CreateTag(opts.ProjectID, &gitlab.CreateTagOptions{
224+
TagName: gitlab.Ptr(tagName),
225+
Ref: gitlab.Ptr(targetBranch),
226+
})
227+
assert.NilError(t, err)
228+
229+
// as we're creating a tag, we need to increment the number of PRs
230+
// because the tag creation will also trigger a new PipelineRun
231+
numberOfPRs++
232+
} else {
233+
runcnx.Clients.Log.Infof("Tag %s already created in repository %s", tagName, projectinfo.Name)
234+
sha = tag.Commit.ID
235+
}
236+
defer tgitlab.TearDown(ctx, t, runcnx, glprovider, -1, "", targetNs, opts.ProjectID)
237+
238+
cc, _, err := glprovider.Client().Commits.PostCommitComment(opts.ProjectID, sha, &gitlab.PostCommitCommentOptions{
239+
Note: gitlab.Ptr(comment),
240+
})
241+
assert.NilError(t, err)
242+
runcnx.Clients.Log.Infof("Commit comment %s has been created", cc.Note)
243+
244+
waitOpts := wait.Opts{
245+
RepoName: targetNs,
246+
Namespace: targetNs,
247+
MinNumberStatus: numberOfPRs,
248+
PollTimeout: wait.DefaultTimeout,
249+
TargetSHA: sha,
250+
}
251+
252+
err = wait.UntilPipelineRunCreated(ctx, runcnx.Clients, waitOpts)
253+
assert.NilError(t, err)
254+
255+
prsNew, err := runcnx.Clients.Tekton.TektonV1().PipelineRuns(targetNs).List(ctx, metav1.ListOptions{})
256+
assert.NilError(t, err)
257+
assert.Assert(t, len(prsNew.Items) == numberOfPRs)
258+
}

0 commit comments

Comments
 (0)