|  | 
|  | 1 | +// Copyright 2025 The Gitea Authors. All rights reserved. | 
|  | 2 | +// SPDX-License-Identifier: MIT | 
|  | 3 | + | 
|  | 4 | +package integration | 
|  | 5 | + | 
|  | 6 | +import ( | 
|  | 7 | +	"net/url" | 
|  | 8 | +	"strconv" | 
|  | 9 | +	"strings" | 
|  | 10 | +	"testing" | 
|  | 11 | + | 
|  | 12 | +	actions_model "code.gitea.io/gitea/models/actions" | 
|  | 13 | +	auth_model "code.gitea.io/gitea/models/auth" | 
|  | 14 | +	git_model "code.gitea.io/gitea/models/git" | 
|  | 15 | +	issues_model "code.gitea.io/gitea/models/issues" | 
|  | 16 | +	repo_model "code.gitea.io/gitea/models/repo" | 
|  | 17 | +	unit_model "code.gitea.io/gitea/models/unit" | 
|  | 18 | +	"code.gitea.io/gitea/models/unittest" | 
|  | 19 | +	user_model "code.gitea.io/gitea/models/user" | 
|  | 20 | +	"code.gitea.io/gitea/modules/migration" | 
|  | 21 | +	api "code.gitea.io/gitea/modules/structs" | 
|  | 22 | +	"code.gitea.io/gitea/modules/util" | 
|  | 23 | +	mirror_service "code.gitea.io/gitea/services/mirror" | 
|  | 24 | +	repo_service "code.gitea.io/gitea/services/repository" | 
|  | 25 | +	files_service "code.gitea.io/gitea/services/repository/files" | 
|  | 26 | + | 
|  | 27 | +	"github.com/stretchr/testify/assert" | 
|  | 28 | +) | 
|  | 29 | + | 
|  | 30 | +func TestScheduleUpdate(t *testing.T) { | 
|  | 31 | +	t.Run("Push", testScheduleUpdatePush) | 
|  | 32 | +	t.Run("PullMerge", testScheduleUpdatePullMerge) | 
|  | 33 | +	t.Run("DisableAndEnableActionsUnit", testScheduleUpdateDisableAndEnableActionsUnit) | 
|  | 34 | +	t.Run("ArchiveAndUnarchive", testScheduleUpdateArchiveAndUnarchive) | 
|  | 35 | +	t.Run("MirrorSync", testScheduleUpdateMirrorSync) | 
|  | 36 | +} | 
|  | 37 | + | 
|  | 38 | +func testScheduleUpdatePush(t *testing.T) { | 
|  | 39 | +	doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) { | 
|  | 40 | +		newCron := "30 5 * * 1,3" | 
|  | 41 | +		pushScheduleChange(t, u, repo, newCron) | 
|  | 42 | +		branch, err := git_model.GetBranch(t.Context(), repo.ID, repo.DefaultBranch) | 
|  | 43 | +		assert.NoError(t, err) | 
|  | 44 | +		return branch.CommitID, newCron | 
|  | 45 | +	}) | 
|  | 46 | +} | 
|  | 47 | + | 
|  | 48 | +func testScheduleUpdatePullMerge(t *testing.T) { | 
|  | 49 | +	newBranchName := "feat1" | 
|  | 50 | +	workflowTreePath := ".gitea/workflows/actions-schedule.yml" | 
|  | 51 | +	workflowContent := `name: actions-schedule | 
|  | 52 | +on: | 
|  | 53 | +  schedule: | 
|  | 54 | +    - cron:  '@every 2m' # update to 2m | 
|  | 55 | +jobs: | 
|  | 56 | +  job: | 
|  | 57 | +    runs-on: ubuntu-latest | 
|  | 58 | +    steps: | 
|  | 59 | +      - run: echo 'schedule workflow' | 
|  | 60 | +` | 
|  | 61 | + | 
|  | 62 | +	mergeStyles := []repo_model.MergeStyle{ | 
|  | 63 | +		repo_model.MergeStyleMerge, | 
|  | 64 | +		repo_model.MergeStyleRebase, | 
|  | 65 | +		repo_model.MergeStyleRebaseMerge, | 
|  | 66 | +		repo_model.MergeStyleSquash, | 
|  | 67 | +		repo_model.MergeStyleFastForwardOnly, | 
|  | 68 | +	} | 
|  | 69 | + | 
|  | 70 | +	for _, mergeStyle := range mergeStyles { | 
|  | 71 | +		t.Run(string(mergeStyle), func(t *testing.T) { | 
|  | 72 | +			doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) { | 
|  | 73 | +				// update workflow file | 
|  | 74 | +				_, err := files_service.ChangeRepoFiles(t.Context(), repo, user, &files_service.ChangeRepoFilesOptions{ | 
|  | 75 | +					NewBranch: newBranchName, | 
|  | 76 | +					Files: []*files_service.ChangeRepoFile{ | 
|  | 77 | +						{ | 
|  | 78 | +							Operation:     "update", | 
|  | 79 | +							TreePath:      workflowTreePath, | 
|  | 80 | +							ContentReader: strings.NewReader(workflowContent), | 
|  | 81 | +						}, | 
|  | 82 | +					}, | 
|  | 83 | +					Message: "update workflow schedule", | 
|  | 84 | +				}) | 
|  | 85 | +				assert.NoError(t, err) | 
|  | 86 | + | 
|  | 87 | +				// create pull request | 
|  | 88 | +				apiPull, err := doAPICreatePullRequest(testContext, repo.OwnerName, repo.Name, repo.DefaultBranch, newBranchName)(t) | 
|  | 89 | +				assert.NoError(t, err) | 
|  | 90 | + | 
|  | 91 | +				// merge pull request | 
|  | 92 | +				testPullMerge(t, testContext.Session, repo.OwnerName, repo.Name, strconv.FormatInt(apiPull.Index, 10), mergeStyle, false) | 
|  | 93 | + | 
|  | 94 | +				pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID}) | 
|  | 95 | +				return pull.MergedCommitID, "@every 2m" | 
|  | 96 | +			}) | 
|  | 97 | +		}) | 
|  | 98 | +	} | 
|  | 99 | + | 
|  | 100 | +	t.Run(string(repo_model.MergeStyleManuallyMerged), func(t *testing.T) { | 
|  | 101 | +		doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) { | 
|  | 102 | +			// enable manual-merge | 
|  | 103 | +			doAPIEditRepository(testContext, &api.EditRepoOption{ | 
|  | 104 | +				HasPullRequests:  util.ToPointer(true), | 
|  | 105 | +				AllowManualMerge: util.ToPointer(true), | 
|  | 106 | +			})(t) | 
|  | 107 | + | 
|  | 108 | +			// update workflow file | 
|  | 109 | +			fileResp, err := files_service.ChangeRepoFiles(t.Context(), repo, user, &files_service.ChangeRepoFilesOptions{ | 
|  | 110 | +				NewBranch: newBranchName, | 
|  | 111 | +				Files: []*files_service.ChangeRepoFile{ | 
|  | 112 | +					{ | 
|  | 113 | +						Operation:     "update", | 
|  | 114 | +						TreePath:      workflowTreePath, | 
|  | 115 | +						ContentReader: strings.NewReader(workflowContent), | 
|  | 116 | +					}, | 
|  | 117 | +				}, | 
|  | 118 | +				Message: "update workflow schedule", | 
|  | 119 | +			}) | 
|  | 120 | +			assert.NoError(t, err) | 
|  | 121 | + | 
|  | 122 | +			// merge and push | 
|  | 123 | +			dstPath := t.TempDir() | 
|  | 124 | +			u.Path = repo.FullName() + ".git" | 
|  | 125 | +			u.User = url.UserPassword(repo.OwnerName, userPassword) | 
|  | 126 | +			doGitClone(dstPath, u)(t) | 
|  | 127 | +			doGitMerge(dstPath, "origin/"+newBranchName)(t) | 
|  | 128 | +			doGitPushTestRepository(dstPath, "origin", repo.DefaultBranch)(t) | 
|  | 129 | + | 
|  | 130 | +			// create pull request | 
|  | 131 | +			apiPull, err := doAPICreatePullRequest(testContext, repo.OwnerName, repo.Name, repo.DefaultBranch, newBranchName)(t) | 
|  | 132 | +			assert.NoError(t, err) | 
|  | 133 | + | 
|  | 134 | +			// merge pull request manually | 
|  | 135 | +			doAPIManuallyMergePullRequest(testContext, repo.OwnerName, repo.Name, fileResp.Commit.SHA, apiPull.Index)(t) | 
|  | 136 | + | 
|  | 137 | +			pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID}) | 
|  | 138 | +			assert.Equal(t, issues_model.PullRequestStatusManuallyMerged, pull.Status) | 
|  | 139 | +			return pull.MergedCommitID, "@every 2m" | 
|  | 140 | +		}) | 
|  | 141 | +	}) | 
|  | 142 | +} | 
|  | 143 | + | 
|  | 144 | +func testScheduleUpdateMirrorSync(t *testing.T) { | 
|  | 145 | +	doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) { | 
|  | 146 | +		// create mirror repo | 
|  | 147 | +		opts := migration.MigrateOptions{ | 
|  | 148 | +			RepoName:    "actions-schedule-mirror", | 
|  | 149 | +			Description: "Test mirror for actions-schedule", | 
|  | 150 | +			Private:     false, | 
|  | 151 | +			Mirror:      true, | 
|  | 152 | +			CloneAddr:   repo.CloneLinkGeneral(t.Context()).HTTPS, | 
|  | 153 | +		} | 
|  | 154 | +		mirrorRepo, err := repo_service.CreateRepositoryDirectly(t.Context(), user, user, repo_service.CreateRepoOptions{ | 
|  | 155 | +			Name:          opts.RepoName, | 
|  | 156 | +			Description:   opts.Description, | 
|  | 157 | +			IsPrivate:     opts.Private, | 
|  | 158 | +			IsMirror:      opts.Mirror, | 
|  | 159 | +			DefaultBranch: repo.DefaultBranch, | 
|  | 160 | +			Status:        repo_model.RepositoryBeingMigrated, | 
|  | 161 | +		}, false) | 
|  | 162 | +		assert.NoError(t, err) | 
|  | 163 | +		assert.True(t, mirrorRepo.IsMirror) | 
|  | 164 | +		mirrorRepo, err = repo_service.MigrateRepositoryGitData(t.Context(), user, mirrorRepo, opts, nil) | 
|  | 165 | +		assert.NoError(t, err) | 
|  | 166 | +		mirrorContext := NewAPITestContext(t, user.Name, mirrorRepo.Name, auth_model.AccessTokenScopeWriteRepository) | 
|  | 167 | + | 
|  | 168 | +		// enable actions unit for mirror repo | 
|  | 169 | +		assert.False(t, mirrorRepo.UnitEnabled(t.Context(), unit_model.TypeActions)) | 
|  | 170 | +		doAPIEditRepository(mirrorContext, &api.EditRepoOption{ | 
|  | 171 | +			HasActions: util.ToPointer(true), | 
|  | 172 | +		})(t) | 
|  | 173 | +		actionSchedule := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionSchedule{RepoID: mirrorRepo.ID}) | 
|  | 174 | +		scheduleSpec := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScheduleSpec{RepoID: mirrorRepo.ID, ScheduleID: actionSchedule.ID}) | 
|  | 175 | +		assert.Equal(t, "@every 1m", scheduleSpec.Spec) | 
|  | 176 | + | 
|  | 177 | +		// update remote repo | 
|  | 178 | +		newCron := "30 5,17 * * 2,4" | 
|  | 179 | +		pushScheduleChange(t, u, repo, newCron) | 
|  | 180 | +		repoDefaultBranch, err := git_model.GetBranch(t.Context(), repo.ID, repo.DefaultBranch) | 
|  | 181 | +		assert.NoError(t, err) | 
|  | 182 | + | 
|  | 183 | +		// sync | 
|  | 184 | +		ok := mirror_service.SyncPullMirror(t.Context(), mirrorRepo.ID) | 
|  | 185 | +		assert.True(t, ok) | 
|  | 186 | +		mirrorRepoDefaultBranch, err := git_model.GetBranch(t.Context(), mirrorRepo.ID, mirrorRepo.DefaultBranch) | 
|  | 187 | +		assert.NoError(t, err) | 
|  | 188 | +		assert.Equal(t, repoDefaultBranch.CommitID, mirrorRepoDefaultBranch.CommitID) | 
|  | 189 | + | 
|  | 190 | +		// check updated schedule | 
|  | 191 | +		actionSchedule = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionSchedule{RepoID: mirrorRepo.ID}) | 
|  | 192 | +		scheduleSpec = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScheduleSpec{RepoID: mirrorRepo.ID, ScheduleID: actionSchedule.ID}) | 
|  | 193 | +		assert.Equal(t, newCron, scheduleSpec.Spec) | 
|  | 194 | + | 
|  | 195 | +		return repoDefaultBranch.CommitID, newCron | 
|  | 196 | +	}) | 
|  | 197 | +} | 
|  | 198 | + | 
|  | 199 | +func testScheduleUpdateArchiveAndUnarchive(t *testing.T) { | 
|  | 200 | +	doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) { | 
|  | 201 | +		doAPIEditRepository(testContext, &api.EditRepoOption{ | 
|  | 202 | +			Archived: util.ToPointer(true), | 
|  | 203 | +		})(t) | 
|  | 204 | +		assert.Zero(t, unittest.GetCount(t, &actions_model.ActionSchedule{RepoID: repo.ID})) | 
|  | 205 | +		doAPIEditRepository(testContext, &api.EditRepoOption{ | 
|  | 206 | +			Archived: util.ToPointer(false), | 
|  | 207 | +		})(t) | 
|  | 208 | +		branch, err := git_model.GetBranch(t.Context(), repo.ID, repo.DefaultBranch) | 
|  | 209 | +		assert.NoError(t, err) | 
|  | 210 | +		return branch.CommitID, "@every 1m" | 
|  | 211 | +	}) | 
|  | 212 | +} | 
|  | 213 | + | 
|  | 214 | +func testScheduleUpdateDisableAndEnableActionsUnit(t *testing.T) { | 
|  | 215 | +	doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) { | 
|  | 216 | +		doAPIEditRepository(testContext, &api.EditRepoOption{ | 
|  | 217 | +			HasActions: util.ToPointer(false), | 
|  | 218 | +		})(t) | 
|  | 219 | +		assert.Zero(t, unittest.GetCount(t, &actions_model.ActionSchedule{RepoID: repo.ID})) | 
|  | 220 | +		doAPIEditRepository(testContext, &api.EditRepoOption{ | 
|  | 221 | +			HasActions: util.ToPointer(true), | 
|  | 222 | +		})(t) | 
|  | 223 | +		branch, err := git_model.GetBranch(t.Context(), repo.ID, repo.DefaultBranch) | 
|  | 224 | +		assert.NoError(t, err) | 
|  | 225 | +		return branch.CommitID, "@every 1m" | 
|  | 226 | +	}) | 
|  | 227 | +} | 
|  | 228 | + | 
|  | 229 | +type scheduleUpdateTrigger func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) | 
|  | 230 | + | 
|  | 231 | +func doTestScheduleUpdate(t *testing.T, updateTrigger scheduleUpdateTrigger) { | 
|  | 232 | +	onGiteaRun(t, func(t *testing.T, u *url.URL) { | 
|  | 233 | +		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | 
|  | 234 | +		session := loginUser(t, user2.Name) | 
|  | 235 | +		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) | 
|  | 236 | + | 
|  | 237 | +		apiRepo := createActionsTestRepo(t, token, "actions-schedule", false) | 
|  | 238 | +		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) | 
|  | 239 | +		assert.NoError(t, repo.LoadAttributes(t.Context())) | 
|  | 240 | +		httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository) | 
|  | 241 | +		defer doAPIDeleteRepository(httpContext)(t) | 
|  | 242 | + | 
|  | 243 | +		wfTreePath := ".gitea/workflows/actions-schedule.yml" | 
|  | 244 | +		wfFileContent := `name: actions-schedule | 
|  | 245 | +on: | 
|  | 246 | +  schedule: | 
|  | 247 | +    - cron:  '@every 1m' | 
|  | 248 | +jobs: | 
|  | 249 | +  job: | 
|  | 250 | +    runs-on: ubuntu-latest | 
|  | 251 | +    steps: | 
|  | 252 | +      - run: echo 'schedule workflow' | 
|  | 253 | +` | 
|  | 254 | + | 
|  | 255 | +		opts1 := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent) | 
|  | 256 | +		apiFileResp := createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts1) | 
|  | 257 | + | 
|  | 258 | +		actionSchedule := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionSchedule{RepoID: repo.ID, CommitSHA: apiFileResp.Commit.SHA}) | 
|  | 259 | +		scheduleSpec := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScheduleSpec{RepoID: repo.ID, ScheduleID: actionSchedule.ID}) | 
|  | 260 | +		assert.Equal(t, "@every 1m", scheduleSpec.Spec) | 
|  | 261 | + | 
|  | 262 | +		commitID, expectedSpec := updateTrigger(t, u, httpContext, user2, repo) | 
|  | 263 | + | 
|  | 264 | +		actionSchedule = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionSchedule{RepoID: repo.ID, CommitSHA: commitID}) | 
|  | 265 | +		scheduleSpec = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScheduleSpec{RepoID: repo.ID, ScheduleID: actionSchedule.ID}) | 
|  | 266 | +		assert.Equal(t, expectedSpec, scheduleSpec.Spec) | 
|  | 267 | +	}) | 
|  | 268 | +} | 
|  | 269 | + | 
|  | 270 | +func pushScheduleChange(t *testing.T, u *url.URL, repo *repo_model.Repository, newCron string) { | 
|  | 271 | +	workflowTreePath := ".gitea/workflows/actions-schedule.yml" | 
|  | 272 | +	workflowContent := `name: actions-schedule | 
|  | 273 | +on: | 
|  | 274 | +  schedule: | 
|  | 275 | +    - cron:  '` + newCron + `' | 
|  | 276 | +jobs: | 
|  | 277 | +  job: | 
|  | 278 | +    runs-on: ubuntu-latest | 
|  | 279 | +    steps: | 
|  | 280 | +      - run: echo 'schedule workflow' | 
|  | 281 | +` | 
|  | 282 | + | 
|  | 283 | +	dstPath := t.TempDir() | 
|  | 284 | +	u.Path = repo.FullName() + ".git" | 
|  | 285 | +	u.User = url.UserPassword(repo.OwnerName, userPassword) | 
|  | 286 | +	doGitClone(dstPath, u)(t) | 
|  | 287 | +	doGitCheckoutWriteFileCommit(localGitAddCommitOptions{ | 
|  | 288 | +		LocalRepoPath:   dstPath, | 
|  | 289 | +		CheckoutBranch:  repo.DefaultBranch, | 
|  | 290 | +		TreeFilePath:    workflowTreePath, | 
|  | 291 | +		TreeFileContent: workflowContent, | 
|  | 292 | +	})(t) | 
|  | 293 | +	doGitPushTestRepository(dstPath, "origin", repo.DefaultBranch)(t) | 
|  | 294 | +} | 
0 commit comments