mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Prevent duplicate actions email (#35215)
Trying to prevent duplicate action emails by adding an extra check on job status. --------- Signed-off-by: NorthRealm <155140859+NorthRealm@users.noreply.github.com> Co-authored-by: Christopher Homberger <christopher.homberger@web.de>
This commit is contained in:
		| @@ -429,6 +429,12 @@ func Rerun(ctx *context_module.Context) { | ||||
| 			ctx.ServerError("UpdateRun", err) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if err := run.LoadAttributes(ctx); err != nil { | ||||
| 			ctx.ServerError("run.LoadAttributes", err) | ||||
| 			return | ||||
| 		} | ||||
| 		notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) | ||||
| 	} | ||||
|  | ||||
| 	job, jobs := getRunJobs(ctx, runIndex, jobIndex) | ||||
| @@ -485,7 +491,6 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou | ||||
| 	} | ||||
|  | ||||
| 	actions_service.CreateCommitStatus(ctx, job) | ||||
| 	actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) | ||||
| 	notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) | ||||
|  | ||||
| 	return nil | ||||
| @@ -560,9 +565,8 @@ func Cancel(ctx *context_module.Context) { | ||||
| 	if len(updatedjobs) > 0 { | ||||
| 		job := updatedjobs[0] | ||||
| 		actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) | ||||
| 		notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) | ||||
| 	} | ||||
| 	ctx.JSON(http.StatusOK, struct{}{}) | ||||
| 	ctx.JSONOK() | ||||
| } | ||||
|  | ||||
| func Approve(ctx *context_module.Context) { | ||||
| @@ -606,7 +610,6 @@ func Approve(ctx *context_module.Context) { | ||||
| 	if len(updatedjobs) > 0 { | ||||
| 		job := updatedjobs[0] | ||||
| 		actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) | ||||
| 		notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) | ||||
| 	} | ||||
|  | ||||
| 	for _, job := range updatedjobs { | ||||
| @@ -614,7 +617,7 @@ func Approve(ctx *context_module.Context) { | ||||
| 		notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) | ||||
| 	} | ||||
|  | ||||
| 	ctx.JSON(http.StatusOK, struct{}{}) | ||||
| 	ctx.JSONOK() | ||||
| } | ||||
|  | ||||
| func Delete(ctx *context_module.Context) { | ||||
|   | ||||
| @@ -42,10 +42,8 @@ func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.Ac | ||||
| 			_ = job.LoadAttributes(ctx) | ||||
| 			notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) | ||||
| 		} | ||||
| 		if len(jobs) > 0 { | ||||
| 			job := jobs[0] | ||||
| 			notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) | ||||
| 		} | ||||
| 		job := jobs[0] | ||||
| 		notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -101,7 +99,7 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // CancelAbandonedJobs cancels the jobs which have waiting status, but haven't been picked by a runner for a long time | ||||
| // CancelAbandonedJobs cancels jobs that have not been picked by any runner for a long time | ||||
| func CancelAbandonedJobs(ctx context.Context) error { | ||||
| 	jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{ | ||||
| 		Statuses:      []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked}, | ||||
| @@ -113,24 +111,40 @@ func CancelAbandonedJobs(ctx context.Context) error { | ||||
| 	} | ||||
|  | ||||
| 	now := timeutil.TimeStampNow() | ||||
|  | ||||
| 	// Collect one job per run to send workflow run status update | ||||
| 	updatedRuns := map[int64]*actions_model.ActionRunJob{} | ||||
|  | ||||
| 	for _, job := range jobs { | ||||
| 		job.Status = actions_model.StatusCancelled | ||||
| 		job.Stopped = now | ||||
| 		updated := false | ||||
| 		if err := db.WithTx(ctx, func(ctx context.Context) error { | ||||
| 			n, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped") | ||||
| 			updated = err == nil && n > 0 | ||||
| 			return err | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			if err := job.LoadAttributes(ctx); err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			updated = n > 0 | ||||
| 			if updated && job.Run.Status.IsDone() { | ||||
| 				updatedRuns[job.RunID] = job | ||||
| 			} | ||||
| 			return nil | ||||
| 		}); err != nil { | ||||
| 			log.Warn("cancel abandoned job %v: %v", job.ID, err) | ||||
| 			// go on | ||||
| 		} | ||||
| 		CreateCommitStatus(ctx, job) | ||||
| 		if updated { | ||||
| 			NotifyWorkflowRunStatusUpdateWithReload(ctx, job) | ||||
| 			notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	for _, job := range updatedRuns { | ||||
| 		notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
| @@ -33,7 +33,18 @@ func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Reposito | ||||
| 	return fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain) | ||||
| } | ||||
|  | ||||
| func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) { | ||||
| func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) error { | ||||
| 	jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	for _, job := range jobs { | ||||
| 		if !job.Status.IsDone() { | ||||
| 			log.Debug("composeAndSendActionsWorkflowRunStatusEmail: A job is not done. Will not compose and send actions email.") | ||||
| 			return nil | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	subject := "Run" | ||||
| 	switch run.Status { | ||||
| 	case actions_model.StatusFailure: | ||||
| @@ -48,11 +59,6 @@ func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo | ||||
| 	messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run) | ||||
| 	metadataHeaders := generateMetadataHeaders(repo) | ||||
|  | ||||
| 	jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) | ||||
| 	if err != nil { | ||||
| 		log.Error("GetRunJobsByRunID: %v", err) | ||||
| 		return | ||||
| 	} | ||||
| 	sort.SliceStable(jobs, func(i, j int) bool { | ||||
| 		si, sj := jobs[i].Status, jobs[j].Status | ||||
| 		/* | ||||
| @@ -111,11 +117,11 @@ func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo | ||||
| 			"Jobs":          convertedJobs, | ||||
| 			"locale":        locale, | ||||
| 		}); err != nil { | ||||
| 			log.Error("ExecuteTemplate [%s]: %v", tplWorkflowRun, err) | ||||
| 			return | ||||
| 			return err | ||||
| 		} | ||||
| 		msgs := make([]*sender_service.Message, 0, len(tos)) | ||||
| 		for _, rec := range tos { | ||||
| 			log.Trace("Sending actions email to %s (UID: %d)", rec.Name, rec.ID) | ||||
| 			msg := sender_service.NewMessageFrom( | ||||
| 				rec.Email, | ||||
| 				displayName, | ||||
| @@ -135,14 +141,16 @@ func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo | ||||
| 		} | ||||
| 		SendAsync(msgs...) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) { | ||||
| func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) error { | ||||
| 	if setting.MailService == nil { | ||||
| 		return | ||||
| 		return nil | ||||
| 	} | ||||
| 	if run.Status.IsSkipped() { | ||||
| 		return | ||||
| 	if !run.Status.IsDone() || run.Status.IsSkipped() { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	recipients := make([]*user_model.User, 0) | ||||
| @@ -151,8 +159,7 @@ func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo | ||||
| 		notifyPref, err := user_model.GetUserSetting(ctx, sender.ID, | ||||
| 			user_model.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly) | ||||
| 		if err != nil { | ||||
| 			log.Error("GetUserSetting: %v", err) | ||||
| 			return | ||||
| 			return err | ||||
| 		} | ||||
| 		if notifyPref == user_model.SettingEmailNotificationGiteaActionsAll || !run.Status.IsSuccess() && notifyPref != user_model.SettingEmailNotificationGiteaActionsDisabled { | ||||
| 			recipients = append(recipients, sender) | ||||
| @@ -160,6 +167,8 @@ func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo | ||||
| 	} | ||||
|  | ||||
| 	if len(recipients) > 0 { | ||||
| 		composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients) | ||||
| 		log.Debug("MailActionsTrigger: Initiate email composition") | ||||
| 		return composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
| @@ -208,8 +208,7 @@ func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner * | ||||
| } | ||||
|  | ||||
| func (m *mailNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { | ||||
| 	if !run.Status.IsDone() { | ||||
| 		return | ||||
| 	if err := MailActionsTrigger(ctx, sender, repo, run); err != nil { | ||||
| 		log.Error("MailActionsTrigger: %v", err) | ||||
| 	} | ||||
| 	MailActionsTrigger(ctx, sender, repo, run) | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import ( | ||||
| 	"path" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	auth_model "code.gitea.io/gitea/models/auth" | ||||
| 	"code.gitea.io/gitea/models/repo" | ||||
| @@ -24,7 +25,9 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/json" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	api "code.gitea.io/gitea/modules/structs" | ||||
| 	"code.gitea.io/gitea/modules/test" | ||||
| 	webhook_module "code.gitea.io/gitea/modules/webhook" | ||||
| 	"code.gitea.io/gitea/services/actions" | ||||
| 	"code.gitea.io/gitea/tests" | ||||
|  | ||||
| 	runnerv1 "code.gitea.io/actions-proto-go/runner/v1" | ||||
| @@ -1102,45 +1105,465 @@ type workflowRunWebhook struct { | ||||
| } | ||||
|  | ||||
| func Test_WebhookWorkflowRun(t *testing.T) { | ||||
| 	webhookData := &workflowRunWebhook{} | ||||
| 	provider := newMockWebhookProvider(func(r *http.Request) { | ||||
| 		assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_run", "X-GitHub-Event-Type should contain workflow_run") | ||||
| 		assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_run", "X-Gitea-Event-Type should contain workflow_run") | ||||
| 		assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_run", "X-Gogs-Event-Type should contain workflow_run") | ||||
| 		content, _ := io.ReadAll(r.Body) | ||||
| 		var payload api.WorkflowRunPayload | ||||
| 		err := json.Unmarshal(content, &payload) | ||||
| 		assert.NoError(t, err) | ||||
| 		webhookData.payloads = append(webhookData.payloads, payload) | ||||
| 		webhookData.triggeredEvent = "workflow_run" | ||||
| 	}, http.StatusOK) | ||||
| 	defer provider.Close() | ||||
| 	webhookData.URL = provider.URL() | ||||
|  | ||||
| 	tests := []struct { | ||||
| 	testCases := []struct { | ||||
| 		name     string | ||||
| 		callback func(t *testing.T, webhookData *workflowRunWebhook) | ||||
| 		testFunc func(t *testing.T, webhookData *workflowRunWebhook) | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:     "WorkflowRun", | ||||
| 			callback: testWebhookWorkflowRun, | ||||
| 			testFunc: testWebhookWorkflowRun, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "WorkflowRunDepthLimit", | ||||
| 			callback: testWebhookWorkflowRunDepthLimit, | ||||
| 			testFunc: testWebhookWorkflowRunDepthLimit, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "WorkflowRunEvents", | ||||
| 			testFunc: testWorkflowRunEvents, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "WorkflowRunEventsOnRerun", | ||||
| 			testFunc: testWorkflowRunEventsOnRerun, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "WorkflowRunEventsOnCancellingAbandonedRunAllJobsAbandoned", | ||||
| 			testFunc: func(t *testing.T, webhookData *workflowRunWebhook) { | ||||
| 				testWorkflowRunEventsOnCancellingAbandonedRun(t, webhookData, true) | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "WorkflowRunEventsOnCancellingAbandonedRunPartiallyAbandoned", | ||||
| 			testFunc: func(t *testing.T, webhookData *workflowRunWebhook) { | ||||
| 				testWorkflowRunEventsOnCancellingAbandonedRun(t, webhookData, false) | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, test := range tests { | ||||
| 		t.Run(test.name, func(t *testing.T) { | ||||
| 			webhookData.payloads = nil | ||||
| 			webhookData.triggeredEvent = "" | ||||
| 	for _, obj := range testCases { | ||||
| 		t.Run(obj.name, func(t *testing.T) { | ||||
| 			onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { | ||||
| 				test.callback(t, webhookData) | ||||
| 				webhookData := &workflowRunWebhook{} | ||||
| 				provider := newMockWebhookProvider(func(r *http.Request) { | ||||
| 					assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_run", "X-GitHub-Event-Type should contain workflow_run") | ||||
| 					assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_run", "X-Gitea-Event-Type should contain workflow_run") | ||||
| 					assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_run", "X-Gogs-Event-Type should contain workflow_run") | ||||
| 					content, _ := io.ReadAll(r.Body) | ||||
| 					var payload api.WorkflowRunPayload | ||||
| 					err := json.Unmarshal(content, &payload) | ||||
| 					assert.NoError(t, err) | ||||
| 					webhookData.payloads = append(webhookData.payloads, payload) | ||||
| 					webhookData.triggeredEvent = "workflow_run" | ||||
| 				}, http.StatusOK) | ||||
| 				defer provider.Close() | ||||
| 				webhookData.URL = provider.URL() | ||||
| 				webhookData.payloads = nil | ||||
| 				webhookData.triggeredEvent = "" | ||||
| 				obj.testFunc(t, webhookData) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func testWorkflowRunEvents(t *testing.T, webhookData *workflowRunWebhook) { | ||||
| 	// 1. create a new webhook with special webhook for repo1 | ||||
| 	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | ||||
| 	session := loginUser(t, "user2") | ||||
| 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) | ||||
|  | ||||
| 	testAPICreateWebhookForRepo(t, session, "user2", "repo1", webhookData.URL, "workflow_run") | ||||
|  | ||||
| 	repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) | ||||
|  | ||||
| 	gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// 2.2 trigger the webhooks | ||||
|  | ||||
| 	// add workflow file to the repo | ||||
| 	// init the workflow | ||||
| 	wfTreePath := ".gitea/workflows/push.yml" | ||||
| 	wfFileContent := `on: | ||||
|   push: | ||||
|   workflow_dispatch: | ||||
|  | ||||
| jobs: | ||||
|   test: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test2: | ||||
|     needs: [test] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test3: | ||||
|     needs: [test, test2] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test4: | ||||
|     needs: [test, test2, test3] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test5: | ||||
|     needs: [test, test2, test4] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test6: | ||||
|     strategy: | ||||
|       matrix: | ||||
|         os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04] | ||||
|     needs: [test, test2, test3] | ||||
|     runs-on: ${{ matrix.os }} | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test7: | ||||
|     needs: test6 | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test8: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test9: | ||||
|     strategy: | ||||
|       matrix: | ||||
|         os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-25.04, windows-2022, windows-2025, macos-13, macos-14, macos-15] | ||||
|     runs-on: ${{ matrix.os }} | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test10: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0` | ||||
| 	opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent) | ||||
| 	createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts) | ||||
|  | ||||
| 	commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// 3. validate the webhook is triggered | ||||
| 	assert.Equal(t, "workflow_run", webhookData.triggeredEvent) | ||||
| 	assert.Len(t, webhookData.payloads, 1) | ||||
| 	assert.Equal(t, "requested", webhookData.payloads[0].Action) | ||||
| 	assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status) | ||||
| 	assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch) | ||||
| 	assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha) | ||||
| 	assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name) | ||||
| 	assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName) | ||||
|  | ||||
| 	// Call cancel ui api | ||||
| 	// Only a web UI API exists for cancelling workflow runs, so use the UI endpoint. | ||||
| 	cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", webhookData.payloads[0].WorkflowRun.RunNumber) | ||||
| 	req := NewRequestWithValues(t, "POST", cancelURL, map[string]string{ | ||||
| 		"_csrf": GetUserCSRFToken(t, session), | ||||
| 	}) | ||||
| 	session.MakeRequest(t, req, http.StatusOK) | ||||
|  | ||||
| 	assert.Len(t, webhookData.payloads, 2) | ||||
|  | ||||
| 	// 4. Validate the second webhook payload | ||||
| 	assert.Equal(t, "workflow_run", webhookData.triggeredEvent) | ||||
| 	assert.Equal(t, "completed", webhookData.payloads[1].Action) | ||||
| 	assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event) | ||||
| 	assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) | ||||
| 	assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) | ||||
| 	assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) | ||||
| 	assert.Equal(t, "repo1", webhookData.payloads[1].Repo.Name) | ||||
| 	assert.Equal(t, "user2/repo1", webhookData.payloads[1].Repo.FullName) | ||||
| } | ||||
|  | ||||
| func testWorkflowRunEventsOnRerun(t *testing.T, webhookData *workflowRunWebhook) { | ||||
| 	// 1. create a new webhook with special webhook for repo1 | ||||
| 	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | ||||
| 	session := loginUser(t, "user2") | ||||
| 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) | ||||
|  | ||||
| 	runners := make([]*mockRunner, 2) | ||||
| 	for i := range runners { | ||||
| 		runners[i] = newMockRunner() | ||||
| 		runners[i].registerAsRepoRunner(t, "user2", "repo1", fmt.Sprintf("mock-runner-%d", i), []string{"ubuntu-latest"}, false) | ||||
| 	} | ||||
|  | ||||
| 	testAPICreateWebhookForRepo(t, session, "user2", "repo1", webhookData.URL, "workflow_run") | ||||
|  | ||||
| 	repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) | ||||
|  | ||||
| 	gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// 2.2 trigger the webhooks | ||||
|  | ||||
| 	// add workflow file to the repo | ||||
| 	// init the workflow | ||||
| 	wfTreePath := ".gitea/workflows/push.yml" | ||||
| 	wfFileContent := `on: | ||||
|   push: | ||||
|   workflow_dispatch: | ||||
|  | ||||
| jobs: | ||||
|   test: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test2: | ||||
|     needs: [test] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test3: | ||||
|     needs: [test, test2] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test4: | ||||
|     needs: [test, test2, test3] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test5: | ||||
|     needs: [test, test2, test4] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test6: | ||||
|     strategy: | ||||
|       matrix: | ||||
|         os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04] | ||||
|     needs: [test, test2, test3] | ||||
|     runs-on: ${{ matrix.os }} | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test7: | ||||
|     needs: test6 | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test8: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test9: | ||||
|     strategy: | ||||
|       matrix: | ||||
|         os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-25.04, windows-2022, windows-2025, macos-13, macos-14, macos-15] | ||||
|     runs-on: ${{ matrix.os }} | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test10: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0` | ||||
| 	opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent) | ||||
| 	createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts) | ||||
|  | ||||
| 	commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// 3. validate the webhook is triggered | ||||
| 	assert.Equal(t, "workflow_run", webhookData.triggeredEvent) | ||||
| 	assert.Len(t, webhookData.payloads, 1) | ||||
| 	assert.Equal(t, "requested", webhookData.payloads[0].Action) | ||||
| 	assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status) | ||||
| 	assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch) | ||||
| 	assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha) | ||||
| 	assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name) | ||||
| 	assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName) | ||||
|  | ||||
| 	for _, runner := range runners { | ||||
| 		task := runner.fetchTask(t) | ||||
| 		runner.execTask(t, task, &mockTaskOutcome{ | ||||
| 			result: runnerv1.Result_RESULT_SUCCESS, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	// Call cancel ui api | ||||
| 	// Only a web UI API exists for cancelling workflow runs, so use the UI endpoint. | ||||
| 	cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", webhookData.payloads[0].WorkflowRun.RunNumber) | ||||
| 	req := NewRequestWithValues(t, "POST", cancelURL, map[string]string{ | ||||
| 		"_csrf": GetUserCSRFToken(t, session), | ||||
| 	}) | ||||
| 	session.MakeRequest(t, req, http.StatusOK) | ||||
|  | ||||
| 	assert.Len(t, webhookData.payloads, 2) | ||||
|  | ||||
| 	// 4. Validate the second webhook payload | ||||
| 	assert.Equal(t, "workflow_run", webhookData.triggeredEvent) | ||||
| 	assert.Equal(t, "completed", webhookData.payloads[1].Action) | ||||
| 	assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event) | ||||
| 	assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) | ||||
| 	assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) | ||||
| 	assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) | ||||
| 	assert.Equal(t, "repo1", webhookData.payloads[1].Repo.Name) | ||||
| 	assert.Equal(t, "user2/repo1", webhookData.payloads[1].Repo.FullName) | ||||
|  | ||||
| 	// Call rerun ui api | ||||
| 	// Only a web UI API exists for cancelling workflow runs, so use the UI endpoint. | ||||
| 	rerunURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/rerun", webhookData.payloads[0].WorkflowRun.RunNumber) | ||||
| 	req = NewRequestWithValues(t, "POST", rerunURL, map[string]string{ | ||||
| 		"_csrf": GetUserCSRFToken(t, session), | ||||
| 	}) | ||||
| 	session.MakeRequest(t, req, http.StatusOK) | ||||
|  | ||||
| 	assert.Len(t, webhookData.payloads, 3) | ||||
| } | ||||
|  | ||||
| func testWorkflowRunEventsOnCancellingAbandonedRun(t *testing.T, webhookData *workflowRunWebhook, allJobsAbandoned bool) { | ||||
| 	defer test.MockVariableValue(&setting.Actions.AbandonedJobTimeout, 0*time.Nanosecond)() | ||||
|  | ||||
| 	// 1. create a new webhook with special webhook for repo1 | ||||
| 	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | ||||
| 	session := loginUser(t, "user2") | ||||
| 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) | ||||
|  | ||||
| 	repoName := "test-workflow-run-cancelling-abandoned-run" | ||||
| 	testRepo := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: createActionsTestRepo(t, token, repoName, false).ID}) | ||||
|  | ||||
| 	runners := make([]*mockRunner, 2) | ||||
| 	for i := range runners { | ||||
| 		runners[i] = newMockRunner() | ||||
| 		runners[i].registerAsRepoRunner(t, "user2", repoName, | ||||
| 			fmt.Sprintf("mock-runner-%d", i), []string{"ubuntu-latest"}, false) | ||||
| 	} | ||||
|  | ||||
| 	testAPICreateWebhookForRepo(t, session, "user2", repoName, webhookData.URL, "workflow_run") | ||||
|  | ||||
| 	ctx := t.Context() | ||||
| 	gitRepo, err := gitrepo.OpenRepository(ctx, testRepo) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// 2.2 trigger the webhooks | ||||
|  | ||||
| 	// add workflow file to the repo | ||||
| 	// init the workflow | ||||
| 	wfilename := "push.yml" | ||||
| 	wfTreePath := ".gitea/workflows/" + wfilename | ||||
| 	wfFileContent := `on: | ||||
|   push: | ||||
|   workflow_dispatch: | ||||
|  | ||||
| jobs: | ||||
|   test: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test2: | ||||
|     needs: [test] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test3: | ||||
|     needs: [test, test2] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test4: | ||||
|     needs: [test, test2, test3] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test5: | ||||
|     needs: [test, test2, test4] | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test6: | ||||
|     strategy: | ||||
|       matrix: | ||||
|         os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04] | ||||
|     needs: [test, test2, test3] | ||||
|     runs-on: ${{ matrix.os }} | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test7: | ||||
|     needs: test6 | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test8: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test9: | ||||
|     strategy: | ||||
|       matrix: | ||||
|         os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-25.04, windows-2022, windows-2025, macos-13, macos-14, macos-15] | ||||
|     runs-on: ${{ matrix.os }} | ||||
|     steps: | ||||
|       - run: exit 0 | ||||
|  | ||||
|   test10: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: exit 0` | ||||
|  | ||||
| 	opts := getWorkflowCreateFileOptions(user2, testRepo.DefaultBranch, "create "+wfTreePath, wfFileContent) | ||||
| 	createWorkflowFile(t, token, "user2", repoName, wfTreePath, opts) | ||||
|  | ||||
| 	commitID, err := gitRepo.GetBranchCommitID(testRepo.DefaultBranch) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// 3. validate the webhook is triggered | ||||
| 	assert.Equal(t, "workflow_run", webhookData.triggeredEvent) | ||||
| 	assert.Len(t, webhookData.payloads, 1) | ||||
| 	assert.Equal(t, "requested", webhookData.payloads[0].Action) | ||||
| 	assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status) | ||||
| 	assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch) | ||||
| 	assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha) | ||||
| 	assert.Equal(t, repoName, webhookData.payloads[0].Repo.Name) | ||||
| 	assert.Equal(t, "user2/"+repoName, webhookData.payloads[0].Repo.FullName) | ||||
|  | ||||
| 	if !allJobsAbandoned { | ||||
| 		for _, runner := range runners { | ||||
| 			task := runner.fetchTask(t) | ||||
| 			runner.execTask(t, task, &mockTaskOutcome{ | ||||
| 				result: runnerv1.Result_RESULT_SUCCESS, | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Add this sleep to ensure the func can find the tasks by timestamp. | ||||
| 	time.Sleep(time.Second) | ||||
|  | ||||
| 	err = actions.CancelAbandonedJobs(ctx) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Len(t, webhookData.payloads, 2) | ||||
| 	assert.Equal(t, "completed", webhookData.payloads[1].Action) | ||||
| 	assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) | ||||
| 	assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) | ||||
| 	assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) | ||||
| 	assert.Equal(t, repoName, webhookData.payloads[1].Repo.Name) | ||||
| 	assert.Equal(t, "user2/"+repoName, webhookData.payloads[1].Repo.FullName) | ||||
| } | ||||
|  | ||||
| func testWebhookWorkflowRun(t *testing.T, webhookData *workflowRunWebhook) { | ||||
| 	// 1. create a new webhook with special webhook for repo1 | ||||
| 	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user