mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Support actions and reusable workflows from private repos (#32562)
Resolve https://gitea.com/gitea/act_runner/issues/102 This PR allows administrators of a private repository to specify some collaborative owners. The repositories of collaborative owners will be allowed to access this repository's actions and workflows. Settings for private repos:  --- This PR also moves "Enable Actions" setting to `Actions > General` page <img width="960" alt="image" src="https://github.com/user-attachments/assets/49337ec2-afb1-4a67-8516-5c9ef0ce05d4" /> <img width="960" alt="image" src="https://github.com/user-attachments/assets/f58ee6d5-17f9-4180-8760-a78e859f1c37" /> --------- Signed-off-by: Zettat123 <zettat123@gmail.com> Co-authored-by: ChristopherHX <christopher.homberger@web.de>
This commit is contained in:
		| @@ -139,3 +139,23 @@ | ||||
|   updated: 1683636626 | ||||
|   need_approval: 0 | ||||
|   approved_by: 0 | ||||
| - | ||||
|   id: 804 | ||||
|   title: "use a private action" | ||||
|   repo_id: 60 | ||||
|   owner_id: 40 | ||||
|   workflow_id: "run.yaml" | ||||
|   index: 189 | ||||
|   trigger_user_id: 40 | ||||
|   ref: "refs/heads/master" | ||||
|   commit_sha: "6e64b26de7ba966d01d90ecfaf5c7f14ef203e86" | ||||
|   event: "push" | ||||
|   trigger_event: "push" | ||||
|   is_fork_pull_request: 0 | ||||
|   status: 1 | ||||
|   started: 1683636528 | ||||
|   stopped: 1683636626 | ||||
|   created: 1683636108 | ||||
|   updated: 1683636626 | ||||
|   need_approval: 0 | ||||
|   approved_by: 0 | ||||
|   | ||||
| @@ -129,3 +129,17 @@ | ||||
|   status: 5 | ||||
|   started: 1683636528 | ||||
|   stopped: 1683636626 | ||||
| - | ||||
|   id: 205 | ||||
|   run_id: 804 | ||||
|   repo_id: 6 | ||||
|   owner_id: 10 | ||||
|   commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86 | ||||
|   is_fork_pull_request: 0 | ||||
|   name: job_2 | ||||
|   attempt: 1 | ||||
|   job_id: job_2 | ||||
|   task_id: 48 | ||||
|   status: 1 | ||||
|   started: 1683636528 | ||||
|   stopped: 1683636626 | ||||
|   | ||||
| @@ -177,3 +177,23 @@ | ||||
|   log_length: 0 | ||||
|   log_size: 0 | ||||
|   log_expired: 0 | ||||
| - | ||||
|   id: 55 | ||||
|   job_id: 205 | ||||
|   attempt: 1 | ||||
|   runner_id: 1 | ||||
|   status: 6 # 6 is the status code for "running" | ||||
|   started: 1683636528 | ||||
|   stopped: 1683636626 | ||||
|   repo_id: 6 | ||||
|   owner_id: 10 | ||||
|   commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86 | ||||
|   is_fork_pull_request: 0 | ||||
|   token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc478422b | ||||
|   token_salt: ERxJGHvg3I | ||||
|   token_last_eight: 182199eb | ||||
|   log_filename: collaborative-owner-test/1a/49.log | ||||
|   log_in_storage: 1 | ||||
|   log_length: 707 | ||||
|   log_size: 90179 | ||||
|   log_expired: 0 | ||||
|   | ||||
| @@ -733,3 +733,10 @@ | ||||
|   type: 3 | ||||
|   config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" | ||||
|   created_unix: 946684810 | ||||
|  | ||||
| - | ||||
|   id: 111 | ||||
|   repo_id: 3 | ||||
|   type: 10 | ||||
|   config: "{}" | ||||
|   created_unix: 946684810 | ||||
|   | ||||
| @@ -264,13 +264,22 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito | ||||
| 	if err != nil { | ||||
| 		return perm, err | ||||
| 	} | ||||
| 	if task.RepoID != repo.ID { | ||||
| 		// FIXME allow public repo read access if tokenless pull is enabled | ||||
| 		return perm, nil | ||||
| 	} | ||||
|  | ||||
| 	var accessMode perm_model.AccessMode | ||||
| 	if task.IsForkPullRequest { | ||||
| 	if task.RepoID != repo.ID { | ||||
| 		taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID) | ||||
| 		if err != nil || !exist { | ||||
| 			return perm, err | ||||
| 		} | ||||
| 		actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() | ||||
| 		if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate { | ||||
| 			// The task repo can access the current repo only if the task repo is private and | ||||
| 			// the owner of the task repo is a collaborative owner of the current repo. | ||||
| 			// FIXME allow public repo read access if tokenless pull is enabled | ||||
| 			return perm, nil | ||||
| 		} | ||||
| 		accessMode = perm_model.AccessModeRead | ||||
| 	} else if task.IsForkPullRequest { | ||||
| 		accessMode = perm_model.AccessModeRead | ||||
| 	} else { | ||||
| 		accessMode = perm_model.AccessModeWrite | ||||
|   | ||||
| @@ -170,6 +170,9 @@ func (cfg *PullRequestsConfig) GetDefaultMergeStyle() MergeStyle { | ||||
|  | ||||
| type ActionsConfig struct { | ||||
| 	DisabledWorkflows []string | ||||
| 	// CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos. | ||||
| 	// Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions. | ||||
| 	CollaborativeOwnerIDs []int64 | ||||
| } | ||||
|  | ||||
| func (cfg *ActionsConfig) EnableWorkflow(file string) { | ||||
| @@ -192,6 +195,20 @@ func (cfg *ActionsConfig) DisableWorkflow(file string) { | ||||
| 	cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file) | ||||
| } | ||||
|  | ||||
| func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) { | ||||
| 	if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) { | ||||
| 		cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) { | ||||
| 	cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID) | ||||
| } | ||||
|  | ||||
| func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool { | ||||
| 	return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) | ||||
| } | ||||
|  | ||||
| // FromDB fills up a ActionsConfig from serialized format. | ||||
| func (cfg *ActionsConfig) FromDB(bs []byte) error { | ||||
| 	return json.UnmarshalHandleDoubleEncode(bs, &cfg) | ||||
|   | ||||
| @@ -6,6 +6,7 @@ package user | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"slices" | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| @@ -22,7 +23,7 @@ type SearchUserOptions struct { | ||||
| 	db.ListOptions | ||||
|  | ||||
| 	Keyword       string | ||||
| 	Type          UserType | ||||
| 	Types         []UserType | ||||
| 	UID           int64 | ||||
| 	LoginName     string // this option should be used only for admin user | ||||
| 	SourceID      int64  // this option should be used only for admin user | ||||
| @@ -43,16 +44,16 @@ type SearchUserOptions struct { | ||||
|  | ||||
| func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { | ||||
| 	var cond builder.Cond | ||||
| 	cond = builder.Eq{"type": opts.Type} | ||||
| 	cond = builder.In("type", opts.Types) | ||||
| 	if opts.IncludeReserved { | ||||
| 		switch opts.Type { | ||||
| 		case UserTypeIndividual: | ||||
| 		switch { | ||||
| 		case slices.Contains(opts.Types, UserTypeIndividual): | ||||
| 			cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( | ||||
| 				builder.Eq{"type": UserTypeBot}, | ||||
| 			).Or( | ||||
| 				builder.Eq{"type": UserTypeRemoteUser}, | ||||
| 			) | ||||
| 		case UserTypeOrganization: | ||||
| 		case slices.Contains(opts.Types, UserTypeOrganization): | ||||
| 			cond = cond.Or(builder.Eq{"type": UserTypeOrganizationReserved}) | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -1449,3 +1449,15 @@ func DisabledFeaturesWithLoginType(user *User) *container.Set[string] { | ||||
| 	} | ||||
| 	return &setting.Admin.UserDisabledFeatures | ||||
| } | ||||
|  | ||||
| // GetUserOrOrgIDByName returns the id for a user or an org by name | ||||
| func GetUserOrOrgIDByName(ctx context.Context, name string) (int64, error) { | ||||
| 	var id int64 | ||||
| 	has, err := db.GetEngine(ctx).Table("user").Where("name = ?", name).Cols("id").Get(&id) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} else if !has { | ||||
| 		return 0, fmt.Errorf("user or org with name %s: %w", name, util.ErrNotExist) | ||||
| 	} | ||||
| 	return id, nil | ||||
| } | ||||
|   | ||||
| @@ -126,7 +126,7 @@ func TestSearchUsers(t *testing.T) { | ||||
|  | ||||
| 	// test orgs | ||||
| 	testOrgSuccess := func(opts user_model.SearchUserOptions, expectedOrgIDs []int64) { | ||||
| 		opts.Type = user_model.UserTypeOrganization | ||||
| 		opts.Types = []user_model.UserType{user_model.UserTypeOrganization} | ||||
| 		testSuccess(opts, expectedOrgIDs) | ||||
| 	} | ||||
|  | ||||
| @@ -150,7 +150,7 @@ func TestSearchUsers(t *testing.T) { | ||||
|  | ||||
| 	// test users | ||||
| 	testUserSuccess := func(opts user_model.SearchUserOptions, expectedUserIDs []int64) { | ||||
| 		opts.Type = user_model.UserTypeIndividual | ||||
| 		opts.Types = []user_model.UserType{user_model.UserTypeIndividual} | ||||
| 		testSuccess(opts, expectedUserIDs) | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -3914,6 +3914,15 @@ variables.update.success = The variable has been edited. | ||||
| logs.always_auto_scroll = Always auto scroll logs | ||||
| logs.always_expand_running = Always expand running logs | ||||
|  | ||||
| general = General | ||||
| general.enable_actions = Enable Actions | ||||
| general.collaborative_owners_management = Collaborative Owners Management | ||||
| general.collaborative_owners_management_help = A collaborative owner is a user or an organization whose private repository has access to the actions and workflows of this repository. | ||||
| general.add_collaborative_owner = Add Collaborative Owner | ||||
| general.collaborative_owner_not_exist = The collaborative owner does not exist. | ||||
| general.remove_collaborative_owner = Remove Collaborative Owner | ||||
| general.remove_collaborative_owner_desc = Removing a collaborative owner will prevent the repositories of the owner from accessing the actions in this repository. Continue? | ||||
|  | ||||
| [projects] | ||||
| deleted.display_name = Deleted Project | ||||
| type-1.display_name = Individual Project | ||||
|   | ||||
| @@ -103,7 +103,7 @@ func GetAllOrgs(ctx *context.APIContext) { | ||||
|  | ||||
| 	users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ | ||||
| 		Actor:       ctx.Doer, | ||||
| 		Type:        user_model.UserTypeOrganization, | ||||
| 		Types:       []user_model.UserType{user_model.UserTypeOrganization}, | ||||
| 		OrderBy:     db.SearchOrderByAlphabetically, | ||||
| 		ListOptions: listOptions, | ||||
| 		Visible:     []api.VisibleType{api.VisibleTypePublic, api.VisibleTypeLimited, api.VisibleTypePrivate}, | ||||
|   | ||||
| @@ -425,7 +425,7 @@ func SearchUsers(ctx *context.APIContext) { | ||||
|  | ||||
| 	users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ | ||||
| 		Actor:       ctx.Doer, | ||||
| 		Type:        user_model.UserTypeIndividual, | ||||
| 		Types:       []user_model.UserType{user_model.UserTypeIndividual}, | ||||
| 		LoginName:   ctx.FormTrim("login_name"), | ||||
| 		SourceID:    ctx.FormInt64("source_id"), | ||||
| 		OrderBy:     db.SearchOrderByAlphabetically, | ||||
|   | ||||
| @@ -202,7 +202,7 @@ func GetAll(ctx *context.APIContext) { | ||||
| 	publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ | ||||
| 		Actor:       ctx.Doer, | ||||
| 		ListOptions: listOptions, | ||||
| 		Type:        user_model.UserTypeOrganization, | ||||
| 		Types:       []user_model.UserType{user_model.UserTypeOrganization}, | ||||
| 		OrderBy:     db.SearchOrderByAlphabetically, | ||||
| 		Visible:     vMode, | ||||
| 	}) | ||||
|   | ||||
| @@ -77,7 +77,7 @@ func Search(ctx *context.APIContext) { | ||||
| 			Actor:         ctx.Doer, | ||||
| 			Keyword:       ctx.FormTrim("q"), | ||||
| 			UID:           uid, | ||||
| 			Type:          user_model.UserTypeIndividual, | ||||
| 			Types:         []user_model.UserType{user_model.UserTypeIndividual}, | ||||
| 			SearchByEmail: true, | ||||
| 			Visible:       visible, | ||||
| 			ListOptions:   listOptions, | ||||
|   | ||||
| @@ -29,7 +29,7 @@ func Organizations(ctx *context.Context) { | ||||
|  | ||||
| 	explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ | ||||
| 		Actor:           ctx.Doer, | ||||
| 		Type:            user_model.UserTypeOrganization, | ||||
| 		Types:           []user_model.UserType{user_model.UserTypeOrganization}, | ||||
| 		IncludeReserved: true, // administrator needs to list all accounts include reserved | ||||
| 		ListOptions: db.ListOptions{ | ||||
| 			PageSize: setting.UI.Admin.OrgPagingNum, | ||||
|   | ||||
| @@ -67,7 +67,7 @@ func Users(ctx *context.Context) { | ||||
|  | ||||
| 	explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ | ||||
| 		Actor: ctx.Doer, | ||||
| 		Type:  user_model.UserTypeIndividual, | ||||
| 		Types: []user_model.UserType{user_model.UserTypeIndividual}, | ||||
| 		ListOptions: db.ListOptions{ | ||||
| 			PageSize: setting.UI.Admin.UserPagingNum, | ||||
| 		}, | ||||
|   | ||||
| @@ -46,7 +46,7 @@ func Organizations(ctx *context.Context) { | ||||
|  | ||||
| 	RenderUserSearch(ctx, user_model.SearchUserOptions{ | ||||
| 		Actor:       ctx.Doer, | ||||
| 		Type:        user_model.UserTypeOrganization, | ||||
| 		Types:       []user_model.UserType{user_model.UserTypeOrganization}, | ||||
| 		ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, | ||||
| 		Visible:     visibleTypes, | ||||
|  | ||||
|   | ||||
| @@ -153,7 +153,7 @@ func Users(ctx *context.Context) { | ||||
|  | ||||
| 	RenderUserSearch(ctx, user_model.SearchUserOptions{ | ||||
| 		Actor:       ctx.Doer, | ||||
| 		Type:        user_model.UserTypeIndividual, | ||||
| 		Types:       []user_model.UserType{user_model.UserTypeIndividual}, | ||||
| 		ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, | ||||
| 		IsActive:    optional.Some(true), | ||||
| 		Visible:     []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, | ||||
|   | ||||
| @@ -69,7 +69,7 @@ func HomeSitemap(ctx *context.Context) { | ||||
| 	m := sitemap.NewSitemapIndex() | ||||
| 	if !setting.Service.Explore.DisableUsersPage { | ||||
| 		_, cnt, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ | ||||
| 			Type:        user_model.UserTypeIndividual, | ||||
| 			Types:       []user_model.UserType{user_model.UserTypeIndividual}, | ||||
| 			ListOptions: db.ListOptions{PageSize: 1}, | ||||
| 			IsActive:    optional.Some(true), | ||||
| 			Visible:     []structs.VisibleType{structs.VisibleTypePublic}, | ||||
|   | ||||
| @@ -191,7 +191,7 @@ func httpBase(ctx *context.Context) *serviceHandler { | ||||
| 				taskID := ctx.Data["ActionsTaskID"].(int64) | ||||
| 				p, err := access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID) | ||||
| 				if err != nil { | ||||
| 					ctx.ServerError("GetUserRepoPermission", err) | ||||
| 					ctx.ServerError("GetActionsUserRepoPermission", err) | ||||
| 					return nil | ||||
| 				} | ||||
|  | ||||
|   | ||||
							
								
								
									
										121
									
								
								routers/web/repo/setting/actions.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								routers/web/repo/setting/actions.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| // Copyright 2025 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package setting | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
|  | ||||
| 	repo_model "code.gitea.io/gitea/models/repo" | ||||
| 	unit_model "code.gitea.io/gitea/models/unit" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/templates" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/services/context" | ||||
| 	repo_service "code.gitea.io/gitea/services/repository" | ||||
| ) | ||||
|  | ||||
| const tplRepoActionsGeneralSettings templates.TplName = "repo/settings/actions" | ||||
|  | ||||
| func ActionsGeneralSettings(ctx *context.Context) { | ||||
| 	ctx.Data["Title"] = ctx.Tr("actions.general") | ||||
| 	ctx.Data["PageType"] = "general" | ||||
| 	ctx.Data["PageIsActionsSettingsGeneral"] = true | ||||
|  | ||||
| 	actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions) | ||||
| 	if err != nil && !repo_model.IsErrUnitTypeNotExist(err) { | ||||
| 		ctx.ServerError("GetUnit", err) | ||||
| 		return | ||||
| 	} | ||||
| 	if actionsUnit == nil { // no actions unit | ||||
| 		ctx.HTML(http.StatusOK, tplRepoActionsGeneralSettings) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if ctx.Repo.Repository.IsPrivate { | ||||
| 		collaborativeOwnerIDs := actionsUnit.ActionsConfig().CollaborativeOwnerIDs | ||||
| 		collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs) | ||||
| 		if err != nil { | ||||
| 			ctx.ServerError("GetUsersByIDs", err) | ||||
| 			return | ||||
| 		} | ||||
| 		ctx.Data["CollaborativeOwners"] = collaborativeOwners | ||||
| 	} | ||||
|  | ||||
| 	ctx.HTML(http.StatusOK, tplRepoActionsGeneralSettings) | ||||
| } | ||||
|  | ||||
| func ActionsUnitPost(ctx *context.Context) { | ||||
| 	redirectURL := ctx.Repo.RepoLink + "/settings/actions/general" | ||||
| 	enableActionsUnit := ctx.FormBool("enable_actions") | ||||
| 	repo := ctx.Repo.Repository | ||||
|  | ||||
| 	var err error | ||||
| 	if enableActionsUnit && !unit_model.TypeActions.UnitGlobalDisabled() { | ||||
| 		err = repo_service.UpdateRepositoryUnits(ctx, repo, []repo_model.RepoUnit{newRepoUnit(repo, unit_model.TypeActions, nil)}, nil) | ||||
| 	} else if !unit_model.TypeActions.UnitGlobalDisabled() { | ||||
| 		err = repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit_model.Type{unit_model.TypeActions}) | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		ctx.ServerError("UpdateRepositoryUnits", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) | ||||
| 	ctx.Redirect(redirectURL) | ||||
| } | ||||
|  | ||||
| func AddCollaborativeOwner(ctx *context.Context) { | ||||
| 	name := strings.ToLower(ctx.FormString("collaborative_owner")) | ||||
|  | ||||
| 	ownerID, err := user_model.GetUserOrOrgIDByName(ctx, name) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, util.ErrNotExist) { | ||||
| 			ctx.Flash.Error(ctx.Tr("form.user_not_exist")) | ||||
| 			ctx.JSONErrorNotFound() | ||||
| 		} else { | ||||
| 			ctx.ServerError("GetUserOrOrgIDByName", err) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions) | ||||
| 	if err != nil { | ||||
| 		ctx.ServerError("GetUnit", err) | ||||
| 		return | ||||
| 	} | ||||
| 	actionsCfg := actionsUnit.ActionsConfig() | ||||
| 	actionsCfg.AddCollaborativeOwner(ownerID) | ||||
| 	if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { | ||||
| 		ctx.ServerError("UpdateRepoUnit", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ctx.JSONOK() | ||||
| } | ||||
|  | ||||
| func DeleteCollaborativeOwner(ctx *context.Context) { | ||||
| 	ownerID := ctx.FormInt64("id") | ||||
|  | ||||
| 	actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions) | ||||
| 	if err != nil { | ||||
| 		ctx.ServerError("GetUnit", err) | ||||
| 		return | ||||
| 	} | ||||
| 	actionsCfg := actionsUnit.ActionsConfig() | ||||
| 	if !actionsCfg.IsCollaborativeOwner(ownerID) { | ||||
| 		ctx.Flash.Error(ctx.Tr("actions.general.collaborative_owner_not_exist")) | ||||
| 		ctx.JSONErrorNotFound() | ||||
| 		return | ||||
| 	} | ||||
| 	actionsCfg.RemoveCollaborativeOwner(ownerID) | ||||
| 	if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { | ||||
| 		ctx.ServerError("UpdateRepoUnit", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ctx.JSONOK() | ||||
| } | ||||
| @@ -613,12 +613,6 @@ func handleSettingsPostAdvanced(ctx *context.Context) { | ||||
| 		deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages) | ||||
| 	} | ||||
|  | ||||
| 	if form.EnableActions && !unit_model.TypeActions.UnitGlobalDisabled() { | ||||
| 		units = append(units, newRepoUnit(repo, unit_model.TypeActions, nil)) | ||||
| 	} else if !unit_model.TypeActions.UnitGlobalDisabled() { | ||||
| 		deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions) | ||||
| 	} | ||||
|  | ||||
| 	if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() { | ||||
| 		units = append(units, newRepoUnit(repo, unit_model.TypePullRequests, &repo_model.PullRequestsConfig{ | ||||
| 			IgnoreWhitespaceConflicts:     form.PullsIgnoreWhitespace, | ||||
|   | ||||
| @@ -16,10 +16,14 @@ import ( | ||||
|  | ||||
| // SearchCandidates searches candidate users for dropdown list | ||||
| func SearchCandidates(ctx *context.Context) { | ||||
| 	searchUserTypes := []user_model.UserType{user_model.UserTypeIndividual} | ||||
| 	if ctx.FormBool("orgs") { | ||||
| 		searchUserTypes = append(searchUserTypes, user_model.UserTypeOrganization) | ||||
| 	} | ||||
| 	users, _, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ | ||||
| 		Actor:       ctx.Doer, | ||||
| 		Keyword:     ctx.FormTrim("q"), | ||||
| 		Type:        user_model.UserTypeIndividual, | ||||
| 		Types:       searchUserTypes, | ||||
| 		IsActive:    optional.Some(true), | ||||
| 		ListOptions: db.ListOptions{PageSize: setting.UI.MembersPagingNum}, | ||||
| 	}) | ||||
|   | ||||
| @@ -1159,11 +1159,21 @@ func registerWebRoutes(m *web.Router) { | ||||
| 				m.Post("/{lid}/unlock", repo_setting.LFSUnlock) | ||||
| 			}) | ||||
| 		}) | ||||
| 		m.Group("/actions/general", func() { | ||||
| 			m.Get("", repo_setting.ActionsGeneralSettings) | ||||
| 			m.Post("/actions_unit", repo_setting.ActionsUnitPost) | ||||
| 		}) | ||||
| 		m.Group("/actions", func() { | ||||
| 			m.Get("", shared_actions.RedirectToDefaultSetting) | ||||
| 			addSettingsRunnersRoutes() | ||||
| 			addSettingsSecretsRoutes() | ||||
| 			addSettingsVariablesRoutes() | ||||
| 			m.Group("/general", func() { | ||||
| 				m.Group("/collaborative_owner", func() { | ||||
| 					m.Post("/add", repo_setting.AddCollaborativeOwner) | ||||
| 					m.Post("/delete", repo_setting.DeleteCollaborativeOwner) | ||||
| 				}) | ||||
| 			}) | ||||
| 		}, actions.MustEnableActions) | ||||
| 		// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed | ||||
| 		m.Group("/migrate", func() { | ||||
|   | ||||
| @@ -6,6 +6,8 @@ | ||||
| 			{{template "shared/secrets/add_list" .}} | ||||
| 		{{else if eq .PageType "variables"}} | ||||
| 			{{template "shared/variables/variable_list" .}} | ||||
| 		{{else if eq .PageType "general"}} | ||||
| 			{{template "repo/settings/actions_general" .}} | ||||
| 		{{end}} | ||||
| 	</div> | ||||
| {{template "repo/settings/layout_footer" .}} | ||||
|   | ||||
							
								
								
									
										69
									
								
								templates/repo/settings/actions_general.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								templates/repo/settings/actions_general.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| <div class="repo-setting-content"> | ||||
| 	<h4 class="ui top attached header"> | ||||
| 		{{ctx.Locale.Tr "actions.general.enable_actions"}} | ||||
| 	</h4> | ||||
| 	<div class="ui attached segment"> | ||||
| 		<form class="ui form" action="{{.Link}}/actions_unit" method="post"> | ||||
| 			{{.CsrfTokenHtml}} | ||||
| 			{{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}} | ||||
| 			{{$isActionsGlobalDisabled := ctx.Consts.RepoUnitTypeActions.UnitGlobalDisabled}} | ||||
| 			<div class="inline field"> | ||||
| 				<label>{{ctx.Locale.Tr "actions.actions"}}</label> | ||||
| 				<div class="ui checkbox{{if $isActionsGlobalDisabled}} disabled{{end}}"{{if $isActionsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}> | ||||
| 					<input class="enable-system" name="enable_actions" type="checkbox" {{if $isActionsGlobalDisabled}}disabled{{end}} {{if $isActionsEnabled}}checked{{end}}> | ||||
| 					<label>{{ctx.Locale.Tr "repo.settings.actions_desc"}}</label> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			{{if not $isActionsGlobalDisabled}} | ||||
| 			<div class="divider"></div> | ||||
| 				<div class="field"> | ||||
| 				<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button> | ||||
| 			</div> | ||||
| 			{{end}} | ||||
| 		</form> | ||||
| 	</div> | ||||
|  | ||||
| 	{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}} | ||||
| 	{{if .Repository.IsPrivate}} | ||||
| 	<h4 class="ui top attached header"> | ||||
| 		{{ctx.Locale.Tr "actions.general.collaborative_owners_management"}} | ||||
| 	</h4> | ||||
| 	{{if len .CollaborativeOwners}} | ||||
| 	<div class="ui attached segment"> | ||||
| 		<div class="flex-list"> | ||||
| 			{{range .CollaborativeOwners}} | ||||
| 			<div class="flex-item tw-items-center"> | ||||
| 				<div class="flex-item-leading"> | ||||
| 					<a href="{{.HomeLink}}">{{ctx.AvatarUtils.Avatar . 32}}</a> | ||||
| 				</div> | ||||
| 				<div class="flex-item-main"> | ||||
| 					<div class="flex-item-title"> | ||||
| 						{{template "shared/user/name" .}} | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="flex-item-trailing"> | ||||
| 					<button class="ui red tiny button inline link-action" | ||||
| 						data-url="{{$.Link}}/collaborative_owner/delete?id={{.ID}}" | ||||
| 						data-modal-confirm-header="{{ctx.Locale.Tr "actions.general.remove_collaborative_owner"}}" | ||||
| 						data-modal-confirm-content="{{ctx.Locale.Tr "actions.general.remove_collaborative_owner_desc"}}" | ||||
| 					>{{ctx.Locale.Tr "remove"}}</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			{{end}} | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	{{end}} | ||||
| 	<div class="ui bottom attached segment"> | ||||
| 		<form class="ui form form-fetch-action" action="{{.Link}}/collaborative_owner/add" method="post"> | ||||
| 			{{.CsrfTokenHtml}} | ||||
| 			<div id="search-user-box" class="ui search input tw-align-middle" data-include-orgs="true"> | ||||
| 				<input class="prompt" name="collaborative_owner" placeholder="{{ctx.Locale.Tr "search.user_kind"}}" autocomplete="off" autofocus required> | ||||
| 			</div> | ||||
| 			<button class="ui primary button">{{ctx.Locale.Tr "actions.general.add_collaborative_owner"}}</button> | ||||
| 		</form> | ||||
| 		<br> | ||||
| 		{{ctx.Locale.Tr "actions.general.collaborative_owners_management_help"}} | ||||
| 	</div> | ||||
| 	{{end}} | ||||
| 	{{end}} | ||||
| </div> | ||||
| @@ -38,10 +38,13 @@ | ||||
| 				</a> | ||||
| 			{{end}} | ||||
| 		{{end}} | ||||
| 		{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}} | ||||
| 		<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}> | ||||
| 		<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsActionsSettingsGeneral}}open{{end}}> | ||||
| 			<summary>{{ctx.Locale.Tr "actions.actions"}}</summary> | ||||
| 			<div class="menu"> | ||||
| 				<a class="{{if .PageIsActionsSettingsGeneral}}active {{end}}item" href="{{.RepoLink}}/settings/actions/general"> | ||||
| 					{{ctx.Locale.Tr "actions.general"}} | ||||
| 				</a> | ||||
| 				{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}} | ||||
| 				<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.RepoLink}}/settings/actions/runners"> | ||||
| 					{{ctx.Locale.Tr "actions.runners"}} | ||||
| 				</a> | ||||
| @@ -51,8 +54,8 @@ | ||||
| 				<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.RepoLink}}/settings/actions/variables"> | ||||
| 					{{ctx.Locale.Tr "actions.variables"}} | ||||
| 				</a> | ||||
| 				{{end}} | ||||
| 			</div> | ||||
| 		</details> | ||||
| 		{{end}} | ||||
| 	</div> | ||||
| </div> | ||||
|   | ||||
| @@ -509,18 +509,6 @@ | ||||
| 					</div> | ||||
| 				</div> | ||||
|  | ||||
| 				{{if .EnableActions}} | ||||
| 					{{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}} | ||||
| 					{{$isActionsGlobalDisabled := ctx.Consts.RepoUnitTypeActions.UnitGlobalDisabled}} | ||||
| 					<div class="inline field"> | ||||
| 						<label>{{ctx.Locale.Tr "actions.actions"}}</label> | ||||
| 							<div class="ui checkbox{{if $isActionsGlobalDisabled}} disabled{{end}}"{{if $isActionsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}> | ||||
| 							<input class="enable-system" name="enable_actions" type="checkbox" {{if $isActionsEnabled}}checked{{end}}> | ||||
| 							<label>{{ctx.Locale.Tr "repo.settings.actions_desc"}}</label> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				{{end}} | ||||
|  | ||||
| 				{{if not .IsMirror}} | ||||
| 					<div class="divider"></div> | ||||
| 					{{$pullRequestEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePullRequests}} | ||||
|   | ||||
							
								
								
									
										62
									
								
								tests/integration/actions_settings_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								tests/integration/actions_settings_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| // Copyright 2025 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package integration | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"testing" | ||||
|  | ||||
| 	actions_model "code.gitea.io/gitea/models/actions" | ||||
| 	auth_model "code.gitea.io/gitea/models/auth" | ||||
| 	"code.gitea.io/gitea/models/unittest" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestActionsCollaborativeOwner(t *testing.T) { | ||||
| 	onGiteaRun(t, func(t *testing.T, u *url.URL) { | ||||
| 		// user2 is the owner of "reusable_workflow" repo | ||||
| 		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | ||||
| 		user2Session := loginUser(t, user2.Name) | ||||
| 		user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) | ||||
| 		repo := createActionsTestRepo(t, user2Token, "reusable_workflow", true) | ||||
|  | ||||
| 		// a private repo(id=6) of user10 will try to clone "reusable_workflow" repo | ||||
| 		user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) | ||||
| 		// task id is 55 and its repo_id=6 | ||||
| 		task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 55, RepoID: 6}) | ||||
| 		taskToken := "674f727a81ed2f195bccab036cccf86a182199eb" | ||||
| 		tokenHash := auth_model.HashToken(taskToken, task.TokenSalt) | ||||
| 		assert.Equal(t, task.TokenHash, tokenHash) | ||||
|  | ||||
| 		dstPath := t.TempDir() | ||||
| 		u.Path = fmt.Sprintf("%s/%s.git", repo.Owner.UserName, repo.Name) | ||||
| 		u.User = url.UserPassword("gitea-actions", taskToken) | ||||
|  | ||||
| 		// the git clone will fail | ||||
| 		doGitCloneFail(u)(t) | ||||
|  | ||||
| 		// add user10 to the list of collaborative owners | ||||
| 		req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", repo.Owner.UserName, repo.Name), map[string]string{ | ||||
| 			"_csrf":               GetUserCSRFToken(t, user2Session), | ||||
| 			"collaborative_owner": user10.Name, | ||||
| 		}) | ||||
| 		user2Session.MakeRequest(t, req, http.StatusOK) | ||||
|  | ||||
| 		// the git clone will be successful | ||||
| 		doGitClone(dstPath, u)(t) | ||||
|  | ||||
| 		// remove user10 from the list of collaborative owners | ||||
| 		req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/delete?id=%d", repo.Owner.UserName, repo.Name, user10.ID), map[string]string{ | ||||
| 			"_csrf": GetUserCSRFToken(t, user2Session), | ||||
| 		}) | ||||
| 		user2Session.MakeRequest(t, req, http.StatusOK) | ||||
|  | ||||
| 		// the git clone will fail | ||||
| 		doGitCloneFail(u)(t) | ||||
| 	}) | ||||
| } | ||||
| @@ -10,10 +10,11 @@ export function initCompSearchUserBox() { | ||||
|  | ||||
|   const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true'; | ||||
|   const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined; | ||||
|   const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true'; | ||||
|   fomanticQuery(searchUserBox).search({ | ||||
|     minCharacters: 2, | ||||
|     apiSettings: { | ||||
|       url: `${appSubUrl}/user/search_candidates?q={query}`, | ||||
|       url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`, | ||||
|       onResponse(response: any) { | ||||
|         const resultItems = []; | ||||
|         const searchQuery = searchUserBox.querySelector('input').value; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user