// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions import ( "context" "fmt" "gitea.dev/models/db" repo_model "gitea.dev/models/repo" "gitea.dev/modules/timeutil" "gitea.dev/modules/util" "xorm.io/builder" ) // ActionScopedWorkflowSource registers a repository as a source of scoped workflows, either for an owner (user/org) or for the whole instance. type ActionScopedWorkflowSource struct { ID int64 `xorm:"pk autoincr"` // OwnerID is the scope the source applies to: a user/org ID (applies to that owner's repos), or 0 for instance-level (applies to every repo). OwnerID int64 `xorm:"UNIQUE(owner_repo) NOT NULL DEFAULT 0"` // SourceRepoID is the source repository providing the workflow files; always non-zero. SourceRepoID int64 `xorm:"INDEX UNIQUE(owner_repo) NOT NULL DEFAULT 0"` // WorkflowConfigs maps a workflow ID (entry name) to its merge-gate config. WorkflowConfigs map[string]*ScopedWorkflowConfig `xorm:"JSON TEXT 'workflow_configs'"` CreatedUnix timeutil.TimeStamp `xorm:"created"` UpdatedUnix timeutil.TimeStamp `xorm:"updated"` } // ScopedWorkflowConfig is one scoped workflow's config within a source registration. type ScopedWorkflowConfig struct { Required bool `json:"required"` Patterns []string `json:"patterns"` // the status-check patterns that must be present and pass, only effective when Required is true } func init() { db.RegisterModel(new(ActionScopedWorkflowSource)) } // IsWorkflowRequired reports whether the given workflow ID (entry name) is marked required in this source. func (s *ActionScopedWorkflowSource) IsWorkflowRequired(workflowID string) bool { c, ok := s.WorkflowConfigs[workflowID] return ok && c.Required } type FindScopedWorkflowSourceOpts struct { db.ListOptions OwnerIDs []int64 SourceRepoID int64 } func (opts FindScopedWorkflowSourceOpts) ToConds() builder.Cond { cond := builder.NewCond() if len(opts.OwnerIDs) > 0 { cond = cond.And(builder.In("owner_id", opts.OwnerIDs)) } if opts.SourceRepoID != 0 { cond = cond.And(builder.Eq{"source_repo_id": opts.SourceRepoID}) } return cond } // GetEffectiveScopedWorkflowSources returns the scoped-workflow sources effective for a repo owned by repoOwnerID: // the owner's own sources plus instance-level (owner_id=0) sources. func GetEffectiveScopedWorkflowSources(ctx context.Context, repoOwnerID int64) ([]*ActionScopedWorkflowSource, error) { owners := []int64{0} if repoOwnerID != 0 { owners = append(owners, repoOwnerID) } return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners}) } // IsScopedWorkflowSourceEffective reports whether sourceRepoID is a scoped-workflow source effective for a repo owned by repoOwnerID. func IsScopedWorkflowSourceEffective(ctx context.Context, repoOwnerID, sourceRepoID int64) (bool, error) { owners := []int64{0} if repoOwnerID != 0 { owners = append(owners, repoOwnerID) } return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners, SourceRepoID: sourceRepoID}.ToConds()) } // IsWorkflowRequiredInSources reports whether workflowID from sourceRepoID is required by any of the given sources. func IsWorkflowRequiredInSources(sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool { for _, s := range sources { if s.SourceRepoID == sourceRepoID && s.IsWorkflowRequired(workflowID) { return true } } return false } // ScopedStatusContextPrefix returns the source-repo prefix that makes a scoped run's commit-status context distinct from same-named workflows. func ScopedStatusContextPrefix(ctx context.Context, sourceRepoID int64) string { if sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID); err == nil { return sourceRepo.FullName() } return fmt.Sprintf("scoped:%d", sourceRepoID) } // IsScopedWorkflowRequired reports whether workflowID from sourceRepoID is required for a repo owned by consumerOwnerID. func IsScopedWorkflowRequired(ctx context.Context, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) { sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID) if err != nil { return false, err } return IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID), nil } // IsScopedWorkflowOptedOutloads the consumer's effective sources then calls ScopedWorkflowOptedOut func IsScopedWorkflowOptedOut(ctx context.Context, cfg *repo_model.ActionsConfig, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) { if !cfg.IsScopedWorkflowDisabled(sourceRepoID, workflowID) { return false, nil } sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID) if err != nil { return false, err } return ScopedWorkflowOptedOut(cfg, sources, sourceRepoID, workflowID), nil } // ScopedWorkflowOptedOut reports whether a consumer's opt-out of (sourceRepoID, workflowID) is in effect. func ScopedWorkflowOptedOut(cfg *repo_model.ActionsConfig, sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool { return !IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID) && cfg.IsScopedWorkflowDisabled(sourceRepoID, workflowID) } // GetScopedWorkflowSourcesByOwner returns the sources an owner (user/org, or 0 for instance) registered. func GetScopedWorkflowSourcesByOwner(ctx context.Context, ownerID int64) ([]*ActionScopedWorkflowSource, error) { return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{ownerID}}) } // GetScopedWorkflowSource returns the (owner, repo) source registration or a NotExist error. func GetScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) (*ActionScopedWorkflowSource, error) { src := &ActionScopedWorkflowSource{} has, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Get(src) if err != nil { return nil, err } if !has { return nil, util.NewNotExistErrorf("scoped workflow source (owner %d, repo %d) does not exist", ownerID, repoID) } return src, nil } // AddScopedWorkflowSource registers repoID as a source for ownerID (no-op if already registered). func AddScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error { exists, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource)) if err != nil { return err } if exists { return nil } if err := db.Insert(ctx, &ActionScopedWorkflowSource{OwnerID: ownerID, SourceRepoID: repoID}); err != nil { // Re-check and treat an already-present row as the intended no-op. if exists, existErr := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource)); existErr == nil && exists { return nil } return err } return nil } // SetScopedWorkflowSourceConfigs replaces the per-workflow merge-gate configs (workflow ID -> config). func SetScopedWorkflowSourceConfigs(ctx context.Context, ownerID, repoID int64, configs map[string]*ScopedWorkflowConfig) error { _, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID). Cols("workflow_configs"). Update(&ActionScopedWorkflowSource{WorkflowConfigs: configs}) return err } // RemoveScopedWorkflowSource removes the (owner, repo) source registration. func RemoveScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error { _, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Delete(new(ActionScopedWorkflowSource)) return err }