mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-09 05:37:47 +02:00
Merge branch 'main' into fix/project-board-api-review-feedback
This commit is contained in:
commit
506236f217
@ -194,7 +194,7 @@ Gitea or set your environment appropriately.`, "")
|
||||
userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
|
||||
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
|
||||
deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64)
|
||||
actionPerm, _ := strconv.Atoi(os.Getenv(repo_module.EnvActionPerm))
|
||||
actionsTaskID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionsTaskID), 10, 64)
|
||||
|
||||
hookOptions := private.HookOptions{
|
||||
UserID: userID,
|
||||
@ -204,7 +204,7 @@ Gitea or set your environment appropriately.`, "")
|
||||
GitPushOptions: pushOptions(),
|
||||
PullRequestID: prID,
|
||||
DeployKeyID: deployKeyID,
|
||||
ActionPerm: actionPerm,
|
||||
ActionsTaskID: actionsTaskID,
|
||||
IsWiki: isWiki,
|
||||
}
|
||||
|
||||
|
||||
@ -170,10 +170,10 @@ type ActionArtifactMeta struct {
|
||||
}
|
||||
|
||||
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
|
||||
func ListUploadedArtifactsMeta(ctx context.Context, runID int64) ([]*ActionArtifactMeta, error) {
|
||||
func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*ActionArtifactMeta, error) {
|
||||
arts := make([]*ActionArtifactMeta, 0, 10)
|
||||
return arts, db.GetEngine(ctx).Table("action_artifact").
|
||||
Where("run_id=? AND (status=? OR status=?)", runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
||||
Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
||||
GroupBy("artifact_name").
|
||||
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
|
||||
Find(&arts)
|
||||
|
||||
74
models/actions/config.go
Normal file
74
models/actions/config.go
Normal file
@ -0,0 +1,74 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"xorm.io/xorm/convert"
|
||||
)
|
||||
|
||||
// OwnerActionsConfig defines the Actions configuration for a user or organization
|
||||
type OwnerActionsConfig struct {
|
||||
// TokenPermissionMode defines the default permission mode (permissive, restricted)
|
||||
TokenPermissionMode repo_model.ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"`
|
||||
|
||||
// MaxTokenPermissions defines the absolute maximum permissions any token can have in this context.
|
||||
MaxTokenPermissions *repo_model.ActionsTokenPermissions `json:"max_token_permissions,omitempty"`
|
||||
|
||||
// AllowedCrossRepoIDs is a list of specific repo IDs that can be accessed cross-repo
|
||||
AllowedCrossRepoIDs []int64 `json:"allowed_cross_repo_ids,omitempty"`
|
||||
}
|
||||
|
||||
var _ convert.ConversionFrom = (*OwnerActionsConfig)(nil)
|
||||
|
||||
func (cfg *OwnerActionsConfig) FromDB(bytes []byte) error {
|
||||
_ = json.Unmarshal(bytes, cfg)
|
||||
cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOwnerActionsConfig loads the OwnerActionsConfig for a user or organization from user settings
|
||||
// It returns a default config if no setting is found
|
||||
func GetOwnerActionsConfig(ctx context.Context, userID int64) (ret OwnerActionsConfig, err error) {
|
||||
return user_model.GetUserSettingJSON(ctx, userID, user_model.SettingsKeyActionsConfig, ret)
|
||||
}
|
||||
|
||||
// SetOwnerActionsConfig saves the OwnerActionsConfig for a user or organization to user settings
|
||||
func SetOwnerActionsConfig(ctx context.Context, userID int64, cfg OwnerActionsConfig) error {
|
||||
return user_model.SetUserSettingJSON(ctx, userID, user_model.SettingsKeyActionsConfig, cfg)
|
||||
}
|
||||
|
||||
// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode.
|
||||
func (cfg *OwnerActionsConfig) GetDefaultTokenPermissions() repo_model.ActionsTokenPermissions {
|
||||
switch cfg.TokenPermissionMode {
|
||||
case repo_model.ActionsTokenPermissionModeRestricted:
|
||||
return repo_model.MakeRestrictedPermissions()
|
||||
case repo_model.ActionsTokenPermissionModePermissive:
|
||||
return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
default:
|
||||
return repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)
|
||||
}
|
||||
}
|
||||
|
||||
// GetMaxTokenPermissions returns the maximum allowed permissions
|
||||
func (cfg *OwnerActionsConfig) GetMaxTokenPermissions() repo_model.ActionsTokenPermissions {
|
||||
if cfg.MaxTokenPermissions != nil {
|
||||
return *cfg.MaxTokenPermissions
|
||||
}
|
||||
// Default max is write for everything
|
||||
return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
}
|
||||
|
||||
// ClampPermissions ensures that the given permissions don't exceed the maximum
|
||||
func (cfg *OwnerActionsConfig) ClampPermissions(perms repo_model.ActionsTokenPermissions) repo_model.ActionsTokenPermissions {
|
||||
maxPerms := cfg.GetMaxTokenPermissions()
|
||||
return repo_model.ClampActionsTokenPermissions(perms, maxPerms)
|
||||
}
|
||||
@ -51,6 +51,11 @@ type ActionRunJob struct {
|
||||
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group
|
||||
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress
|
||||
|
||||
// TokenPermissions stores the explicit permissions from workflow/job YAML (no org/repo clamps applied).
|
||||
// Org/repo clamps are enforced when the token is used at runtime.
|
||||
// It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified.
|
||||
TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"`
|
||||
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
|
||||
60
models/actions/token_permissions.go
Normal file
60
models/actions/token_permissions.go
Normal file
@ -0,0 +1,60 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
)
|
||||
|
||||
// ComputeTaskTokenPermissions computes the effective permissions for a job token against the target repository.
|
||||
// It uses the job's stored permissions (if any), then applies org/repo clamps and fork/cross-repo restrictions.
|
||||
// Note: target repository access policy checks are enforced in GetActionsUserRepoPermission; this function only computes the job token's effective permission ceiling.
|
||||
func ComputeTaskTokenPermissions(ctx context.Context, task *ActionTask, targetRepo *repo_model.Repository) (ret repo_model.ActionsTokenPermissions, err error) {
|
||||
if err := task.LoadJob(ctx); err != nil {
|
||||
return ret, err
|
||||
}
|
||||
if err := task.Job.LoadRepo(ctx); err != nil {
|
||||
return ret, err
|
||||
}
|
||||
runRepo := task.Job.Repo
|
||||
|
||||
if err := runRepo.LoadOwner(ctx); err != nil {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
repoActionsCfg := runRepo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
|
||||
ownerActionsCfg, err := GetOwnerActionsConfig(ctx, runRepo.OwnerID)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
var jobDeclaredPerms repo_model.ActionsTokenPermissions
|
||||
if task.Job.TokenPermissions != nil {
|
||||
jobDeclaredPerms = *task.Job.TokenPermissions
|
||||
} else if repoActionsCfg.OverrideOwnerConfig {
|
||||
jobDeclaredPerms = repoActionsCfg.GetDefaultTokenPermissions()
|
||||
} else {
|
||||
jobDeclaredPerms = ownerActionsCfg.GetDefaultTokenPermissions()
|
||||
}
|
||||
|
||||
var effectivePerms repo_model.ActionsTokenPermissions
|
||||
if repoActionsCfg.OverrideOwnerConfig {
|
||||
effectivePerms = repoActionsCfg.ClampPermissions(jobDeclaredPerms)
|
||||
} else {
|
||||
effectivePerms = ownerActionsCfg.ClampPermissions(jobDeclaredPerms)
|
||||
}
|
||||
|
||||
// Cross-repository access and fork pull requests are strictly read-only for security.
|
||||
// This ensures a "task repo" cannot gain write access to other repositories via CrossRepoAccess settings.
|
||||
isSameRepo := task.Job.RepoID == targetRepo.ID
|
||||
restrictCrossRepoAccess := task.IsForkPullRequest || !isSameRepo
|
||||
if restrictCrossRepoAccess {
|
||||
effectivePerms = repo_model.ClampActionsTokenPermissions(effectivePerms, repo_model.MakeRestrictedPermissions())
|
||||
}
|
||||
|
||||
return effectivePerms, nil
|
||||
}
|
||||
@ -402,6 +402,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments),
|
||||
newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID),
|
||||
newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner),
|
||||
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
16
models/migrations/v1_26/v328.go
Normal file
16
models/migrations/v1_26/v328.go
Normal file
@ -0,0 +1,16 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddTokenPermissionsToActionRunJob(x *xorm.Engine) error {
|
||||
type ActionRunJob struct {
|
||||
TokenPermissions string `xorm:"JSON TEXT"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunJob))
|
||||
return err
|
||||
}
|
||||
155
models/perm/access/actions_repo_permission_test.go
Normal file
155
models/perm/access/actions_repo_permission_test.go
Normal file
@ -0,0 +1,155 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package access
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
perm_model "code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetActionsUserRepoPermission(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// Use fixtures for repos and users
|
||||
repo4 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) // Public, Owner 5, has Actions unit
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) // Private, Owner 2, no Actions unit in fixtures
|
||||
repo15 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15}) // Private, Owner 2, no Actions unit in fixtures
|
||||
owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
actionsUser := user_model.NewActionsUser()
|
||||
|
||||
// Ensure repo2 and repo15 have Actions units for testing configuration
|
||||
for _, r := range []*repo_model.Repository{repo2, repo15} {
|
||||
require.NoError(t, db.Insert(ctx, &repo_model.RepoUnit{
|
||||
RepoID: r.ID,
|
||||
Type: unit.TypeActions,
|
||||
Config: &repo_model.ActionsConfig{},
|
||||
}))
|
||||
}
|
||||
|
||||
t.Run("SameRepo_Public", func(t *testing.T) {
|
||||
task47 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
|
||||
require.Equal(t, repo4.ID, task47.RepoID)
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo4, actionsUser, task47.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Public repo, bot should have Read access even if not collaborator
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
|
||||
assert.True(t, perm.CanRead(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("SameRepo_Private", func(t *testing.T) {
|
||||
// Use Task 53 which is already in Repo 2 (Private)
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
require.Equal(t, repo2.ID, task53.RepoID)
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Private repo, bot has no base access, but gets Write from effective tokens perms (Permissive by default)
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
|
||||
assert.True(t, perm.CanWrite(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("CrossRepo_Denied_None", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
|
||||
// Set owner policy to nil allowed repos (None)
|
||||
cfg := actions_model.OwnerActionsConfig{}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg))
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT have access to the private repo.
|
||||
assert.False(t, perm.CanRead(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("ForkPR_NoCrossRepo", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
task53.IsForkPullRequest = true
|
||||
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
|
||||
|
||||
// Policy contains repo15
|
||||
cfg := actions_model.OwnerActionsConfig{
|
||||
AllowedCrossRepoIDs: []int64{repo15.ID},
|
||||
}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg))
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fork PR never gets cross-repo access to other private repos
|
||||
assert.False(t, perm.CanRead(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("Inheritance_And_Clamping", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
task53.IsForkPullRequest = false
|
||||
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
|
||||
|
||||
// Owner policy: Restricted mode (Read-only Code)
|
||||
ownerCfg := actions_model.OwnerActionsConfig{
|
||||
TokenPermissionMode: repo_model.ActionsTokenPermissionModeRestricted,
|
||||
MaxTokenPermissions: &repo_model.ActionsTokenPermissions{
|
||||
UnitAccessModes: map[unit.Type]perm_model.AccessMode{
|
||||
unit.TypeCode: perm_model.AccessModeRead,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg))
|
||||
|
||||
// Repo policy: OverrideOwnerConfig = false (should inherit owner's restricted mode)
|
||||
repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions)
|
||||
repo2ActionsCfg := repo2ActionsUnit.ActionsConfig()
|
||||
repo2ActionsCfg.OverrideOwnerConfig = false
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit))
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be clamped to Read-only
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode))
|
||||
assert.False(t, perm.CanWrite(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("RepoOverride_Clamping", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
|
||||
// Owner policy: Permissive (Write access)
|
||||
ownerCfg := actions_model.OwnerActionsConfig{
|
||||
TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg))
|
||||
|
||||
// Repo policy: OverrideOwnerConfig = true, MaxTokenPermissions = Read
|
||||
repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions)
|
||||
repo2ActionsCfg := repo2ActionsUnit.ActionsConfig()
|
||||
repo2ActionsCfg.OverrideOwnerConfig = true
|
||||
repo2ActionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModeRestricted
|
||||
repo2ActionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{
|
||||
UnitAccessModes: map[unit.Type]perm_model.AccessMode{
|
||||
unit.TypeCode: perm_model.AccessModeRead,
|
||||
},
|
||||
}
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit))
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be clamped to Read-only
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode))
|
||||
})
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@ -258,6 +259,23 @@ func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) {
|
||||
}
|
||||
}
|
||||
|
||||
func checkSameOwnerCrossRepoAccess(ctx context.Context, taskRepo, targetRepo *repo_model.Repository, isForkPR bool) bool {
|
||||
if isForkPR {
|
||||
// Fork PRs are never allowed cross-repo access to other private repositories of the owner.
|
||||
return false
|
||||
}
|
||||
if taskRepo.OwnerID != targetRepo.OwnerID {
|
||||
return false
|
||||
}
|
||||
ownerCfg, err := actions_model.GetOwnerActionsConfig(ctx, targetRepo.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("GetOwnerActionsConfig: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.Contains(ownerCfg.AllowedCrossRepoIDs, targetRepo.ID)
|
||||
}
|
||||
|
||||
// GetActionsUserRepoPermission returns the actions user permissions to the repository
|
||||
func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, taskID int64) (perm Permission, err error) {
|
||||
if actionsUser.ID != user_model.ActionsUserID {
|
||||
@ -268,37 +286,96 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
|
||||
return perm, err
|
||||
}
|
||||
|
||||
var accessMode perm_model.AccessMode
|
||||
if err := task.LoadJob(ctx); err != nil {
|
||||
return perm, err
|
||||
}
|
||||
|
||||
var taskRepo *repo_model.Repository
|
||||
if task.RepoID != repo.ID {
|
||||
taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID)
|
||||
if err != nil || !exist {
|
||||
if err := task.Job.LoadRepo(ctx); err != nil {
|
||||
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 should owner's visibility also be considered here?
|
||||
taskRepo = task.Job.Repo
|
||||
} else {
|
||||
taskRepo = repo
|
||||
}
|
||||
|
||||
// check permission like simple user but limit to read-only
|
||||
perm, err = GetUserRepoPermission(ctx, repo, user_model.NewActionsUser())
|
||||
// Compute effective permissions for this task against the target repo
|
||||
effectivePerms, err := actions_model.ComputeTaskTokenPermissions(ctx, task, repo)
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
if task.RepoID != repo.ID {
|
||||
// Cross-repo access must also respect the target repo's permission ceiling.
|
||||
targetRepoActionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
|
||||
if targetRepoActionsCfg.OverrideOwnerConfig {
|
||||
effectivePerms = targetRepoActionsCfg.ClampPermissions(effectivePerms)
|
||||
} else {
|
||||
targetRepoOwnerActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
perm.AccessMode = min(perm.AccessMode, perm_model.AccessModeRead)
|
||||
return perm, nil
|
||||
effectivePerms = targetRepoOwnerActionsCfg.ClampPermissions(effectivePerms)
|
||||
}
|
||||
accessMode = perm_model.AccessModeRead
|
||||
} else if task.IsForkPullRequest {
|
||||
accessMode = perm_model.AccessModeRead
|
||||
} else {
|
||||
accessMode = perm_model.AccessModeWrite
|
||||
}
|
||||
|
||||
if err := repo.LoadUnits(ctx); err != nil {
|
||||
return perm, err
|
||||
}
|
||||
perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode)
|
||||
|
||||
var maxPerm Permission
|
||||
|
||||
// Set up per-unit access modes based on configured permissions
|
||||
maxPerm.units = repo.Units
|
||||
maxPerm.unitsMode = maps.Clone(effectivePerms.UnitAccessModes)
|
||||
|
||||
// Check permission like simple user but limit to read-only (PR #36095)
|
||||
// Enhanced to also grant read-only access if isSameRepo is true and target repository is public
|
||||
botPerm, err := GetUserRepoPermission(ctx, repo, user_model.NewActionsUser())
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
if botPerm.AccessMode >= perm_model.AccessModeRead {
|
||||
// Public repo allows read access, increase permissions to at least read
|
||||
// Otherwise you cannot access your own repository if your permissions are set to none but the repository is public
|
||||
for _, u := range repo.Units {
|
||||
if botPerm.CanRead(u.Type) {
|
||||
maxPerm.unitsMode[u.Type] = max(maxPerm.unitsMode[u.Type], perm_model.AccessModeRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if task.RepoID == repo.ID {
|
||||
return maxPerm, nil
|
||||
}
|
||||
|
||||
if checkSameOwnerCrossRepoAccess(ctx, taskRepo, repo, task.IsForkPullRequest) {
|
||||
// Access allowed by owner policy (grants access to private repos).
|
||||
// Note: maxPerm has already been restricted to Read-Only in ComputeTaskTokenPermissions
|
||||
// because isSameRepo is false.
|
||||
return maxPerm, nil
|
||||
}
|
||||
|
||||
// Fall through to allow public repository read access via botPerm check below
|
||||
|
||||
// Check if the repo is public or the Bot has explicit access
|
||||
if botPerm.AccessMode >= perm_model.AccessModeRead {
|
||||
return maxPerm, nil
|
||||
}
|
||||
|
||||
// Check Collaborative Owner and explicit Bot permissions
|
||||
// We allow access if:
|
||||
// 1. It's a collaborative owner relationship
|
||||
// 2. The Actions Bot user has been explicitly granted access and repository is private
|
||||
// 3. The repository is public (handled by botPerm above)
|
||||
|
||||
if taskRepo.IsPrivate {
|
||||
actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions)
|
||||
if actionsUnit.ActionsConfig().IsCollaborativeOwner(taskRepo.OwnerID) {
|
||||
return maxPerm, nil
|
||||
}
|
||||
}
|
||||
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ func TestDefaultTargetBranchSelection(t *testing.T) {
|
||||
prConfig := prUnit.PullRequestsConfig()
|
||||
prConfig.DefaultTargetBranch = "branch2"
|
||||
prUnit.Config = prConfig
|
||||
assert.NoError(t, UpdateRepoUnit(ctx, prUnit))
|
||||
assert.NoError(t, UpdateRepoUnitConfig(ctx, prUnit))
|
||||
repo.Units = nil
|
||||
assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx))
|
||||
}
|
||||
|
||||
@ -778,3 +778,11 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor
|
||||
repos := make(RepositoryList, 0, opts.PageSize)
|
||||
return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos)
|
||||
}
|
||||
|
||||
func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) {
|
||||
if len(repoIDs) == 0 {
|
||||
return RepositoryList{}, nil
|
||||
}
|
||||
repos := make(RepositoryList, 0, len(repoIDs))
|
||||
return repos, db.GetEngine(ctx).Where(builder.Eq{"owner_id": ownerID}).In("id", repoIDs).Find(&repos)
|
||||
}
|
||||
|
||||
@ -5,8 +5,6 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
@ -175,57 +173,6 @@ func DefaultPullRequestsUnit(repoID int64) RepoUnit {
|
||||
return RepoUnit{RepoID: repoID, Type: unit.TypePullRequests, Config: DefaultPullRequestsConfig()}
|
||||
}
|
||||
|
||||
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) {
|
||||
cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) ToString() string {
|
||||
return strings.Join(cfg.DisabledWorkflows, ",")
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool {
|
||||
return slices.Contains(cfg.DisabledWorkflows, file)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) DisableWorkflow(file string) {
|
||||
if slices.Contains(cfg.DisabledWorkflows, file) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ToDB exports a ActionsConfig to a serialized format.
|
||||
func (cfg *ActionsConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
// ProjectsMode represents the projects enabled for a repository
|
||||
type ProjectsMode string
|
||||
|
||||
@ -279,7 +226,8 @@ func (cfg *ProjectsConfig) IsProjectsAllowed(m ProjectsMode) bool {
|
||||
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
|
||||
switch colName {
|
||||
case "type":
|
||||
switch unit.Type(db.Cell2Int64(val)) {
|
||||
r.Type = unit.Type(db.Cell2Int64(val))
|
||||
switch r.Type {
|
||||
case unit.TypeExternalWiki:
|
||||
r.Config = new(ExternalWikiConfig)
|
||||
case unit.TypeExternalTracker:
|
||||
@ -297,6 +245,11 @@ func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
|
||||
default:
|
||||
r.Config = new(UnitConfig)
|
||||
}
|
||||
case "config":
|
||||
if *val == nil {
|
||||
// XROM doesn't call FromDB if the value is nil, but we need to set default values for the config fields
|
||||
_ = r.Config.FromDB(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -360,9 +313,9 @@ func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err
|
||||
return units, nil
|
||||
}
|
||||
|
||||
// UpdateRepoUnit updates the provided repo unit
|
||||
func UpdateRepoUnit(ctx context.Context, unit *RepoUnit) error {
|
||||
_, err := db.GetEngine(ctx).ID(unit.ID).Update(unit)
|
||||
// UpdateRepoUnitConfig updates the config of the provided repo unit
|
||||
func UpdateRepoUnitConfig(ctx context.Context, unit *RepoUnit) error {
|
||||
_, err := db.GetEngine(ctx).ID(unit.ID).Cols("config").Update(unit)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
153
models/repo/repo_unit_actions.go
Normal file
153
models/repo/repo_unit_actions.go
Normal file
@ -0,0 +1,153 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
// ActionsTokenPermissionMode defines the default permission mode for Actions tokens
|
||||
type ActionsTokenPermissionMode string
|
||||
|
||||
const (
|
||||
// ActionsTokenPermissionModePermissive - write access by default (current behavior, backwards compatible)
|
||||
ActionsTokenPermissionModePermissive ActionsTokenPermissionMode = "permissive"
|
||||
// ActionsTokenPermissionModeRestricted - read access by default
|
||||
ActionsTokenPermissionModeRestricted ActionsTokenPermissionMode = "restricted"
|
||||
)
|
||||
|
||||
func (ActionsTokenPermissionMode) EnumValues() []ActionsTokenPermissionMode {
|
||||
return []ActionsTokenPermissionMode{ActionsTokenPermissionModePermissive /* default */, ActionsTokenPermissionModeRestricted}
|
||||
}
|
||||
|
||||
// ActionsTokenPermissions defines the permissions for different repository units
|
||||
type ActionsTokenPermissions struct {
|
||||
UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"`
|
||||
}
|
||||
|
||||
var ActionsTokenUnitTypes = []unit.Type{
|
||||
unit.TypeCode,
|
||||
unit.TypeIssues,
|
||||
unit.TypePullRequests,
|
||||
unit.TypePackages,
|
||||
unit.TypeActions,
|
||||
unit.TypeWiki,
|
||||
unit.TypeReleases,
|
||||
unit.TypeProjects,
|
||||
}
|
||||
|
||||
func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTokenPermissions) {
|
||||
ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
|
||||
for _, u := range ActionsTokenUnitTypes {
|
||||
ret.UnitAccessModes[u] = unitAccessMode
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// ClampActionsTokenPermissions ensures that the given permissions don't exceed the maximum
|
||||
func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTokenPermissions) {
|
||||
ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
|
||||
for _, ut := range ActionsTokenUnitTypes {
|
||||
ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut])
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// MakeRestrictedPermissions returns the restricted permissions
|
||||
func MakeRestrictedPermissions() ActionsTokenPermissions {
|
||||
ret := MakeActionsTokenPermissions(perm.AccessModeNone)
|
||||
ret.UnitAccessModes[unit.TypeCode] = perm.AccessModeRead
|
||||
ret.UnitAccessModes[unit.TypePackages] = perm.AccessModeRead
|
||||
ret.UnitAccessModes[unit.TypeReleases] = perm.AccessModeRead
|
||||
return ret
|
||||
}
|
||||
|
||||
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
|
||||
// TokenPermissionMode defines the default permission mode (permissive, restricted, or custom)
|
||||
TokenPermissionMode ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"`
|
||||
// MaxTokenPermissions defines the absolute maximum permissions any token can have in this context.
|
||||
// Workflow YAML "permissions" keywords can reduce permissions but never exceed this ceiling.
|
||||
MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"`
|
||||
// OverrideOwnerConfig indicates if this repository should override the owner-level configuration (User or Org)
|
||||
OverrideOwnerConfig bool `json:"override_owner_config,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) EnableWorkflow(file string) {
|
||||
cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool {
|
||||
return slices.Contains(cfg.DisabledWorkflows, file)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) DisableWorkflow(file string) {
|
||||
if slices.Contains(cfg.DisabledWorkflows, file) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode.
|
||||
// It does not apply MaxTokenPermissions; callers must clamp if needed.
|
||||
func (cfg *ActionsConfig) GetDefaultTokenPermissions() ActionsTokenPermissions {
|
||||
switch cfg.TokenPermissionMode {
|
||||
case ActionsTokenPermissionModeRestricted:
|
||||
return MakeRestrictedPermissions()
|
||||
case ActionsTokenPermissionModePermissive:
|
||||
return MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
default:
|
||||
return ActionsTokenPermissions{}
|
||||
}
|
||||
}
|
||||
|
||||
// GetMaxTokenPermissions returns the maximum allowed permissions
|
||||
func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions {
|
||||
if cfg.MaxTokenPermissions != nil {
|
||||
return *cfg.MaxTokenPermissions
|
||||
}
|
||||
// Default max is write for everything
|
||||
return MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
}
|
||||
|
||||
// ClampPermissions ensures that the given permissions don't exceed the maximum
|
||||
func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) ActionsTokenPermissions {
|
||||
maxPerms := cfg.GetMaxTokenPermissions()
|
||||
return ClampActionsTokenPermissions(perms, maxPerms)
|
||||
}
|
||||
|
||||
// FromDB fills up a ActionsConfig from serialized format.
|
||||
func (cfg *ActionsConfig) FromDB(bs []byte) error {
|
||||
_ = json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
||||
cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDB exports a ActionsConfig to a serialized format.
|
||||
func (cfg *ActionsConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
@ -4,8 +4,12 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@ -26,5 +30,75 @@ func TestActionsConfig(t *testing.T) {
|
||||
cfg.DisableWorkflow("test1.yaml")
|
||||
cfg.DisableWorkflow("test2.yaml")
|
||||
cfg.DisableWorkflow("test3.yaml")
|
||||
assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", cfg.ToString())
|
||||
assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", strings.Join(cfg.DisabledWorkflows, ","))
|
||||
}
|
||||
|
||||
func TestActionsConfigTokenPermissions(t *testing.T) {
|
||||
t.Run("Default Permission Mode", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{TokenPermissionMode: "invalid-value"}
|
||||
_ = cfg.FromDB(nil)
|
||||
assert.Equal(t, ActionsTokenPermissionModePermissive, cfg.TokenPermissionMode)
|
||||
assert.Equal(t, perm.AccessModeWrite, cfg.GetDefaultTokenPermissions().UnitAccessModes[unit.TypeCode])
|
||||
})
|
||||
|
||||
t.Run("Explicit Permission Mode", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{
|
||||
TokenPermissionMode: ActionsTokenPermissionModeRestricted,
|
||||
}
|
||||
assert.Equal(t, ActionsTokenPermissionModeRestricted, cfg.TokenPermissionMode)
|
||||
})
|
||||
|
||||
t.Run("Effective Permissions - Permissive Mode", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{
|
||||
TokenPermissionMode: ActionsTokenPermissionModePermissive,
|
||||
}
|
||||
defaultPerms := cfg.GetDefaultTokenPermissions()
|
||||
perms := cfg.ClampPermissions(defaultPerms)
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypePackages])
|
||||
})
|
||||
|
||||
t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{
|
||||
TokenPermissionMode: ActionsTokenPermissionModeRestricted,
|
||||
}
|
||||
defaultPerms := cfg.GetDefaultTokenPermissions()
|
||||
perms := cfg.ClampPermissions(defaultPerms)
|
||||
assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeNone, perms.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypePackages])
|
||||
})
|
||||
|
||||
t.Run("Clamp Permissions", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{
|
||||
MaxTokenPermissions: &ActionsTokenPermissions{
|
||||
UnitAccessModes: map[unit.Type]perm.AccessMode{
|
||||
unit.TypeCode: perm.AccessModeRead,
|
||||
unit.TypeIssues: perm.AccessModeWrite,
|
||||
unit.TypePullRequests: perm.AccessModeRead,
|
||||
unit.TypePackages: perm.AccessModeRead,
|
||||
unit.TypeActions: perm.AccessModeNone,
|
||||
unit.TypeWiki: perm.AccessModeWrite,
|
||||
},
|
||||
},
|
||||
}
|
||||
input := ActionsTokenPermissions{
|
||||
UnitAccessModes: map[unit.Type]perm.AccessMode{
|
||||
unit.TypeCode: perm.AccessModeWrite, // Should be clamped to Read
|
||||
unit.TypeIssues: perm.AccessModeWrite, // Should stay Write
|
||||
unit.TypePullRequests: perm.AccessModeWrite, // Should be clamped to Read
|
||||
unit.TypePackages: perm.AccessModeWrite, // Should be clamped to Read
|
||||
unit.TypeActions: perm.AccessModeRead, // Should be clamped to None
|
||||
unit.TypeWiki: perm.AccessModeRead, // Should stay Read
|
||||
},
|
||||
}
|
||||
clamped := cfg.ClampPermissions(input)
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeWrite, clamped.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePullRequests])
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeNone, clamped.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeWiki])
|
||||
})
|
||||
}
|
||||
|
||||
@ -11,10 +11,12 @@ import (
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/cache"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
setting_module "code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm/convert"
|
||||
)
|
||||
|
||||
// Setting is a key value store of user settings
|
||||
@ -211,3 +213,44 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserSettingJSON[T any](ctx context.Context, userID int64, key string, def T) (ret T, _ error) {
|
||||
ret = def
|
||||
str, err := GetUserSetting(ctx, userID, key)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
conv, ok := any(&ret).(convert.ConversionFrom)
|
||||
if !ok {
|
||||
conv, ok = any(ret).(convert.ConversionFrom)
|
||||
}
|
||||
if ok {
|
||||
if err := conv.FromDB(util.UnsafeStringToBytes(str)); err != nil {
|
||||
return ret, err
|
||||
}
|
||||
} else {
|
||||
if str == "" {
|
||||
return ret, nil
|
||||
}
|
||||
err = json.Unmarshal(util.UnsafeStringToBytes(str), &ret)
|
||||
}
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func SetUserSettingJSON[T any](ctx context.Context, userID int64, key string, val T) (err error) {
|
||||
conv, ok := any(&val).(convert.ConversionTo)
|
||||
if !ok {
|
||||
conv, ok = any(val).(convert.ConversionTo)
|
||||
}
|
||||
var bs []byte
|
||||
if ok {
|
||||
bs, err = conv.ToDB()
|
||||
} else {
|
||||
bs, err = json.Marshal(val)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return SetUserSetting(ctx, userID, key, util.UnsafeBytesToString(bs))
|
||||
}
|
||||
|
||||
@ -22,4 +22,6 @@ const (
|
||||
SettingEmailNotificationGiteaActionsAll = "all"
|
||||
SettingEmailNotificationGiteaActionsFailureOnly = "failure-only" // Default for actions email preference
|
||||
SettingEmailNotificationGiteaActionsDisabled = "disabled"
|
||||
|
||||
SettingsKeyActionsConfig = "actions.config"
|
||||
)
|
||||
|
||||
@ -37,7 +37,7 @@ type HookOptions struct {
|
||||
PushTrigger repository.PushTrigger
|
||||
DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
|
||||
IsWiki bool
|
||||
ActionPerm int
|
||||
ActionsTaskID int64 // if the pusher is an Actions user, the task ID
|
||||
}
|
||||
|
||||
// SSHLogOption ssh log options
|
||||
|
||||
@ -11,25 +11,26 @@ import (
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
// env keys for git hooks need
|
||||
const (
|
||||
EnvRepoName = "GITEA_REPO_NAME"
|
||||
EnvRepoUsername = "GITEA_REPO_USER_NAME"
|
||||
EnvRepoID = "GITEA_REPO_ID"
|
||||
EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
|
||||
EnvPusherName = "GITEA_PUSHER_NAME"
|
||||
EnvPusherEmail = "GITEA_PUSHER_EMAIL"
|
||||
EnvPusherID = "GITEA_PUSHER_ID"
|
||||
EnvKeyID = "GITEA_KEY_ID" // public key ID
|
||||
EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
|
||||
EnvPRID = "GITEA_PR_ID"
|
||||
EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
|
||||
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
|
||||
EnvIsInternal = "GITEA_INTERNAL_PUSH"
|
||||
EnvAppURL = "GITEA_ROOT_URL"
|
||||
EnvActionPerm = "GITEA_ACTION_PERM"
|
||||
EnvRepoName = "GITEA_REPO_NAME"
|
||||
EnvRepoUsername = "GITEA_REPO_USER_NAME"
|
||||
EnvRepoID = "GITEA_REPO_ID"
|
||||
EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
|
||||
EnvPusherName = "GITEA_PUSHER_NAME"
|
||||
EnvPusherEmail = "GITEA_PUSHER_EMAIL"
|
||||
EnvPusherID = "GITEA_PUSHER_ID"
|
||||
EnvKeyID = "GITEA_KEY_ID" // public key ID
|
||||
EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
|
||||
EnvPRID = "GITEA_PR_ID"
|
||||
EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
|
||||
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
|
||||
EnvIsInternal = "GITEA_INTERNAL_PUSH"
|
||||
EnvAppURL = "GITEA_ROOT_URL"
|
||||
EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID"
|
||||
)
|
||||
|
||||
type PushTrigger string
|
||||
@ -54,36 +55,39 @@ func PushingEnvironment(doer *user_model.User, repo *repo_model.Repository) []st
|
||||
return FullPushingEnvironment(doer, doer, repo, repo.Name, 0, 0)
|
||||
}
|
||||
|
||||
func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository, isWiki bool) []string {
|
||||
env := []string{
|
||||
EnvAppURL + "=" + setting.AppURL,
|
||||
EnvRepoName + "=" + repo.Name + util.Iif(isWiki, ".wiki", ""),
|
||||
EnvRepoUsername + "=" + repo.OwnerName,
|
||||
EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
|
||||
EnvRepoIsWiki + "=" + strconv.FormatBool(isWiki),
|
||||
EnvPusherName + "=" + doer.Name,
|
||||
EnvPusherID + "=" + strconv.FormatInt(doer.ID, 10),
|
||||
}
|
||||
if !doer.KeepEmailPrivate {
|
||||
env = append(env, EnvPusherEmail+"="+doer.Email)
|
||||
}
|
||||
if taskID, isActionsUser := user_model.GetActionsUserTaskID(doer); isActionsUser {
|
||||
env = append(env, EnvActionsTaskID+"="+strconv.FormatInt(taskID, 10))
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// FullPushingEnvironment returns an os environment to allow hooks to work on push
|
||||
func FullPushingEnvironment(author, committer *user_model.User, repo *repo_model.Repository, repoName string, prID, prIndex int64) []string {
|
||||
isWiki := "false"
|
||||
if strings.HasSuffix(repoName, ".wiki") {
|
||||
isWiki = "true"
|
||||
}
|
||||
|
||||
isWiki := strings.HasSuffix(repoName, ".wiki")
|
||||
authorSig := author.NewGitSig()
|
||||
committerSig := committer.NewGitSig()
|
||||
|
||||
environ := append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME="+authorSig.Name,
|
||||
"GIT_AUTHOR_EMAIL="+authorSig.Email,
|
||||
"GIT_COMMITTER_NAME="+committerSig.Name,
|
||||
"GIT_COMMITTER_EMAIL="+committerSig.Email,
|
||||
EnvRepoName+"="+repoName,
|
||||
EnvRepoUsername+"="+repo.OwnerName,
|
||||
EnvRepoIsWiki+"="+isWiki,
|
||||
EnvPusherName+"="+committer.Name,
|
||||
EnvPusherID+"="+strconv.FormatInt(committer.ID, 10),
|
||||
EnvRepoID+"="+strconv.FormatInt(repo.ID, 10),
|
||||
EnvPRID+"="+strconv.FormatInt(prID, 10),
|
||||
EnvPRIndex+"="+strconv.FormatInt(prIndex, 10),
|
||||
EnvAppURL+"="+setting.AppURL,
|
||||
"SSH_ORIGINAL_COMMAND=gitea-internal",
|
||||
)
|
||||
|
||||
if !committer.KeepEmailPrivate {
|
||||
environ = append(environ, EnvPusherEmail+"="+committer.Email)
|
||||
}
|
||||
|
||||
environ = append(environ, DoerPushingEnvironment(committer, repo, isWiki)...)
|
||||
return environ
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@ -240,6 +241,20 @@ func OptionalArg[T any](optArg []T, defaultValue ...T) (ret T) {
|
||||
return ret
|
||||
}
|
||||
|
||||
type EnumConst[T comparable] interface {
|
||||
EnumValues() []T
|
||||
}
|
||||
|
||||
// EnumValue returns the value if it's in the enum const's values,
|
||||
// otherwise returns the first item of enums as default value.
|
||||
func EnumValue[T comparable](val EnumConst[T]) (ret T, valid bool) {
|
||||
enums := val.EnumValues()
|
||||
if slices.Contains(enums, val.(T)) {
|
||||
return val.(T), true
|
||||
}
|
||||
return enums[0], false
|
||||
}
|
||||
|
||||
func ReserveLineBreakForTextarea(input string) string {
|
||||
// Since the content is from a form which is a textarea, the line endings are \r\n.
|
||||
// It's a standard behavior of HTML.
|
||||
|
||||
@ -646,6 +646,7 @@
|
||||
"user.block.note.edit": "Edit note",
|
||||
"user.block.list": "Blocked users",
|
||||
"user.block.list.none": "You have not blocked any users.",
|
||||
"settings.general": "General",
|
||||
"settings.profile": "Profile",
|
||||
"settings.account": "Account",
|
||||
"settings.appearance": "Appearance",
|
||||
@ -3708,6 +3709,10 @@
|
||||
"actions.runs.not_done": "This workflow run is not done.",
|
||||
"actions.runs.view_workflow_file": "View workflow file",
|
||||
"actions.runs.workflow_graph": "Workflow Graph",
|
||||
"actions.runs.summary": "Summary",
|
||||
"actions.runs.all_jobs": "All jobs",
|
||||
"actions.runs.triggered_via": "Triggered via %s",
|
||||
"actions.runs.total_duration": "Total duration:",
|
||||
"actions.workflow.disable": "Disable Workflow",
|
||||
"actions.workflow.disable_success": "Workflow '%s' disabled successfully.",
|
||||
"actions.workflow.enable": "Enable Workflow",
|
||||
@ -3757,5 +3762,24 @@
|
||||
"git.filemode.normal_file": "Regular",
|
||||
"git.filemode.executable_file": "Executable",
|
||||
"git.filemode.symbolic_link": "Symlink",
|
||||
"git.filemode.submodule": "Submodule"
|
||||
"git.filemode.submodule": "Submodule",
|
||||
"org.repos.none": "No repositories.",
|
||||
"actions.general.permissions": "Actions Token Permissions",
|
||||
"actions.general.token_permissions.mode": "Default Token Permissions",
|
||||
"actions.general.token_permissions.mode.desc": "An Actions job will use the default permissions if it doesn't declare its permissions in the workflow file.",
|
||||
"actions.general.token_permissions.mode.permissive": "Permissive",
|
||||
"actions.general.token_permissions.mode.permissive.desc": "Read and write permissions for the job's repository.",
|
||||
"actions.general.token_permissions.mode.restricted": "Restricted",
|
||||
"actions.general.token_permissions.mode.restricted.desc": "Read-only permissions for contents units (code, releases) of the job's repository.",
|
||||
"actions.general.token_permissions.override_owner": "Override owner-level configuration",
|
||||
"actions.general.token_permissions.override_owner_desc": "If enabled, this repository will use its own Actions configuration instead of following the owner-level (user or organization) configuration.",
|
||||
"actions.general.token_permissions.maximum": "Maximum Token Permissions",
|
||||
"actions.general.token_permissions.maximum.description": "Actions job's effective permissions will be limited by the maximum permissions.",
|
||||
"actions.general.token_permissions.fork_pr_note": "If a job is started by a pull request from a fork, its effective permissions won't exceed the read-only permissions.",
|
||||
"actions.general.token_permissions.customize_max_permissions": "Customize maximum permissions",
|
||||
"actions.general.cross_repo": "Cross-Repository Access",
|
||||
"actions.general.cross_repo_desc": "Allow the selected repositories to be accessed (read-only) by all the repositories in this owner with GITEA_TOKEN when running Actions jobs.",
|
||||
"actions.general.cross_repo_selected": "Selected repositories",
|
||||
"actions.general.cross_repo_target_repos": "Target Repositories",
|
||||
"actions.general.cross_repo_add": "Add Target Repository"
|
||||
}
|
||||
|
||||
@ -496,16 +496,25 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
||||
}
|
||||
|
||||
if ctx.opts.UserID == user_model.ActionsUserID {
|
||||
ctx.user = user_model.NewActionsUser()
|
||||
ctx.userPerm.AccessMode = perm_model.AccessMode(ctx.opts.ActionPerm)
|
||||
if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil {
|
||||
log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err)
|
||||
taskID := ctx.opts.ActionsTaskID
|
||||
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
|
||||
if taskID == 0 {
|
||||
log.Error("HookPreReceive: ActionsUser with task ID 0")
|
||||
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||
Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err),
|
||||
Err: "ActionsUser with task ID 0",
|
||||
})
|
||||
return false
|
||||
}
|
||||
ctx.userPerm.SetUnitsWithDefaultAccessMode(ctx.Repo.Repository.Units, ctx.userPerm.AccessMode)
|
||||
|
||||
userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, taskID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get Actions user repo permission for task %d Error: %v", taskID, err)
|
||||
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||
Err: fmt.Sprintf("Unable to get Actions user repo permission for task %d Error: %v", taskID, err),
|
||||
})
|
||||
return false
|
||||
}
|
||||
ctx.userPerm = userPerm
|
||||
} else {
|
||||
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
|
||||
if err != nil {
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func RedirectToDefaultSetting(ctx *context.Context) {
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners")
|
||||
}
|
||||
@ -59,15 +59,14 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
|
||||
}
|
||||
|
||||
func MockActionsView(ctx *context.Context) {
|
||||
ctx.Data["RunID"] = ctx.PathParam("run")
|
||||
ctx.Data["JobID"] = ctx.PathParam("job")
|
||||
ctx.Data["RunID"] = ctx.PathParamInt64("run")
|
||||
ctx.Data["JobID"] = ctx.PathParamInt64("job")
|
||||
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
|
||||
}
|
||||
|
||||
func MockActionsRunsJobs(ctx *context.Context) {
|
||||
runID := ctx.PathParamInt64("run")
|
||||
|
||||
req := web.GetForm(ctx).(*actions.ViewRequest)
|
||||
resp := &actions.ViewResponse{}
|
||||
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
||||
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
|
||||
@ -79,6 +78,9 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
resp.State.Run.CanDeleteArtifact = true
|
||||
resp.State.Run.WorkflowID = "workflow-id"
|
||||
resp.State.Run.WorkflowLink = "./workflow-link"
|
||||
resp.State.Run.Duration = "1h 23m 45s"
|
||||
resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix()
|
||||
resp.State.Run.TriggerEvent = "push"
|
||||
resp.State.Run.Commit = actions.ViewCommit{
|
||||
ShortSha: "ccccdddd",
|
||||
Link: "./commit-link",
|
||||
@ -140,6 +142,17 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
Needs: []string{"job-100", "job-101"},
|
||||
})
|
||||
|
||||
fillViewRunResponseCurrentJob(ctx, resp)
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewResponse) {
|
||||
jobID := ctx.PathParamInt64("job")
|
||||
if jobID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
req := web.GetForm(ctx).(*actions.ViewRequest)
|
||||
var mockLogOptions []generateMockStepsLogOptions
|
||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
||||
Summary: "step 0 (mock slow)",
|
||||
@ -163,7 +176,6 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})
|
||||
|
||||
if len(req.LogCursors) == 0 {
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
@ -189,5 +201,4 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
} else {
|
||||
time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
|
||||
}
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@ -51,6 +51,12 @@ func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
func LocationRedirect(target string) func(w http.ResponseWriter, req *http.Request) {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, target, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func WebBannerDismiss(ctx *context.Context) {
|
||||
_, rev, _ := setting.Config().Instance.WebBanner.ValueRevision(ctx)
|
||||
middleware.SetSiteCookie(ctx.Resp, middleware.CookieWebBannerDismissed, strconv.Itoa(rev), 48*3600)
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func RedirectToDefaultSetting(ctx *context.Context) {
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/actions/runners")
|
||||
}
|
||||
@ -38,41 +38,54 @@ import (
|
||||
"github.com/nektos/act/pkg/model"
|
||||
)
|
||||
|
||||
func getRunID(ctx *context_module.Context) int64 {
|
||||
// if run param is "latest", get the latest run id
|
||||
if ctx.PathParam("run") == "latest" {
|
||||
if run, _ := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID); run != nil {
|
||||
return run.ID
|
||||
func findCurrentJobByPathParam(ctx *context_module.Context, jobs []*actions_model.ActionRunJob) (job *actions_model.ActionRunJob, hasPathParam bool) {
|
||||
selectedJobID := ctx.PathParamInt64("job")
|
||||
if selectedJobID <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, job = range jobs {
|
||||
if job.ID == selectedJobID {
|
||||
return job, true
|
||||
}
|
||||
}
|
||||
return ctx.PathParamInt64("run")
|
||||
return nil, true
|
||||
}
|
||||
|
||||
func getCurrentRunByPathParam(ctx *context_module.Context) (run *actions_model.ActionRun) {
|
||||
var err error
|
||||
// if run param is "latest", get the latest run id
|
||||
if ctx.PathParam("run") == "latest" {
|
||||
run, err = actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID)
|
||||
} else {
|
||||
run, err = actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("run"))
|
||||
}
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.NotFound(nil)
|
||||
} else if err != nil {
|
||||
ctx.ServerError("GetRun:"+ctx.PathParam("run"), err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func View(ctx *context_module.Context) {
|
||||
ctx.Data["PageIsActions"] = true
|
||||
runID := getRunID(ctx)
|
||||
|
||||
_, _, current := getRunJobsAndCurrentJob(ctx, runID)
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["RunID"] = runID
|
||||
ctx.Data["JobID"] = current.ID
|
||||
ctx.Data["RunID"] = run.ID
|
||||
ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view)
|
||||
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
|
||||
|
||||
ctx.HTML(http.StatusOK, tplViewActions)
|
||||
}
|
||||
|
||||
func ViewWorkflowFile(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetCommit", func(err error) bool {
|
||||
@ -130,6 +143,10 @@ type ViewResponse struct {
|
||||
IsSchedule bool `json:"isSchedule"`
|
||||
Jobs []*ViewJob `json:"jobs"`
|
||||
Commit ViewCommit `json:"commit"`
|
||||
// Summary view: run duration and trigger time/event
|
||||
Duration string `json:"duration"`
|
||||
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
|
||||
TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
|
||||
} `json:"run"`
|
||||
CurrentJob struct {
|
||||
Title string `json:"title"`
|
||||
@ -190,11 +207,7 @@ type ViewStepLogLine struct {
|
||||
}
|
||||
|
||||
func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID)
|
||||
artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -209,10 +222,7 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifact
|
||||
}
|
||||
|
||||
func ViewPost(ctx *context_module.Context) {
|
||||
req := web.GetForm(ctx).(*ViewRequest)
|
||||
runID := getRunID(ctx)
|
||||
|
||||
run, jobs, current := getRunJobsAndCurrentJob(ctx, runID)
|
||||
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
@ -221,14 +231,24 @@ func ViewPost(ctx *context_module.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
resp := &ViewResponse{}
|
||||
resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runID)
|
||||
fillViewRunResponseSummary(ctx, resp, run, jobs)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
fillViewRunResponseCurrentJob(ctx, resp, run, jobs)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
|
||||
var err error
|
||||
resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.ServerError("getActionsViewArtifacts", err)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("getActionsViewArtifacts", err)
|
||||
return
|
||||
}
|
||||
|
||||
// the title for the "run" is from the commit message
|
||||
@ -289,6 +309,20 @@ func ViewPost(ctx *context_module.Context) {
|
||||
Pusher: pusher,
|
||||
Branch: branch,
|
||||
}
|
||||
resp.State.Run.Duration = run.Duration().String()
|
||||
resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
|
||||
resp.State.Run.TriggerEvent = run.TriggerEvent
|
||||
}
|
||||
|
||||
func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
|
||||
req := web.GetForm(ctx).(*ViewRequest)
|
||||
current, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
|
||||
if current == nil {
|
||||
if hasPathParam {
|
||||
ctx.NotFound(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var task *actions_model.ActionTask
|
||||
if current.TaskID > 0 {
|
||||
@ -321,8 +355,6 @@ func ViewPost(ctx *context_module.Context) {
|
||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...)
|
||||
resp.Logs.StepsLog = append(resp.Logs.StepsLog, logs...)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
|
||||
@ -426,19 +458,22 @@ func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.Action
|
||||
// Rerun will rerun jobs in the given run
|
||||
// If jobIDStr is a blank string, it means rerun all jobs
|
||||
func Rerun(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
|
||||
run, jobs, currentJob := getRunJobsAndCurrentJob(ctx, runID)
|
||||
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if !checkRunRerunAllowed(ctx, run) {
|
||||
return
|
||||
}
|
||||
|
||||
currentJob, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
|
||||
if hasPathParam && currentJob == nil {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
var jobsToRerun []*actions_model.ActionRunJob
|
||||
if ctx.PathParam("job") != "" {
|
||||
if currentJob != nil {
|
||||
jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
|
||||
} else {
|
||||
jobsToRerun = jobs
|
||||
@ -454,13 +489,10 @@ func Rerun(ctx *context_module.Context) {
|
||||
|
||||
// RerunFailed reruns all failed jobs in the given run
|
||||
func RerunFailed(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
|
||||
run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
|
||||
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if !checkRunRerunAllowed(ctx, run) {
|
||||
return
|
||||
}
|
||||
@ -474,18 +506,13 @@ func RerunFailed(ctx *context_module.Context) {
|
||||
}
|
||||
|
||||
func Logs(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
jobID := ctx.PathParamInt64("job")
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
jobID := ctx.PathParamInt64("job")
|
||||
|
||||
if err = common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
|
||||
if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
|
||||
ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
@ -493,9 +520,7 @@ func Logs(ctx *context_module.Context) {
|
||||
}
|
||||
|
||||
func Cancel(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
|
||||
run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
|
||||
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
@ -529,9 +554,11 @@ func Cancel(ctx *context_module.Context) {
|
||||
}
|
||||
|
||||
func Approve(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
|
||||
approveRuns(ctx, []int64{runID})
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
approveRuns(ctx, []int64{run.ID})
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
@ -606,16 +633,8 @@ func approveRuns(ctx *context_module.Context, runIDs []int64) {
|
||||
}
|
||||
|
||||
func Delete(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.JSONErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetRunByRepoAndID", err)
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@ -632,59 +651,37 @@ func Delete(ctx *context_module.Context) {
|
||||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
// getRunJobsAndCurrentJob loads the run and its jobs for runID, and returns the selected job based on the optional "job" path param (or the first job by default).
|
||||
// Any error will be written to the ctx, and nils are returned in that case.
|
||||
func getRunJobsAndCurrentJob(ctx *context_module.Context, runID int64) (*actions_model.ActionRun, []*actions_model.ActionRunJob, *actions_model.ActionRunJob) {
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return nil, nil, nil
|
||||
// getRunJobs loads the run and its jobs for runID
|
||||
// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil.
|
||||
func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) {
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return nil, nil
|
||||
}
|
||||
run.Repo = ctx.Repo.Repository
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunJobsByRunID", err)
|
||||
return nil, nil, nil
|
||||
return nil, nil
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
ctx.NotFound(nil)
|
||||
return nil, nil, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
job.Run = run
|
||||
}
|
||||
|
||||
current := jobs[0]
|
||||
if ctx.PathParam("job") != "" {
|
||||
jobID := ctx.PathParamInt64("job")
|
||||
current, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetRunJobByRunAndID", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
current.Run = run
|
||||
}
|
||||
|
||||
return run, jobs, current
|
||||
return run, jobs
|
||||
}
|
||||
|
||||
func ArtifactsDeleteView(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
|
||||
ctx.ServerError("SetArtifactNeedDelete", err)
|
||||
return
|
||||
}
|
||||
@ -692,19 +689,12 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
|
||||
}
|
||||
|
||||
func ArtifactsDownloadView(ctx *context_module.Context) {
|
||||
runID := getRunID(ctx)
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.HTTPError(http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetRunByRepoAndID", err)
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
||||
RunID: run.ID,
|
||||
ArtifactName: artifactName,
|
||||
@ -832,7 +822,7 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) {
|
||||
cfg.DisableWorkflow(workflow)
|
||||
}
|
||||
|
||||
if err := repo_model.UpdateRepoUnit(ctx, cfgUnit); err != nil {
|
||||
if err := repo_model.UpdateRepoUnitConfig(ctx, cfgUnit); err != nil {
|
||||
ctx.ServerError("UpdateRepoUnit", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -59,7 +59,6 @@ func CorsHandler() func(next http.Handler) http.Handler {
|
||||
// httpBase does the common work for git http services,
|
||||
// including early response, authentication, repository lookup and permission check.
|
||||
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
username := ctx.PathParam("username")
|
||||
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
||||
|
||||
if ctx.FormString("go-get") == "1" {
|
||||
@ -131,10 +130,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
|
||||
// Only public pull don't need auth.
|
||||
isPublicPull := repoExist && !repo.IsPrivate && isPull
|
||||
var (
|
||||
askAuth = !isPublicPull || setting.Service.RequireSignInViewStrict
|
||||
environ []string
|
||||
)
|
||||
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
|
||||
|
||||
// don't allow anonymous pulls if organization is not public
|
||||
if isPublicPull {
|
||||
@ -184,21 +180,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
return nil
|
||||
}
|
||||
|
||||
environ = []string{
|
||||
repo_module.EnvRepoUsername + "=" + username,
|
||||
repo_module.EnvRepoName + "=" + reponame,
|
||||
repo_module.EnvPusherName + "=" + ctx.Doer.Name,
|
||||
repo_module.EnvPusherID + fmt.Sprintf("=%d", ctx.Doer.ID),
|
||||
repo_module.EnvAppURL + "=" + setting.AppURL,
|
||||
}
|
||||
|
||||
if repoExist {
|
||||
// Because of special ref "refs/for" (agit) , need delay write permission check
|
||||
if git.DefaultFeatures().SupportProcReceive {
|
||||
accessMode = perm.AccessModeRead
|
||||
}
|
||||
|
||||
if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
|
||||
taskID, isActionsUser := user_model.GetActionsUserTaskID(ctx.Doer)
|
||||
if isActionsUser {
|
||||
p, err := access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetActionsUserRepoPermission", err)
|
||||
@ -209,7 +198,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
ctx.PlainText(http.StatusNotFound, "Repository not found")
|
||||
return nil
|
||||
}
|
||||
environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, p.UnitAccessMode(unitType)))
|
||||
} else {
|
||||
p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
@ -228,16 +216,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if !ctx.Doer.KeepEmailPrivate {
|
||||
environ = append(environ, repo_module.EnvPusherEmail+"="+ctx.Doer.Email)
|
||||
}
|
||||
|
||||
if isWiki {
|
||||
environ = append(environ, repo_module.EnvRepoIsWiki+"=true")
|
||||
} else {
|
||||
environ = append(environ, repo_module.EnvRepoIsWiki+"=false")
|
||||
}
|
||||
}
|
||||
|
||||
if !repoExist {
|
||||
@ -286,7 +264,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
}
|
||||
}
|
||||
|
||||
environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID))
|
||||
var environ []string
|
||||
if !isPull {
|
||||
// if not "pull", then must be "push", and doer must exist
|
||||
environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki)
|
||||
}
|
||||
|
||||
return &serviceHandler{serviceType, repo, isWiki, environ}
|
||||
}
|
||||
|
||||
@ -8,11 +8,13 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/actions"
|
||||
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"
|
||||
shared_actions "code.gitea.io/gitea/routers/web/shared/actions"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
@ -34,8 +36,31 @@ func ActionsGeneralSettings(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
actionsCfg := actionsUnit.ActionsConfig()
|
||||
|
||||
// Token permission settings
|
||||
ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive
|
||||
ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted
|
||||
|
||||
// Follow owner config (only for repos in orgs)
|
||||
ctx.Data["OverrideOwnerConfig"] = actionsCfg.OverrideOwnerConfig
|
||||
if actionsCfg.OverrideOwnerConfig {
|
||||
ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions()
|
||||
ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode
|
||||
ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil
|
||||
} else {
|
||||
ownerActionsConfig, err := actions.GetOwnerActionsConfig(ctx, ctx.Repo.Repository.OwnerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOwnerActionsConfig", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["MaxTokenPermissions"] = ownerActionsConfig.GetMaxTokenPermissions()
|
||||
ctx.Data["TokenPermissionMode"] = ownerActionsConfig.TokenPermissionMode
|
||||
ctx.Data["EnableMaxTokenPermissions"] = ownerActionsConfig.MaxTokenPermissions != nil
|
||||
}
|
||||
|
||||
if ctx.Repo.Repository.IsPrivate {
|
||||
collaborativeOwnerIDs := actionsUnit.ActionsConfig().CollaborativeOwnerIDs
|
||||
collaborativeOwnerIDs := actionsCfg.CollaborativeOwnerIDs
|
||||
collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUsersByIDs", err)
|
||||
@ -89,8 +114,8 @@ func AddCollaborativeOwner(ctx *context.Context) {
|
||||
}
|
||||
actionsCfg := actionsUnit.ActionsConfig()
|
||||
actionsCfg.AddCollaborativeOwner(ownerID)
|
||||
if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil {
|
||||
ctx.ServerError("UpdateRepoUnit", err)
|
||||
if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil {
|
||||
ctx.ServerError("UpdateRepoUnitConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -112,10 +137,59 @@ func DeleteCollaborativeOwner(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
actionsCfg.RemoveCollaborativeOwner(ownerID)
|
||||
if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil {
|
||||
ctx.ServerError("UpdateRepoUnit", err)
|
||||
if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil {
|
||||
ctx.ServerError("UpdateRepoUnitConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
// UpdateTokenPermissions updates the token permission settings for the repository
|
||||
func UpdateTokenPermissions(ctx *context.Context) {
|
||||
redirectURL := ctx.Repo.RepoLink + "/settings/actions/general"
|
||||
|
||||
actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUnit", err)
|
||||
return
|
||||
}
|
||||
|
||||
actionsCfg := actionsUnit.ActionsConfig()
|
||||
|
||||
// Update Override Owner Config (for repos in orgs)
|
||||
// If checked, it means we WANT to override (opt-out of following)
|
||||
actionsCfg.OverrideOwnerConfig = ctx.FormBool("override_owner_config")
|
||||
|
||||
// Update permission mode (only if overriding owner config)
|
||||
shouldUpdate := actionsCfg.OverrideOwnerConfig
|
||||
|
||||
if shouldUpdate {
|
||||
permissionMode, permissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")))
|
||||
if !permissionModeValid {
|
||||
ctx.Flash.Error("Invalid token permission mode")
|
||||
ctx.Redirect(redirectURL)
|
||||
return
|
||||
}
|
||||
actionsCfg.TokenPermissionMode = permissionMode
|
||||
}
|
||||
|
||||
// Update Maximum Permissions (radio buttons: none/read/write)
|
||||
enableMaxPermissions := ctx.FormBool("enable_max_permissions")
|
||||
if shouldUpdate {
|
||||
if enableMaxPermissions {
|
||||
actionsCfg.MaxTokenPermissions = shared_actions.ParseMaxTokenPermissions(ctx)
|
||||
} else {
|
||||
// If not enabled, ensure any sent permissions are ignored and set to nil
|
||||
actionsCfg.MaxTokenPermissions = nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil {
|
||||
ctx.ServerError("UpdateRepoUnitConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||
ctx.Redirect(redirectURL)
|
||||
}
|
||||
|
||||
160
routers/web/shared/actions/general.go
Normal file
160
routers/web/shared/actions/general.go
Normal file
@ -0,0 +1,160 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplOrgSettingsActionsGeneral templates.TplName = "org/settings/actions_general"
|
||||
tplUserSettingsActionsGeneral templates.TplName = "user/settings/actions_general"
|
||||
)
|
||||
|
||||
// ParseMaxTokenPermissions parses the maximum token permissions from form values
|
||||
func ParseMaxTokenPermissions(ctx *context.Context) *repo_model.ActionsTokenPermissions {
|
||||
parseMaxPerm := func(unitType unit.Type) perm.AccessMode {
|
||||
value := ctx.FormString("max_unit_access_mode_" + strconv.Itoa(int(unitType)))
|
||||
switch value {
|
||||
case "write":
|
||||
return perm.AccessModeWrite
|
||||
case "read":
|
||||
return perm.AccessModeRead
|
||||
default:
|
||||
return perm.AccessModeNone
|
||||
}
|
||||
}
|
||||
ret := new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
for _, ut := range repo_model.ActionsTokenUnitTypes {
|
||||
ret.UnitAccessModes[ut] = parseMaxPerm(ut)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// GeneralSettings renders the actions general settings page
|
||||
func GeneralSettings(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("actions.actions")
|
||||
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if rCtx.IsOrg {
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsOrgSettingsActionsGeneral"] = true
|
||||
} else if rCtx.IsUser {
|
||||
ctx.Data["PageIsUserSettings"] = true
|
||||
ctx.Data["PageIsUserSettingsActionsGeneral"] = true
|
||||
} else {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Load User/Org Actions Config
|
||||
actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOwnerActionsConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode
|
||||
ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive
|
||||
ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted
|
||||
ctx.Data["MaxTokenPermissions"] = actionsCfg.MaxTokenPermissions
|
||||
if actionsCfg.MaxTokenPermissions == nil {
|
||||
ctx.Data["MaxTokenPermissions"] = (&repo_model.ActionsConfig{}).GetMaxTokenPermissions()
|
||||
}
|
||||
ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil
|
||||
|
||||
// Load Allowed Repositories
|
||||
allowedRepos, err := repo_model.GetOwnerRepositoriesByIDs(ctx, rCtx.OwnerID, actionsCfg.AllowedCrossRepoIDs)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOwnerRepositoriesByIDs", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["AllowedRepos"] = allowedRepos
|
||||
ctx.Data["OwnerID"] = rCtx.OwnerID
|
||||
|
||||
if rCtx.IsOrg {
|
||||
ctx.HTML(http.StatusOK, tplOrgSettingsActionsGeneral)
|
||||
} else {
|
||||
ctx.HTML(http.StatusOK, tplUserSettingsActionsGeneral)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateGeneralSettings responses for actions general settings page
|
||||
func UpdateGeneralSettings(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !rCtx.IsOrg && !rCtx.IsUser {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOwnerActionsConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.FormBool("cross_repo_add_target") {
|
||||
targetRepoName := ctx.FormString("cross_repo_add_target_name")
|
||||
if targetRepoName != "" {
|
||||
targetRepo, err := repo_model.GetRepositoryByName(ctx, rCtx.OwnerID, targetRepoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.JSONError("Repository doesn't exist")
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetRepositoryByName", err)
|
||||
return
|
||||
}
|
||||
if !slices.Contains(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID) {
|
||||
actionsCfg.AllowedCrossRepoIDs = append(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if crossRepoRemoveTargetID := ctx.FormInt64("cross_repo_remove_target_id"); crossRepoRemoveTargetID != 0 {
|
||||
actionsCfg.AllowedCrossRepoIDs = util.SliceRemoveAll(actionsCfg.AllowedCrossRepoIDs, crossRepoRemoveTargetID)
|
||||
}
|
||||
|
||||
// Update Token Permission Mode
|
||||
tokenPermissionMode, tokenPermissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")))
|
||||
if tokenPermissionModeValid {
|
||||
actionsCfg.TokenPermissionMode = tokenPermissionMode
|
||||
enableMaxPermissions := ctx.FormBool("enable_max_permissions")
|
||||
// Update Maximum Permissions (radio buttons: none/read/write)
|
||||
if enableMaxPermissions {
|
||||
actionsCfg.MaxTokenPermissions = ParseMaxTokenPermissions(ctx)
|
||||
} else {
|
||||
actionsCfg.MaxTokenPermissions = nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := actions_model.SetOwnerActionsConfig(ctx, rCtx.OwnerID, actionsCfg); err != nil {
|
||||
ctx.ServerError("SetOwnerActionsConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.saved_successfully"))
|
||||
ctx.JSONRedirect("") // use JSONRedirect because frontend uses form-fetch-action
|
||||
}
|
||||
@ -5,6 +5,7 @@ package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
@ -58,8 +59,7 @@ func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
|
||||
|
||||
if ctx.Data["PageIsOrgSettings"] == true {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError
|
||||
return nil, fmt.Errorf("RenderUserOrgHeader: %w", err)
|
||||
}
|
||||
return &runnersCtx{
|
||||
RepoID: 0,
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func RedirectToDefaultSetting(ctx *context.Context) {
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/actions/runners")
|
||||
}
|
||||
@ -33,7 +33,6 @@ import (
|
||||
"code.gitea.io/gitea/routers/web/healthcheck"
|
||||
"code.gitea.io/gitea/routers/web/misc"
|
||||
"code.gitea.io/gitea/routers/web/org"
|
||||
org_setting "code.gitea.io/gitea/routers/web/org/setting"
|
||||
"code.gitea.io/gitea/routers/web/repo"
|
||||
"code.gitea.io/gitea/routers/web/repo/actions"
|
||||
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
|
||||
@ -693,7 +692,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
}, packagesEnabled)
|
||||
|
||||
m.Group("/actions", func() {
|
||||
m.Get("", user_setting.RedirectToDefaultSetting)
|
||||
m.Get("", misc.LocationRedirect("./actions/general"))
|
||||
m.Group("/general", func() {
|
||||
m.Get("", shared_actions.GeneralSettings)
|
||||
m.Post("", shared_actions.UpdateGeneralSettings)
|
||||
})
|
||||
addSettingsRunnersRoutes()
|
||||
addSettingsSecretsRoutes()
|
||||
addSettingsVariablesRoutes()
|
||||
@ -846,7 +849,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
}, oauth2Enabled)
|
||||
|
||||
m.Group("/actions", func() {
|
||||
m.Get("", admin.RedirectToDefaultSetting)
|
||||
m.Get("", misc.LocationRedirect("./actions/runners"))
|
||||
addSettingsRunnersRoutes()
|
||||
addSettingsVariablesRoutes()
|
||||
})
|
||||
@ -998,7 +1001,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
})
|
||||
|
||||
m.Group("/actions", func() {
|
||||
m.Get("", org_setting.RedirectToDefaultSetting)
|
||||
m.Get("", misc.LocationRedirect("./actions/general"))
|
||||
m.Group("/general", func() {
|
||||
m.Get("", shared_actions.GeneralSettings)
|
||||
m.Post("", shared_actions.UpdateGeneralSettings)
|
||||
})
|
||||
addSettingsRunnersRoutes()
|
||||
addSettingsSecretsRoutes()
|
||||
addSettingsVariablesRoutes()
|
||||
@ -1202,9 +1209,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Group("/actions/general", func() {
|
||||
m.Get("", repo_setting.ActionsGeneralSettings)
|
||||
m.Post("/actions_unit", repo_setting.ActionsUnitPost)
|
||||
})
|
||||
}) // doesn't require actions enabled
|
||||
m.Group("/actions", func() {
|
||||
m.Get("", shared_actions.RedirectToDefaultSetting)
|
||||
m.Get("", misc.LocationRedirect("./actions/general"))
|
||||
addSettingsRunnersRoutes()
|
||||
addSettingsSecretsRoutes()
|
||||
addSettingsVariablesRoutes()
|
||||
@ -1213,6 +1220,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/add", repo_setting.AddCollaborativeOwner)
|
||||
m.Post("/delete", repo_setting.DeleteCollaborativeOwner)
|
||||
})
|
||||
m.Post("/token_permissions", repo_setting.UpdateTokenPermissions)
|
||||
})
|
||||
}, actions.MustEnableActions)
|
||||
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
|
||||
@ -1729,8 +1737,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Any("/mail-preview", devtest.MailPreview)
|
||||
m.Any("/mail-preview/*", devtest.MailPreviewRender)
|
||||
m.Any("/{sub}", devtest.TmplCommon)
|
||||
m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
|
||||
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
|
||||
m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
|
||||
m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
|
||||
m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
141
services/actions/permission_parser.go
Normal file
141
services/actions/permission_parser.go
Normal file
@ -0,0 +1,141 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/actions/jobparser"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// ExtractJobPermissionsFromWorkflow extracts permissions from an already parsed workflow/job.
|
||||
// It returns nil if neither workflow nor job explicitly specifies permissions.
|
||||
func ExtractJobPermissionsFromWorkflow(flow *jobparser.SingleWorkflow, job *jobparser.Job) *repo_model.ActionsTokenPermissions {
|
||||
if flow == nil || job == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobPerms := parseRawPermissionsExplicit(&job.RawPermissions)
|
||||
if jobPerms != nil {
|
||||
return jobPerms
|
||||
}
|
||||
|
||||
workflowPerms := parseRawPermissionsExplicit(&flow.RawPermissions)
|
||||
if workflowPerms != nil {
|
||||
return workflowPerms
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseRawPermissionsExplicit parses a YAML permissions node and returns only explicit scopes.
|
||||
// It returns nil if the node does not explicitly specify permissions.
|
||||
func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPermissions {
|
||||
if rawPerms == nil || (rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unwrap DocumentNode and resolve AliasNode
|
||||
node := rawPerms
|
||||
for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode {
|
||||
if node.Kind == yaml.DocumentNode {
|
||||
if len(node.Content) == 0 {
|
||||
return nil
|
||||
}
|
||||
node = node.Content[0]
|
||||
} else {
|
||||
node = node.Alias
|
||||
}
|
||||
}
|
||||
|
||||
if node.Kind == yaml.ScalarNode && node.Value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle scalar values: "read-all" or "write-all"
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
switch node.Value {
|
||||
case "read-all":
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
|
||||
case "write-all":
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite))
|
||||
default:
|
||||
// Explicit but unrecognized scalar: return all-none permissions.
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mapping: individual permission scopes
|
||||
if node.Kind == yaml.MappingNode {
|
||||
result := repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)
|
||||
|
||||
// Collect all scopes into a map first to handle priority
|
||||
scopes := make(map[string]perm.AccessMode)
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
if i+1 >= len(node.Content) {
|
||||
break
|
||||
}
|
||||
keyNode := node.Content[i]
|
||||
valueNode := node.Content[i+1]
|
||||
|
||||
if keyNode.Kind != yaml.ScalarNode || valueNode.Kind != yaml.ScalarNode {
|
||||
continue
|
||||
}
|
||||
|
||||
scopes[keyNode.Value] = parseAccessMode(valueNode.Value)
|
||||
}
|
||||
|
||||
// 1. Apply 'contents' first (lower priority)
|
||||
if mode, ok := scopes["contents"]; ok {
|
||||
result.UnitAccessModes[unit.TypeCode] = mode
|
||||
result.UnitAccessModes[unit.TypeReleases] = mode
|
||||
}
|
||||
|
||||
// 2. Apply all other scopes (overwrites contents if specified)
|
||||
for scope, mode := range scopes {
|
||||
switch scope {
|
||||
case "contents":
|
||||
// already handled
|
||||
case "code":
|
||||
result.UnitAccessModes[unit.TypeCode] = mode
|
||||
case "issues":
|
||||
result.UnitAccessModes[unit.TypeIssues] = mode
|
||||
case "pull-requests":
|
||||
result.UnitAccessModes[unit.TypePullRequests] = mode
|
||||
case "packages":
|
||||
result.UnitAccessModes[unit.TypePackages] = mode
|
||||
case "actions":
|
||||
result.UnitAccessModes[unit.TypeActions] = mode
|
||||
case "wiki":
|
||||
result.UnitAccessModes[unit.TypeWiki] = mode
|
||||
case "releases":
|
||||
result.UnitAccessModes[unit.TypeReleases] = mode
|
||||
case "projects":
|
||||
result.UnitAccessModes[unit.TypeProjects] = mode
|
||||
default:
|
||||
setting.PanicInDevOrTesting("Unrecognized permission scope: %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseAccessMode converts a string access level to perm.AccessMode
|
||||
func parseAccessMode(s string) perm.AccessMode {
|
||||
switch s {
|
||||
case "write":
|
||||
return perm.AccessModeWrite
|
||||
case "read":
|
||||
return perm.AccessModeRead
|
||||
default:
|
||||
return perm.AccessModeNone
|
||||
}
|
||||
}
|
||||
196
services/actions/permission_parser_test.go
Normal file
196
services/actions/permission_parser_test.go
Normal file
@ -0,0 +1,196 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/actions/jobparser"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestParseRawPermissions_ReadAll(t *testing.T) {
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(`read-all`), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePullRequests])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeWiki])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeProjects])
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_WriteAll(t *testing.T) {
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(`write-all`), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePullRequests])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeProjects])
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_IndividualScopes(t *testing.T) {
|
||||
yamlContent := `
|
||||
contents: write
|
||||
issues: read
|
||||
pull-requests: none
|
||||
packages: write
|
||||
actions: read
|
||||
wiki: write
|
||||
projects: none
|
||||
`
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypePullRequests])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeProjects])
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_Priority(t *testing.T) {
|
||||
t.Run("granular-wins-over-contents", func(t *testing.T) {
|
||||
yamlContent := `
|
||||
contents: read
|
||||
code: write
|
||||
releases: none
|
||||
`
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeReleases])
|
||||
})
|
||||
|
||||
t.Run("contents-applied-first", func(t *testing.T) {
|
||||
yamlContent := `
|
||||
code: none
|
||||
releases: write
|
||||
contents: read
|
||||
`
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// code: none should win over contents: read
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeCode])
|
||||
// releases: write should win over contents: read
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeReleases])
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_EmptyNode(t *testing.T) {
|
||||
var rawPerms yaml.Node
|
||||
// Empty node
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
|
||||
// Should return nil for non-explicit
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_NilNode(t *testing.T) {
|
||||
result := parseRawPermissionsExplicit(nil)
|
||||
|
||||
// Should return nil
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestParseAccessMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected perm.AccessMode
|
||||
}{
|
||||
{"write", perm.AccessModeWrite},
|
||||
{"read", perm.AccessModeRead},
|
||||
{"none", perm.AccessModeNone},
|
||||
{"", perm.AccessModeNone},
|
||||
{"invalid", perm.AccessModeNone},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := parseAccessMode(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJobPermissionsFromWorkflow(t *testing.T) {
|
||||
workflowYAML := `
|
||||
name: Test Permissions
|
||||
on: workflow_dispatch
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
job-read-only:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Full read-only"
|
||||
|
||||
job-none-perms:
|
||||
permissions: none
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Full read-only"
|
||||
|
||||
job-override:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- run: echo "Override to write"
|
||||
`
|
||||
|
||||
expectedPerms := map[string]*repo_model.ActionsTokenPermissions{}
|
||||
expectedPerms["job-read-only"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
|
||||
expectedPerms["job-none-perms"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
expectedPerms["job-override"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
expectedPerms["job-override"].UnitAccessModes[unit.TypeCode] = perm.AccessModeWrite
|
||||
expectedPerms["job-override"].UnitAccessModes[unit.TypeReleases] = perm.AccessModeWrite
|
||||
|
||||
singleWorkflows, err := jobparser.Parse([]byte(workflowYAML))
|
||||
require.NoError(t, err)
|
||||
for _, flow := range singleWorkflows {
|
||||
jobID, jobDef := flow.Job()
|
||||
require.NotNil(t, jobDef)
|
||||
t.Run(jobID, func(t *testing.T) {
|
||||
assert.Equal(t, expectedPerms[jobID], ExtractJobPermissionsFromWorkflow(flow, jobDef))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -103,6 +103,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar
|
||||
|
||||
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
|
||||
var hasWaitingJobs bool
|
||||
|
||||
for _, v := range jobs {
|
||||
id, job := v.Job()
|
||||
needs := job.Needs()
|
||||
@ -127,6 +128,11 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar
|
||||
RunsOn: job.RunsOn(),
|
||||
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
|
||||
}
|
||||
// Parse workflow/job permissions (no clamping here)
|
||||
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
|
||||
runJob.TokenPermissions = perms
|
||||
}
|
||||
|
||||
// check job concurrency
|
||||
if job.RawConcurrency != nil {
|
||||
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
|
||||
|
||||
123
services/actions/token_permission_design.md
Normal file
123
services/actions/token_permission_design.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Actions Token Permission System Design
|
||||
|
||||
This document details the design of the Actions Token Permission system within Gitea, originally proposed in [#24635](https://github.com/go-gitea/gitea/issues/24635).
|
||||
|
||||
## Design Philosophy & GitHub Differences
|
||||
|
||||
Gitea Actions uses a **strict clamping mechanism** for token permissions.
|
||||
While workflows can request explicit permissions that exceed the repository's default baseline
|
||||
(e.g., requesting `write` when the default mode is `Restricted`),
|
||||
these requests are always bounded by a hard ceiling.
|
||||
|
||||
The maximum allowable permissions (`MaxTokenPermissions`) are set at the Repository or Organization level.
|
||||
**Any permissions requested by a workflow are strictly clamped by this ceiling policy.**
|
||||
This ensures that workflows cannot bypass organizational or repository-level security restrictions.
|
||||
|
||||
## Terminology
|
||||
|
||||
### 1. `GITEA_TOKEN`
|
||||
- The automatic token generated for each Actions job.
|
||||
- Its permissions (read/write/none) are scoped to the repository and specific features (Code, Issues, etc.).
|
||||
|
||||
### 2. Token Permission Mode
|
||||
- The default access level granted to a token when no explicit `permissions:` block is present in a workflow.
|
||||
- **Permissive**: Grants `write` access to most repository scopes by default.
|
||||
- **Restricted**: Grants `read` access (or none) to repository scopes by default.
|
||||
|
||||
### 3. Actions Token Permissions
|
||||
- A structure representing the granular permission scopes available to a token.
|
||||
- Includes scopes like: Code, Releases (both grouped under `contents` in workflow syntax),
|
||||
Issues, PullRequests, Actions, Wiki, and Projects.
|
||||
- **Note**: The `Packages` scope is supported in workflow/job `permissions:` blocks
|
||||
but is currently hidden from the settings UI.
|
||||
|
||||
### 4. Cross-Repository Access
|
||||
- By default, a token can access the repository where the workflow is running,
|
||||
as well as any **public repositories (read-only)** on the instance.
|
||||
- Users and organizations can configure an `AllowedCrossRepoIDs` list in their owner-level settings
|
||||
to grant the token **read-only** access to other private/internal repositories they own.
|
||||
- If the `AllowedCrossRepoIDs` list is empty, there is no cross-repository access
|
||||
to other private repositories (default for enhanced security).
|
||||
- In any configuration, individual jobs can disable or limit cross-repo access
|
||||
by explicitly restricting their permissions (e.g., `permissions: none`).
|
||||
- **Note on Forks**: Cross-repository access to private repositories is fundamentally denied
|
||||
for workflows triggered by fork pull requests (see [Special Cases](#2-fork-pull-requests)).
|
||||
|
||||
## Token Lifecycle & Permission Evaluation
|
||||
|
||||
When a job starts, Gitea evaluates the requested permissions for the `GITEA_TOKEN` through a multistep clamping process:
|
||||
|
||||
### Step 1: Determine Base Permissions From Workflow
|
||||
- If the job explicitly specifies a valid `permissions:` block, Gitea parses it.
|
||||
- If the job inherits a top-level `permissions:` block, Gitea parses that.
|
||||
- If an invalid or unparseable `permissions:` block is specified, or no explicit permissions are defined at all,
|
||||
Gitea falls back to using the repository's default `TokenPermissionMode` (Permissive or Restricted)
|
||||
to generate base permissions.
|
||||
|
||||
### Step 2: Apply Repository Clamping
|
||||
- Repositories can define `MaxTokenPermissions` in their Actions settings.
|
||||
- The base permissions from Step 1 are clamped against these maximum allowed permissions.
|
||||
- If the repository says `Issues: read` and the workflow requests `Issues: write`, the final token gets `Issues: read`.
|
||||
|
||||
### Step 3: Apply Organization/User Clamping (Hierarchical Override)
|
||||
- The organization (or user) has an owner-level configuration (`UserActionsConfig`) containing `MaxTokenPermissions`,
|
||||
and these restrictions cascade down.
|
||||
- The repository's clamping limits cannot exceed the owner's limits
|
||||
UNLESS the repository explicitly enables `OverrideOwnerConfig`.
|
||||
- If `OverrideOwnerConfig` is false, and the owner sets `MaxTokenPermissions` to `read` for all scopes,
|
||||
no repository under that owner can grant `write` access, regardless of their own settings or the workflow's request.
|
||||
|
||||
## Parsing Priority for "contents" Scope
|
||||
|
||||
In GitHub Actions compatibility, the `contents` scope maps to multiple granular scopes in Gitea.
|
||||
- `contents: write` maps to `Code: write` and `Releases: write`.
|
||||
- When a workflow specifies both `contents` and a more granular scope (e.g., `code`),
|
||||
the granular scope takes absolute priority.
|
||||
|
||||
**Example YAML**:
|
||||
```yaml
|
||||
permissions:
|
||||
contents: write
|
||||
code: read
|
||||
```
|
||||
**Result**: The token gets `Code: read` (from granular) and `Releases: write` (from contents).
|
||||
|
||||
## Special Cases & Edge Scenarios
|
||||
|
||||
### 1. Empty Permissions Mapping (`permissions: {}`)
|
||||
- Explicitly setting an empty mapping means "revoke all permissions".
|
||||
- The token gets `none` for all scopes.
|
||||
|
||||
### 2. Fork Pull Requests
|
||||
- Workflows triggered by Pull Requests from forks inherently operate in `Restricted` mode for security reasons.
|
||||
- The base permissions for the current repository are automatically downgraded to `read` (or `none`),
|
||||
preventing untrusted code from modifying the repository.
|
||||
- **Cross-Repo Access in Forks**: For workflows triggered by fork pull requests, cross-repository access
|
||||
to other private repositories is strictly denied, regardless of the `AllowedCrossRepoIDs` configuration.
|
||||
Fork PRs can only read the target repository and truly public repositories.
|
||||
|
||||
### 3. Public Repositories in Cross-Repo Access
|
||||
- As mentioned in Cross-Repository Access, truly public repositories can always be read by the token,
|
||||
regardless of the `AllowedCrossRepoIDs` setting. The allowed list only governs access
|
||||
to private/internal repositories owned by the same user or organization.
|
||||
|
||||
## Packages Registry
|
||||
|
||||
"Packages" belong to "owner" but not "repository". Although there is a function "linking a package to a repository",
|
||||
in most cases it doesn't really work. When accessing a package, usually there is no information about a repository.
|
||||
So the "packages" permission should be designed separately from other permissions.
|
||||
|
||||
A possible approach is like this: let owner set packages permissions, and make the repositories follow.
|
||||
|
||||
- On owner-level:
|
||||
- Add a "Packages" permission section
|
||||
- "Default permissions for all repositories" can be set to none/read/write
|
||||
- Set different permissions for selected repositories (if needed), like the "Collaborators" permission setting
|
||||
|
||||
- On repository-level:
|
||||
- Now a repository can have "Packages" permission
|
||||
- The repository-level "Packages" permission is clamped by the owner-level "Packages" permission
|
||||
- If the owner-level "Packages" permission for this repository is read,
|
||||
then the repository cannot set its "Packages" permission to write
|
||||
|
||||
Maybe reusing the "org teams" permission system is a good choice: bind a repository's Actions token to a team.
|
||||
@ -41,7 +41,7 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
|
||||
cfg.DisableWorkflow(workflow.ID)
|
||||
}
|
||||
|
||||
return repo_model.UpdateRepoUnit(ctx, cfgUnit)
|
||||
return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit)
|
||||
}
|
||||
|
||||
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) {
|
||||
|
||||
@ -296,7 +296,7 @@ func fixBrokenRepoUnits16961(ctx context.Context, logger log.Logger, autofix boo
|
||||
return nil
|
||||
}
|
||||
|
||||
return repo_model.UpdateRepoUnit(ctx, repoUnit)
|
||||
return repo_model.UpdateRepoUnitConfig(ctx, repoUnit)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@ -26,7 +26,7 @@ func TestIsUserAllowedToUpdate(t *testing.T) {
|
||||
setRepoAllowRebaseUpdate := func(t *testing.T, repoID int64, allow bool) {
|
||||
repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: repoID, Type: unit.TypePullRequests})
|
||||
repoUnit.PullRequestsConfig().AllowRebaseUpdate = allow
|
||||
require.NoError(t, repo_model.UpdateRepoUnit(t.Context(), repoUnit))
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoUnit))
|
||||
}
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
@ -246,6 +247,19 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
|
||||
return fmt.Errorf("recalculateAccesses: %w", err)
|
||||
}
|
||||
|
||||
// Remove repository from old owner's Actions AllowedCrossRepoIDs if present
|
||||
if oldActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, oldOwner.ID); err == nil {
|
||||
newAllowedCrossRepoIDs := util.SliceRemoveAll(oldActionsCfg.AllowedCrossRepoIDs, repo.ID)
|
||||
if len(newAllowedCrossRepoIDs) != len(oldActionsCfg.AllowedCrossRepoIDs) {
|
||||
oldActionsCfg.AllowedCrossRepoIDs = newAllowedCrossRepoIDs
|
||||
if err := actions_model.SetOwnerActionsConfig(ctx, oldOwner.ID, oldActionsCfg); err != nil {
|
||||
return fmt.Errorf("SetOwnerActionsConfig: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("GetOwnerActionsConfig: %w", err)
|
||||
}
|
||||
|
||||
// Update repository count.
|
||||
if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil {
|
||||
return fmt.Errorf("increase new owner repository count: %w", err)
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content">
|
||||
<div class="tw-flex tw-justify-center tw-items-center tw-gap-5">
|
||||
<a href="/devtest/repo-action-view/runs/10/jobs/0">Run:CanCancel</a>
|
||||
<a href="/devtest/repo-action-view/runs/20/jobs/1">Run:CanApprove</a>
|
||||
<a href="/devtest/repo-action-view/runs/30/jobs/2">Run:CanRerun</a>
|
||||
<div class="flex-text-block tw-justify-center tw-gap-5">
|
||||
<a href="/devtest/repo-action-view/runs/10">Run:CanCancel</a>
|
||||
<a href="/devtest/repo-action-view/runs/20">Run:CanApprove</a>
|
||||
<a href="/devtest/repo-action-view/runs/30">Run:CanRerun</a>
|
||||
</div>
|
||||
{{template "repo/actions/view_component" (dict
|
||||
"RunID" (or .RunID 10)
|
||||
"JobID" (or .JobID 0)
|
||||
"ActionsURL" (print AppSubUrl "/devtest/actions-mock")
|
||||
"ActionsURL" (print AppSubUrl "/devtest/repo-action-view")
|
||||
)}}
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
|
||||
5
templates/org/settings/actions_general.tmpl
Normal file
5
templates/org/settings/actions_general.tmpl
Normal file
@ -0,0 +1,5 @@
|
||||
{{template "org/settings/layout_head" (dict "ctxData" .)}}
|
||||
<div class="org-setting-content">
|
||||
{{template "shared/actions/owner_general_settings" .}}
|
||||
</div>
|
||||
{{template "org/settings/layout_footer" .}}
|
||||
@ -26,9 +26,12 @@
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .EnableActions}}
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<details class="item toggleable-item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsOrgSettingsActionsGeneral}}active {{end}}item" href="{{.OrgLink}}/settings/actions">
|
||||
{{ctx.Locale.Tr "settings.general"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.OrgLink}}/settings/actions/runners">
|
||||
{{ctx.Locale.Tr "actions.runners"}}
|
||||
</a>
|
||||
|
||||
@ -12,6 +12,10 @@
|
||||
data-locale-runs-commit="{{ctx.Locale.Tr "actions.runs.commit"}}"
|
||||
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
|
||||
data-locale-runs-workflow-graph="{{ctx.Locale.Tr "actions.runs.workflow_graph"}}"
|
||||
data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}"
|
||||
data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}"
|
||||
data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"
|
||||
data-locale-total-duration="{{ctx.Locale.Tr "actions.runs.total_duration"}}"
|
||||
data-locale-status-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}"
|
||||
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
|
||||
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
{{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}}
|
||||
<div class="repo-setting-content">
|
||||
<!-- Enable/Disable Actions Section (First) -->
|
||||
<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">
|
||||
{{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}}
|
||||
{{$isActionsGlobalDisabled := ctx.Consts.RepoUnitTypeActions.UnitGlobalDisabled}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "actions.actions"}}</label>
|
||||
@ -22,8 +23,40 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}}
|
||||
{{if $isActionsEnabled}}
|
||||
<!-- Token Permissions Section -->
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "actions.general.permissions"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" action="{{.RepoLink}}/settings/actions/general/token_permissions" method="post" data-global-init="initRepoActionsPermissionsForm">
|
||||
<!-- Override Owner Configuration -->
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="override_owner_config" {{if .OverrideOwnerConfig}}checked{{end}}>
|
||||
<label><strong>{{ctx.Locale.Tr "actions.general.token_permissions.override_owner"}}</strong></label>
|
||||
</div>
|
||||
<div class="help">{{ctx.Locale.Tr "actions.general.token_permissions.override_owner_desc"}}</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="field js-repo-token-permissions-config">
|
||||
{{template "shared/actions/permission_mode_select" .}}
|
||||
<div class="divider"></div>
|
||||
{{template "shared/actions/permissions_table" .}}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if $isActionsEnabled}}
|
||||
{{if .Repository.IsPrivate}}
|
||||
<!-- Collaborative Owners Section -->
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "actions.general.collaborative_owners_management"}}
|
||||
</h4>
|
||||
|
||||
58
templates/shared/actions/owner_general_settings.tmpl
Normal file
58
templates/shared/actions/owner_general_settings.tmpl
Normal file
@ -0,0 +1,58 @@
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "actions.general.cross_repo"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form form-fetch-action " action="{{.Link}}" method="post">
|
||||
<!-- Cross-Repository Access -->
|
||||
<div class="help">{{ctx.Locale.Tr "actions.general.cross_repo_desc"}}</div>
|
||||
|
||||
<!-- Allowed Repositories List -->
|
||||
<div class="field tw-mt-4">
|
||||
<h5 class="ui header">
|
||||
{{ctx.Locale.Tr "actions.general.cross_repo_target_repos"}}
|
||||
</h5>
|
||||
<div class="ui attached segment tw-p-2">
|
||||
<div class="ui divided relaxed list flex-items-block muted-links">
|
||||
{{range $repo := .AllowedRepos}}
|
||||
<div class="item">
|
||||
{{template "repo/icon" $repo}}
|
||||
<a class="tw-flex-1" href="{{$repo.Link}}">{{$repo.FullName}}</a>
|
||||
<button class="ui red compact tiny button link-action" type="button" data-url="?cross_repo_remove_target_id={{$repo.ID}}">{{ctx.Locale.Tr "remove"}}</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="item">
|
||||
{{ctx.Locale.Tr "org.repos.none"}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="ui header">
|
||||
{{ctx.Locale.Tr "actions.general.cross_repo_add"}}
|
||||
</h5>
|
||||
<div class="flex-text-block">
|
||||
<div data-global-init="initSearchRepoBox" data-uid="{{.OwnerID}}" data-exclusive="true" class="ui search tw-flex-1">
|
||||
<div class="ui input">
|
||||
<input class="prompt" name="cross_repo_add_target_name" required placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit" name="cross_repo_add_target" value="true">{{ctx.Locale.Tr "add"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "actions.general.permissions"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form form-fetch-action" action="{{.Link}}" method="post" data-global-init="initOwnerActionsPermissionsForm">
|
||||
{{template "shared/actions/permission_mode_select" .}}
|
||||
<div class="divider"></div>
|
||||
{{template "shared/actions/permissions_table" .}}
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
18
templates/shared/actions/permission_mode_select.tmpl
Normal file
18
templates/shared/actions/permission_mode_select.tmpl
Normal file
@ -0,0 +1,18 @@
|
||||
<div class="field js-permission-mode-section">
|
||||
<label>{{ctx.Locale.Tr "actions.general.token_permissions.mode"}}</label>
|
||||
<div class="help">{{ctx.Locale.Tr "actions.general.token_permissions.mode.desc"}}</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="token_permission_mode" value="{{.TokenPermissionModePermissive}}" {{if eq .TokenPermissionMode .TokenPermissionModePermissive}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "actions.general.token_permissions.mode.permissive"}}</label>
|
||||
<div class="help">{{ctx.Locale.Tr "actions.general.token_permissions.mode.permissive.desc"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="token_permission_mode" value="{{.TokenPermissionModeRestricted}}" {{if eq .TokenPermissionMode .TokenPermissionModeRestricted}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "actions.general.token_permissions.mode.restricted"}}</label>
|
||||
<div class="help">{{ctx.Locale.Tr "actions.general.token_permissions.mode.restricted.desc"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
77
templates/shared/actions/permissions_table.tmpl
Normal file
77
templates/shared/actions/permissions_table.tmpl
Normal file
@ -0,0 +1,77 @@
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "actions.general.token_permissions.maximum"}}</label>
|
||||
|
||||
<div class="help">
|
||||
{{ctx.Locale.Tr "actions.general.token_permissions.maximum.description"}}
|
||||
<br>
|
||||
{{ctx.Locale.Tr "actions.general.token_permissions.fork_pr_note"}}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="enable_max_permissions" {{if .EnableMaxTokenPermissions}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "actions.general.token_permissions.customize_max_permissions"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="ui celled table js-permissions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="tw-w-2/5">{{ctx.Locale.Tr "units.unit"}}</th>
|
||||
<th class="tw-text-center">{{ctx.Locale.Tr "org.teams.none_access"}}
|
||||
<span class="tw-align-middle" data-tooltip-content="{{ctx.Locale.Tr "org.teams.none_access_helper"}}">{{svg "octicon-question" 16 "tw-ml-1"}}</span>
|
||||
</th>
|
||||
<th class="tw-text-center">{{ctx.Locale.Tr "org.teams.read_access"}}
|
||||
<span class="tw-align-middle" data-tooltip-content="{{ctx.Locale.Tr "org.teams.read_access_helper"}}">{{svg "octicon-question" 16 "tw-ml-1"}}</span>
|
||||
</th>
|
||||
<th class="tw-text-center">{{ctx.Locale.Tr "org.teams.write_access"}}
|
||||
<span class="tw-align-middle" data-tooltip-content="{{ctx.Locale.Tr "org.teams.write_access_helper"}}">{{svg "octicon-question" 16 "tw-ml-1"}}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{template "shared/actions/permissions_table_unit" (dict
|
||||
"UnitType" ctx.Consts.RepoUnitTypeCode
|
||||
"UnitDisplayName" (ctx.Locale.Tr "repo.code")
|
||||
"UnitDisplayDesc" (ctx.Locale.Tr "repo.code.desc")
|
||||
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypeCode)
|
||||
)}}
|
||||
{{template "shared/actions/permissions_table_unit" (dict
|
||||
"UnitType" ctx.Consts.RepoUnitTypeIssues
|
||||
"UnitDisplayName" (ctx.Locale.Tr "repo.issues")
|
||||
"UnitDisplayDesc" (ctx.Locale.Tr "repo.issues.desc")
|
||||
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypeIssues)
|
||||
)}}
|
||||
{{template "shared/actions/permissions_table_unit" (dict
|
||||
"UnitType" ctx.Consts.RepoUnitTypePullRequests
|
||||
"UnitDisplayName" (ctx.Locale.Tr "repo.pulls")
|
||||
"UnitDisplayDesc" (ctx.Locale.Tr "repo.pulls.desc")
|
||||
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypePullRequests)
|
||||
)}}
|
||||
{{template "shared/actions/permissions_table_unit" (dict
|
||||
"UnitType" ctx.Consts.RepoUnitTypeWiki
|
||||
"UnitDisplayName" (ctx.Locale.Tr "repo.wiki")
|
||||
"UnitDisplayDesc" (ctx.Locale.Tr "repo.wiki.desc")
|
||||
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypeWiki)
|
||||
)}}
|
||||
{{template "shared/actions/permissions_table_unit" (dict
|
||||
"UnitType" ctx.Consts.RepoUnitTypeReleases
|
||||
"UnitDisplayName" (ctx.Locale.Tr "repo.releases")
|
||||
"UnitDisplayDesc" (ctx.Locale.Tr "repo.releases.desc")
|
||||
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypeReleases)
|
||||
)}}
|
||||
{{template "shared/actions/permissions_table_unit" (dict
|
||||
"UnitType" ctx.Consts.RepoUnitTypeProjects
|
||||
"UnitDisplayName" (ctx.Locale.Tr "repo.projects")
|
||||
"UnitDisplayDesc" (ctx.Locale.Tr "repo.projects.desc")
|
||||
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypeProjects)
|
||||
)}}
|
||||
{{template "shared/actions/permissions_table_unit" (dict
|
||||
"UnitType" ctx.Consts.RepoUnitTypeActions
|
||||
"UnitDisplayName" (ctx.Locale.Tr "repo.actions")
|
||||
"UnitDisplayDesc" (ctx.Locale.Tr "actions.unit.desc")
|
||||
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypeActions)
|
||||
)}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
24
templates/shared/actions/permissions_table_unit.tmpl
Normal file
24
templates/shared/actions/permissions_table_unit.tmpl
Normal file
@ -0,0 +1,24 @@
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{.UnitDisplayName}}</strong>
|
||||
<div class="help">{{.UnitDisplayDesc}}</div>
|
||||
</td>
|
||||
<td class="tw-text-center">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="max_unit_access_mode_{{.UnitType}}" value="none" {{if not .UnitAccessMode}}checked{{end}} title="{{ctx.Locale.Tr "org.teams.none_access"}}">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="tw-text-center">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="max_unit_access_mode_{{.UnitType}}" value="read" {{if eq .UnitAccessMode 1}}checked{{end}} title="{{ctx.Locale.Tr "org.teams.read_access"}}">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="tw-text-center">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="max_unit_access_mode_{{.UnitType}}" value="write" {{if eq .UnitAccessMode 2}}checked{{end}} title="{{ctx.Locale.Tr "org.teams.write_access"}}">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
3
templates/user/settings/actions_general.tmpl
Normal file
3
templates/user/settings/actions_general.tmpl
Normal file
@ -0,0 +1,3 @@
|
||||
{{template "user/settings/layout_head" (dict "ctxData" .)}}
|
||||
{{template "shared/actions/owner_general_settings" .}}
|
||||
{{template "user/settings/layout_footer" .}}
|
||||
@ -34,9 +34,12 @@
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .EnableActions}}
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<details class="item toggleable-item" {{if or .PageIsUserSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsUserSettingsActionsGeneral}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/general">
|
||||
{{ctx.Locale.Tr "actions.general"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/runners">
|
||||
{{ctx.Locale.Tr "actions.runners"}}
|
||||
</a>
|
||||
|
||||
@ -5,14 +5,23 @@ package integration
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
org_model "code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
unit_model "code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
@ -20,98 +29,388 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionsJobTokenAccess(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
t.Run("Write Access", testActionsJobTokenAccess(u, false))
|
||||
t.Run("Read Access", testActionsJobTokenAccess(u, true))
|
||||
})
|
||||
}
|
||||
func TestActionsJobTokenPermissiveAccess(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
isFork bool
|
||||
|
||||
func testActionsJobTokenAccess(u *url.URL, isFork bool) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
|
||||
require.NoError(t, task.GenerateToken())
|
||||
task.Status = actions_model.StatusRunning
|
||||
task.IsForkPullRequest = isFork
|
||||
err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request")
|
||||
require.NoError(t, err)
|
||||
session := emptyTestSession(t)
|
||||
context := APITestContext{
|
||||
Session: session,
|
||||
Token: task.Token,
|
||||
Username: "user5",
|
||||
Reponame: "repo4",
|
||||
}
|
||||
dstPath := t.TempDir()
|
||||
ownerPermMode repo_model.ActionsTokenPermissionMode
|
||||
ownerMaxPerms map[unit_model.Type]perm.AccessMode
|
||||
|
||||
u.Path = context.GitPath()
|
||||
u.User = url.UserPassword("gitea-actions", task.Token)
|
||||
repoPermMode repo_model.ActionsTokenPermissionMode
|
||||
repoMaxPerms map[unit_model.Type]perm.AccessMode
|
||||
|
||||
t.Run("Git Clone", doGitClone(dstPath, u))
|
||||
expectGitAccess perm.AccessMode
|
||||
}{
|
||||
{
|
||||
name: "OwnerConfig-Permissive",
|
||||
ownerPermMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
expectGitAccess: perm.AccessModeWrite,
|
||||
},
|
||||
{
|
||||
name: "OwnerConfig-Permissive-CodeNone",
|
||||
ownerPermMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone},
|
||||
expectGitAccess: perm.AccessModeNone,
|
||||
},
|
||||
{
|
||||
name: "OwnerConfig-Restricted",
|
||||
ownerPermMode: repo_model.ActionsTokenPermissionModeRestricted,
|
||||
expectGitAccess: perm.AccessModeRead,
|
||||
},
|
||||
|
||||
t.Run("API Get Repository", doAPIGetRepository(context, func(t *testing.T, r structs.Repository) {
|
||||
require.Equal(t, "repo4", r.Name)
|
||||
require.Equal(t, "user5", r.Owner.UserName)
|
||||
}))
|
||||
// repo uses its own settings, so owner settings should not affect it
|
||||
{
|
||||
name: "SameRepo-Permissive",
|
||||
ownerPermMode: repo_model.ActionsTokenPermissionModeRestricted,
|
||||
ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone},
|
||||
repoPermMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
expectGitAccess: perm.AccessModeWrite,
|
||||
},
|
||||
{
|
||||
name: "SameRepo-Permissive-CodeNone",
|
||||
ownerPermMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeRead},
|
||||
repoPermMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
repoMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone},
|
||||
expectGitAccess: perm.AccessModeNone,
|
||||
},
|
||||
{
|
||||
name: "SameRepo-Restricted",
|
||||
repoPermMode: repo_model.ActionsTokenPermissionModeRestricted,
|
||||
expectGitAccess: perm.AccessModeRead,
|
||||
},
|
||||
|
||||
context.ExpectedCode = util.Iif(isFork, http.StatusForbidden, http.StatusCreated)
|
||||
t.Run("API Create File", doAPICreateFile(context, "test.txt", &structs.CreateFileOptions{
|
||||
FileOptions: structs.FileOptions{
|
||||
NewBranchName: "new-branch",
|
||||
Message: "Create File",
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`This is a test file created using job token.`)),
|
||||
}))
|
||||
|
||||
context.ExpectedCode = http.StatusForbidden
|
||||
t.Run("Fail to Create Repository", doAPICreateRepository(context, true))
|
||||
|
||||
context.ExpectedCode = http.StatusForbidden
|
||||
t.Run("Fail to Delete Repository", doAPIDeleteRepository(context))
|
||||
|
||||
t.Run("Fail to Create Organization", doAPICreateOrganization(context, &structs.CreateOrgOption{
|
||||
UserName: "actions",
|
||||
FullName: "Gitea Actions",
|
||||
}))
|
||||
// forks should be always restricted to max read access for code
|
||||
{
|
||||
name: "Fork-Permissive",
|
||||
repoPermMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
isFork: true,
|
||||
expectGitAccess: perm.AccessModeRead,
|
||||
},
|
||||
{
|
||||
name: "Fork-Restricted",
|
||||
repoPermMode: repo_model.ActionsTokenPermissionModeRestricted,
|
||||
isFork: true,
|
||||
expectGitAccess: perm.AccessModeRead,
|
||||
},
|
||||
{
|
||||
name: "Fork-Restricted-CodeNone",
|
||||
repoPermMode: repo_model.ActionsTokenPermissionModeRestricted,
|
||||
repoMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone},
|
||||
isFork: true,
|
||||
expectGitAccess: perm.AccessModeNone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsJobTokenAccessLFS(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
httpContext := NewAPITestContext(t, "user2", "repo-lfs-test", auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
|
||||
t.Run("Create Repository", doAPICreateRepository(httpContext, false, func(t *testing.T, repository structs.Repository) {
|
||||
task := &actions_model.ActionTask{}
|
||||
require.NoError(t, task.GenerateToken())
|
||||
task.Status = actions_model.StatusRunning
|
||||
task.IsForkPullRequest = false
|
||||
task.RepoID = repository.ID
|
||||
err := db.Insert(t.Context(), task)
|
||||
require.NoError(t, err)
|
||||
session := emptyTestSession(t)
|
||||
httpContext := APITestContext{
|
||||
Session: session,
|
||||
Token: task.Token,
|
||||
Username: "user2",
|
||||
Reponame: "repo-lfs-test",
|
||||
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: task.RepoID})
|
||||
repoActionsUnit := repo.MustGetUnit(t.Context(), unit_model.TypeActions)
|
||||
repoActionsCfg := repoActionsUnit.ActionsConfig()
|
||||
ownerActionsCfg, err := actions_model.GetOwnerActionsConfig(t.Context(), repo.OwnerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.GetEngine(t.Context()).ID(task.RepoID).Cols("is_private").Update(&repo_model.Repository{IsPrivate: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
assertRespCodeForSuccess := func(t *testing.T, resp *httptest.ResponseRecorder, succeed bool) {
|
||||
if succeed {
|
||||
assert.True(t, 200 <= resp.Code && resp.Code < 300, "Expected success status code, got %d", resp.Code)
|
||||
} else {
|
||||
assert.True(t, 400 <= resp.Code && resp.Code < 500, "Expected client error status code, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// prepare owner's token permissions settings
|
||||
ownerActionsCfg.TokenPermissionMode = tt.ownerPermMode
|
||||
ownerActionsCfg.MaxTokenPermissions = util.Iif(tt.ownerMaxPerms == nil, nil, &repo_model.ActionsTokenPermissions{UnitAccessModes: tt.ownerMaxPerms})
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(t.Context(), repo.OwnerID, ownerActionsCfg))
|
||||
|
||||
u.Path = httpContext.GitPath()
|
||||
dstPath := t.TempDir()
|
||||
// prepare repo's token permissions settings
|
||||
repoActionsCfg.OverrideOwnerConfig = tt.repoPermMode != "" || tt.repoMaxPerms != nil
|
||||
repoActionsCfg.TokenPermissionMode = tt.repoPermMode
|
||||
repoActionsCfg.MaxTokenPermissions = util.Iif(tt.repoMaxPerms == nil, nil, &repo_model.ActionsTokenPermissions{UnitAccessModes: tt.repoMaxPerms})
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
|
||||
|
||||
u.Path = httpContext.GitPath()
|
||||
u.User = url.UserPassword("gitea-actions", task.Token)
|
||||
// prepare task and its token
|
||||
require.NoError(t, task.GenerateToken())
|
||||
task.Status = actions_model.StatusRunning
|
||||
task.IsForkPullRequest = tt.isFork
|
||||
err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Clone", doGitClone(dstPath, u))
|
||||
require.NoError(t, task.LoadJob(t.Context()))
|
||||
require.NoError(t, task.Job.LoadRun(t.Context()))
|
||||
task.Job.Run.IsForkPullRequest = tt.isFork
|
||||
require.NoError(t, actions_model.UpdateRun(t.Context(), task.Job.Run, "is_fork_pull_request"))
|
||||
|
||||
dstPath2 := t.TempDir()
|
||||
testURL := *u
|
||||
testURL.User = url.UserPassword("gitea-actions", task.Token)
|
||||
|
||||
t.Run("Partial Clone", doPartialGitClone(dstPath2, u))
|
||||
t.Run("ReadGitContent", func(t *testing.T) {
|
||||
testURL.Path = "/user5/repo4.git/HEAD"
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", testURL.String()), NoExpectedStatus)
|
||||
assertRespCodeForSuccess(t, resp, tt.expectGitAccess != perm.AccessModeNone)
|
||||
|
||||
lfs := lfsCommitAndPushTest(t, dstPath, testFileSizeSmall)[0]
|
||||
testURL.Path = "/user5/repo4.git/info/lfs/locks"
|
||||
req := NewRequest(t, "GET", testURL.String()).SetHeader("Accept", lfs.MediaType)
|
||||
resp = MakeRequest(t, req, NoExpectedStatus)
|
||||
assertRespCodeForSuccess(t, resp, tt.expectGitAccess != perm.AccessModeNone)
|
||||
})
|
||||
|
||||
reqLFS := NewRequest(t, "GET", "/api/v1/repos/user2/repo-lfs-test/media/"+lfs).AddTokenAuth(task.Token)
|
||||
respLFS := MakeRequestNilResponseRecorder(t, reqLFS, http.StatusOK)
|
||||
assert.Equal(t, testFileSizeSmall, respLFS.Length)
|
||||
}))
|
||||
t.Run("WriteGitContent", func(t *testing.T) {
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/contents/test-filename", repo.FullName()), &structs.CreateFileOptions{
|
||||
FileOptions: structs.FileOptions{NewBranchName: "new-branch" + t.Name()},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`dummy content`)),
|
||||
}).AddTokenAuth(task.Token)
|
||||
resp := MakeRequest(t, req, NoExpectedStatus)
|
||||
assertRespCodeForSuccess(t, resp, tt.expectGitAccess == perm.AccessModeWrite)
|
||||
|
||||
testURL.Path = "/user5/repo4.git/info/lfs/objects/batch"
|
||||
req = NewRequestWithJSON(t, "POST", testURL.String(), lfs.BatchRequest{Operation: "upload"}).SetHeader("Accept", lfs.MediaType)
|
||||
resp = MakeRequest(t, req, NoExpectedStatus)
|
||||
assertRespCodeForSuccess(t, resp, tt.expectGitAccess == perm.AccessModeWrite)
|
||||
})
|
||||
|
||||
t.Run("NoOtherPermissions", func(t *testing.T) {
|
||||
req := NewRequest(t, "DELETE", "/api/v1/repos/"+repo.FullName()).AddTokenAuth(task.Token)
|
||||
resp := MakeRequest(t, req, NoExpectedStatus)
|
||||
assertRespCodeForSuccess(t, resp, false)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionsCrossRepoAccess(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
session := loginUser(t, "user2")
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
|
||||
|
||||
// 1. Create Organization
|
||||
orgName := "org-cross-test"
|
||||
req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &structs.CreateOrgOption{
|
||||
UserName: orgName,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
owner, err := org_model.GetOrgByName(t.Context(), orgName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 2. Create Two Repositories in owner
|
||||
createRepoInOrg := func(name string) int64 {
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), &structs.CreateRepoOption{
|
||||
Name: name,
|
||||
AutoInit: true,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var repo structs.Repository
|
||||
DecodeJSON(t, resp, &repo)
|
||||
return repo.ID
|
||||
}
|
||||
|
||||
repoAID := createRepoInOrg("repo-A")
|
||||
repoBID := createRepoInOrg("repo-B")
|
||||
|
||||
// 3. Enable Actions in Repo A (Source) and Repo B (Target)
|
||||
enableActions := func(repoID int64) {
|
||||
err := db.Insert(t.Context(), &repo_model.RepoUnit{
|
||||
RepoID: repoID,
|
||||
Type: unit_model.TypeActions,
|
||||
Config: &repo_model.ActionsConfig{
|
||||
TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
enableActions(repoAID)
|
||||
enableActions(repoBID)
|
||||
|
||||
// 4. Create Task in Repo A, and use A's token to access B
|
||||
taskA := createActionTask(t, repoAID, false)
|
||||
testCtxA := APITestContext{
|
||||
Session: emptyTestSession(t),
|
||||
Token: taskA.Token,
|
||||
Username: orgName,
|
||||
Reponame: "repo-B",
|
||||
}
|
||||
|
||||
testCtxA.ExpectedCode = http.StatusOK
|
||||
t.Run("PublicCrossRepoAccess", doAPIGetRepository(testCtxA, func(t *testing.T, r structs.Repository) {
|
||||
assert.Equal(t, "repo-B", r.Name)
|
||||
}))
|
||||
|
||||
// make repo-B be private
|
||||
req = NewRequestWithJSON(t, "PATCH", "/api/v1/repos/org-cross-test/repo-B", &structs.EditRepoOption{Private: new(true)}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
testCtxA.ExpectedCode = http.StatusNotFound
|
||||
t.Run("NoPrivateCrossRepoAccess", doAPIGetRepository(testCtxA, nil))
|
||||
|
||||
ownerActionsCfg := actions_model.OwnerActionsConfig{AllowedCrossRepoIDs: []int64{repoBID}}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(t.Context(), owner.ID, ownerActionsCfg))
|
||||
|
||||
testCtxA.ExpectedCode = http.StatusOK
|
||||
t.Run("AccessToSelectedPrivateRepo", doAPIGetRepository(testCtxA, func(t *testing.T, r structs.Repository) {
|
||||
assert.Equal(t, "repo-B", r.Name)
|
||||
}))
|
||||
|
||||
t.Run("RepoTransfer", func(t *testing.T) {
|
||||
ownerActionsCfg, err := actions_model.GetOwnerActionsConfig(t.Context(), owner.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, ownerActionsCfg.AllowedCrossRepoIDs, repoBID)
|
||||
|
||||
// Transfer Repository to user4
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/repo-B/transfer", orgName), &structs.TransferRepoOption{
|
||||
NewOwner: "user4",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// Accept transfer as user4
|
||||
session4 := loginUser(t, "user4")
|
||||
token4 := getTokenForLoggedInUser(t, session4, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/repo-B/transfer/accept", orgName)).AddTokenAuth(token4)
|
||||
MakeRequest(t, req, http.StatusAccepted)
|
||||
|
||||
// Verify it is removed from the org's config
|
||||
ownerActionsCfg, err = actions_model.GetOwnerActionsConfig(t.Context(), owner.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, ownerActionsCfg.AllowedCrossRepoIDs, repoBID)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func createActionTask(t *testing.T, repoID int64, isFork bool) *actions_model.ActionTask {
|
||||
job := &actions_model.ActionRunJob{
|
||||
RepoID: repoID,
|
||||
Status: actions_model.StatusRunning,
|
||||
IsForkPullRequest: isFork,
|
||||
JobID: "test_job",
|
||||
Name: "test_job",
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: job.ID,
|
||||
RepoID: repoID,
|
||||
Status: actions_model.StatusRunning,
|
||||
IsForkPullRequest: isFork,
|
||||
}
|
||||
require.NoError(t, task.GenerateToken())
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
return task
|
||||
}
|
||||
|
||||
func TestActionsTokenPermissionsPersistenceWithWorkflow(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
// create repos
|
||||
repo1 := createActionsTestRepo(t, token, "actions-permission-repo1", false)
|
||||
repo2 := createActionsTestRepo(t, token, "actions-permission-repo2", true)
|
||||
|
||||
// add repo2 to owner-level cross-repo access list
|
||||
req := NewRequestWithValues(t, "POST", "/user/settings/actions/general", map[string]string{
|
||||
"cross_repo_add_target": "true",
|
||||
"cross_repo_add_target_name": repo2.Name,
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// create the runner for repo1
|
||||
runner1 := newMockRunner()
|
||||
runner1.registerAsRepoRunner(t, user2.Name, repo1.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
// set repo1 actions token permission mode to "permissive"
|
||||
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo1.Name), map[string]string{
|
||||
"token_permission_mode": "permissive",
|
||||
"override_owner_config": "true",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
// set repo2 actions token permission mode to "restricted", and set max permissions
|
||||
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{
|
||||
"token_permission_mode": "restricted",
|
||||
"override_owner_config": "true",
|
||||
"enable_max_permissions": "true",
|
||||
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeReleases)): "read",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
// create a workflow file with "permission" keyword for repo1
|
||||
wfTreePath := ".gitea/workflows/test_permissions.yml"
|
||||
wfFileContent := `name: Test Permissions
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.gitea/workflows/test_permissions.yml'
|
||||
|
||||
jobs:
|
||||
job-override:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
code: write
|
||||
steps:
|
||||
- run: echo "test perms"
|
||||
`
|
||||
opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent)
|
||||
createWorkflowFile(t, token, user2.Name, repo1.Name, wfTreePath, opts)
|
||||
|
||||
task1 := runner1.fetchTask(t)
|
||||
task1Token := task1.Secrets["GITEA_TOKEN"]
|
||||
require.NotEmpty(t, task1Token)
|
||||
|
||||
// should fail: target repo does not allow code access
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo2.Name)).AddTokenAuth(task1Token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// set repo2 max permission to "read" so that the actions token can access code
|
||||
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{
|
||||
"token_permission_mode": "restricted",
|
||||
"override_owner_config": "true",
|
||||
"enable_max_permissions": "true",
|
||||
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "read",
|
||||
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeReleases)): "read",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
// should succeed: target repo now allows code read access for this token
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo2.Name)).AddTokenAuth(task1Token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
// but it should not have write access
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo2.Name), lfs.BatchRequest{Operation: "upload"}).
|
||||
SetHeader("Accept", lfs.MediaType).
|
||||
AddBasicAuth("gitea-actions", task1Token)
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
|
||||
// set repo1&repo2 max permission to "write" so that the actions token can access code
|
||||
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo1.Name), map[string]string{
|
||||
"token_permission_mode": "restricted",
|
||||
"override_owner_config": "true",
|
||||
"enable_max_permissions": "true",
|
||||
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "write",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{
|
||||
"token_permission_mode": "restricted",
|
||||
"override_owner_config": "true",
|
||||
"enable_max_permissions": "true",
|
||||
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "write",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
// now task1 has write access to repo1, but still only read access to repo2 (different repo)
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo1.Name), lfs.BatchRequest{Operation: "upload"}).
|
||||
SetHeader("Accept", lfs.MediaType).
|
||||
AddBasicAuth("gitea-actions", task1Token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo2.Name), lfs.BatchRequest{Operation: "upload"}).
|
||||
SetHeader("Accept", lfs.MediaType).
|
||||
AddBasicAuth("gitea-actions", task1Token)
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
@ -95,13 +95,13 @@ func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, run
|
||||
|
||||
func (r *mockRunner) fetchTask(t *testing.T, timeout ...time.Duration) *runnerv1.Task {
|
||||
task := r.tryFetchTask(t, timeout...)
|
||||
assert.NotNil(t, task, "failed to fetch a task")
|
||||
require.NotNil(t, task, "failed to fetch a task")
|
||||
return task
|
||||
}
|
||||
|
||||
func (r *mockRunner) fetchNoTask(t *testing.T, timeout ...time.Duration) {
|
||||
task := r.tryFetchTask(t, timeout...)
|
||||
assert.Nil(t, task, "a task is fetched")
|
||||
require.Nil(t, task, "a task is fetched")
|
||||
}
|
||||
|
||||
const defaultFetchTaskTimeout = 1 * time.Second
|
||||
|
||||
@ -29,7 +29,7 @@ func enableRepoDependencies(t *testing.T, repoID int64) {
|
||||
|
||||
repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: repoID, Type: unit.TypeIssues})
|
||||
repoUnit.IssuesConfig().EnableDependencies = true
|
||||
assert.NoError(t, repo_model.UpdateRepoUnit(t.Context(), repoUnit))
|
||||
assert.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoUnit))
|
||||
}
|
||||
|
||||
func TestAPICreateIssueDependencyCrossRepoPermission(t *testing.T) {
|
||||
|
||||
@ -6,6 +6,7 @@ package integration
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"hash"
|
||||
"hash/fnv"
|
||||
@ -326,9 +327,10 @@ func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *Re
|
||||
urlStr = "/" + urlStr
|
||||
}
|
||||
req, err := http.NewRequest(method, urlStr, body)
|
||||
assert.NoError(t, err)
|
||||
req.RequestURI = urlStr
|
||||
|
||||
require.NoError(t, err)
|
||||
if req.URL.User != nil {
|
||||
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(req.URL.User.String())))
|
||||
}
|
||||
return &RequestWrapper{req}
|
||||
}
|
||||
|
||||
|
||||
@ -66,7 +66,7 @@ func enableRepoAllowUpdateWithRebase(t *testing.T, repoID int64, allow bool) {
|
||||
|
||||
repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: repoID, Type: unit.TypePullRequests})
|
||||
repoUnit.PullRequestsConfig().AllowRebaseUpdate = allow
|
||||
assert.NoError(t, repo_model.UpdateRepoUnit(t.Context(), repoUnit))
|
||||
assert.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoUnit))
|
||||
}
|
||||
|
||||
func TestAPIPullUpdateByRebase(t *testing.T) {
|
||||
|
||||
@ -261,6 +261,12 @@ relative-time::part(root)::selection {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.container-disabled {
|
||||
opacity: var(--opacity-disabled);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
|
||||
@ -25,15 +25,6 @@
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.ui.list > .list > .item::after,
|
||||
.ui.list > .item::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 0;
|
||||
clear: both;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ui.list .list:not(.icon) {
|
||||
clear: both;
|
||||
margin: 0;
|
||||
|
||||
684
web_src/js/components/ActionRunJobView.vue
Normal file
684
web_src/js/components/ActionRunJobView.vue
Normal file
@ -0,0 +1,684 @@
|
||||
<script setup lang="ts">
|
||||
import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||
import {formatDatetime} from '../utils/time.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import type {ActionsArtifact, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||
import {
|
||||
type ActionRunViewStore,
|
||||
createLogLineMessage,
|
||||
type LogLine,
|
||||
type LogLineCommand,
|
||||
parseLogLineCommand
|
||||
} from './ActionRunView.ts';
|
||||
|
||||
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// only check whether bottom is in viewport, because the log element can be a log group which is usually tall
|
||||
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
|
||||
}
|
||||
|
||||
type Step = {
|
||||
summary: string,
|
||||
duration: string,
|
||||
status: ActionsRunStatus,
|
||||
}
|
||||
|
||||
type JobStepState = {
|
||||
cursor: string|null,
|
||||
expanded: boolean,
|
||||
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
||||
}
|
||||
|
||||
type StepContainerElement = HTMLElement & {
|
||||
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
||||
// then the following batches of logs should still use the same group (active logs container).
|
||||
// maybe it can be refactored to decouple from the HTML element in the future.
|
||||
_stepLogsActiveContainer?: HTMLElement;
|
||||
}
|
||||
|
||||
type LocaleStorageOptions = {
|
||||
autoScroll: boolean;
|
||||
expandRunning: boolean;
|
||||
actionsLogShowSeconds: boolean;
|
||||
actionsLogShowTimestamps: boolean;
|
||||
};
|
||||
|
||||
type CurrentJob = {
|
||||
title: string;
|
||||
detail: string;
|
||||
steps: Array<Step>;
|
||||
};
|
||||
|
||||
type JobData = {
|
||||
artifacts: Array<ActionsArtifact>;
|
||||
state: {
|
||||
run: ActionsRun;
|
||||
currentJob: CurrentJob;
|
||||
},
|
||||
logs: {
|
||||
stepsLog?: Array<{
|
||||
step: number;
|
||||
cursor: string | null;
|
||||
started: number;
|
||||
lines: LogLine[];
|
||||
}>;
|
||||
},
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
name: 'ActionRunJobView',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
store: ActionRunViewStore,
|
||||
runId: number;
|
||||
jobId: number;
|
||||
actionsUrl: string;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
const store = props.store;
|
||||
const {currentRun: run} = toRefs(store.viewData);
|
||||
|
||||
const defaultViewOptions: LocaleStorageOptions = {
|
||||
autoScroll: true,
|
||||
expandRunning: false,
|
||||
actionsLogShowSeconds: false,
|
||||
actionsLogShowTimestamps: false,
|
||||
};
|
||||
|
||||
const savedViewOptions = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
|
||||
const {autoScroll, expandRunning, actionsLogShowSeconds, actionsLogShowTimestamps} = savedViewOptions;
|
||||
|
||||
// internal state
|
||||
let loadingAbortController: AbortController | null = null;
|
||||
let intervalID: IntervalId | null = null;
|
||||
|
||||
const currentJobStepsStates = ref<Array<JobStepState>>([]);
|
||||
const menuVisible = ref(false);
|
||||
const isFullScreen = ref(false);
|
||||
const timeVisible = ref<Record<string, boolean>>({
|
||||
'log-time-stamp': actionsLogShowTimestamps,
|
||||
'log-time-seconds': actionsLogShowSeconds,
|
||||
});
|
||||
const optionAlwaysAutoScroll = ref(autoScroll);
|
||||
const optionAlwaysExpandRunning = ref(expandRunning);
|
||||
const currentJob = ref<CurrentJob>({
|
||||
title: '',
|
||||
detail: '',
|
||||
steps: [] as Array<Step>,
|
||||
});
|
||||
const stepsContainer = ref<HTMLElement | null>(null);
|
||||
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
||||
|
||||
watch(optionAlwaysAutoScroll, () => {
|
||||
saveLocaleStorageOptions();
|
||||
});
|
||||
|
||||
watch(optionAlwaysExpandRunning, () => {
|
||||
saveLocaleStorageOptions();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// load job data and then auto-reload periodically
|
||||
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
|
||||
await loadJob();
|
||||
|
||||
// auto-scroll to the bottom of the log group when it is opened
|
||||
// "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
|
||||
addDelegatedEventListener(elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
|
||||
if (!optionAlwaysAutoScroll.value) return;
|
||||
const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
|
||||
setTimeout(() => {
|
||||
if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
|
||||
elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
intervalID = setInterval(() => void loadJob(), 1000);
|
||||
document.body.addEventListener('click', closeDropdown);
|
||||
void hashChangeListener();
|
||||
window.addEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.removeEventListener('click', closeDropdown);
|
||||
window.removeEventListener('hashchange', hashChangeListener);
|
||||
// clear the interval timer when the component is unmounted
|
||||
// even our page is rendered once, not spa style
|
||||
if (intervalID) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
}
|
||||
});
|
||||
|
||||
function saveLocaleStorageOptions() {
|
||||
const opts: LocaleStorageOptions = {
|
||||
autoScroll: optionAlwaysAutoScroll.value,
|
||||
expandRunning: optionAlwaysExpandRunning.value,
|
||||
actionsLogShowSeconds: timeVisible.value['log-time-seconds'],
|
||||
actionsLogShowTimestamps: timeVisible.value['log-time-stamp'],
|
||||
};
|
||||
localUserSettings.setJsonObject('actions-view-options', opts);
|
||||
}
|
||||
|
||||
// get the job step logs container ('.job-step-logs')
|
||||
function getJobStepLogsContainer(stepIndex: number): StepContainerElement {
|
||||
return jobStepLogs.value[stepIndex] as StepContainerElement;
|
||||
}
|
||||
|
||||
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
|
||||
function getActiveLogsContainer(stepIndex: number): StepContainerElement {
|
||||
const el = getJobStepLogsContainer(stepIndex);
|
||||
return el._stepLogsActiveContainer ?? el;
|
||||
}
|
||||
|
||||
// begin a log group
|
||||
function beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||
const el = getJobStepLogsContainer(stepIndex);
|
||||
// Using "summary + details" is the best way to create a log group because it has built-in support for "toggle" and "accessibility".
|
||||
// And it makes users can use "Ctrl+F" to search the logs without opening all log groups.
|
||||
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
|
||||
createLogLine(stepIndex, startTime, line, cmd),
|
||||
);
|
||||
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
|
||||
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
|
||||
elJobLogGroupSummary,
|
||||
elJobLogList,
|
||||
);
|
||||
el.append(elJobLogGroup);
|
||||
el._stepLogsActiveContainer = elJobLogList;
|
||||
}
|
||||
|
||||
// end a log group
|
||||
function endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||
const el = getJobStepLogsContainer(stepIndex);
|
||||
el._stepLogsActiveContainer = undefined;
|
||||
el.append(createLogLine(stepIndex, startTime, line, cmd));
|
||||
}
|
||||
|
||||
// show/hide the step logs for a step
|
||||
function toggleStepLogs(idx: number) {
|
||||
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
|
||||
if (currentJobStepsStates.value[idx].expanded) {
|
||||
void loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
|
||||
} else if (currentJob.value.steps[idx].status === 'running') {
|
||||
currentJobStepsStates.value[idx].manuallyCollapsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
|
||||
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
|
||||
String(line.index),
|
||||
);
|
||||
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
||||
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
|
||||
);
|
||||
const logMsg = createLogLineMessage(line, cmd);
|
||||
const seconds = Math.floor(line.timestamp - startTime);
|
||||
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
|
||||
`${seconds}s`, // for "Show seconds"
|
||||
);
|
||||
|
||||
toggleElem(logTimeStamp, timeVisible.value['log-time-stamp']);
|
||||
toggleElem(logTimeSeconds, timeVisible.value['log-time-seconds']);
|
||||
|
||||
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
|
||||
lineNum, logTimeStamp, logMsg, logTimeSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAutoScroll(stepIndex: number): boolean {
|
||||
if (!optionAlwaysAutoScroll.value) return false;
|
||||
const el = getJobStepLogsContainer(stepIndex);
|
||||
// if the logs container is empty, then auto-scroll if the step is expanded
|
||||
if (!el.lastChild) return currentJobStepsStates.value[stepIndex].expanded;
|
||||
// use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
|
||||
return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
|
||||
}
|
||||
|
||||
function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
||||
for (const line of logLines) {
|
||||
const cmd = parseLogLineCommand(line);
|
||||
switch (cmd?.name) {
|
||||
case 'hidden':
|
||||
continue;
|
||||
case 'group':
|
||||
beginLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
case 'endgroup':
|
||||
endLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
}
|
||||
// the active logs container may change during the loop, for example: entering and leaving a group
|
||||
const el = getActiveLogsContainer(stepIndex);
|
||||
el.append(createLogLine(stepIndex, startTime, line, cmd));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJobData(abortController: AbortController): Promise<JobData> {
|
||||
const logCursors = currentJobStepsStates.value.map((it, idx) => {
|
||||
// cursor is used to indicate the last position of the logs
|
||||
// it's only used by backend, frontend just reads it and passes it back, it can be any type.
|
||||
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
|
||||
return {step: idx, cursor: it.cursor, expanded: it.expanded};
|
||||
});
|
||||
const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`;
|
||||
const resp = await POST(url, {
|
||||
signal: abortController.signal,
|
||||
data: {logCursors},
|
||||
});
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function loadJobForce() {
|
||||
loadingAbortController?.abort();
|
||||
loadingAbortController = null;
|
||||
await loadJob();
|
||||
}
|
||||
|
||||
async function loadJob() {
|
||||
if (loadingAbortController) return;
|
||||
const abortController = new AbortController();
|
||||
loadingAbortController = abortController;
|
||||
try {
|
||||
const runJobResp = await fetchJobData(abortController);
|
||||
if (loadingAbortController !== abortController) return;
|
||||
|
||||
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
|
||||
// Use consistent "store" operations to load/update the view data
|
||||
store.viewData.runArtifacts = runJobResp.artifacts || [];
|
||||
store.viewData.currentRun = runJobResp.state.run;
|
||||
|
||||
currentJob.value = runJobResp.state.currentJob;
|
||||
const jobLogs = runJobResp.logs.stepsLog ?? [];
|
||||
|
||||
// sync the currentJobStepsStates to store the job step states
|
||||
for (let i = 0; i < currentJob.value.steps.length; i++) {
|
||||
const autoExpand = optionAlwaysExpandRunning.value && currentJob.value.steps[i].status === 'running';
|
||||
if (!currentJobStepsStates.value[i]) {
|
||||
// initial states for job steps
|
||||
currentJobStepsStates.value[i] = {cursor: null, expanded: autoExpand, manuallyCollapsed: false};
|
||||
} else {
|
||||
// if the step is not manually collapsed by user, then auto-expand it if option is enabled
|
||||
if (autoExpand && !currentJobStepsStates.value[i].manuallyCollapsed) {
|
||||
currentJobStepsStates.value[i].expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
// find the step indexes that need to auto-scroll
|
||||
const autoScrollStepIndexes = new Map<number, boolean>();
|
||||
for (const stepLogs of jobLogs) {
|
||||
if (autoScrollStepIndexes.has(stepLogs.step)) continue;
|
||||
autoScrollStepIndexes.set(stepLogs.step, shouldAutoScroll(stepLogs.step));
|
||||
}
|
||||
|
||||
// append logs to the UI
|
||||
for (const stepLogs of jobLogs) {
|
||||
// save the cursor, it will be passed to backend next time
|
||||
currentJobStepsStates.value[stepLogs.step].cursor = stepLogs.cursor;
|
||||
appendLogs(stepLogs.step, stepLogs.started, stepLogs.lines);
|
||||
}
|
||||
|
||||
// auto-scroll to the last log line of the last step
|
||||
let autoScrollJobStepElement: StepContainerElement | undefined;
|
||||
for (let stepIndex = 0; stepIndex < currentJob.value.steps.length; stepIndex++) {
|
||||
if (!autoScrollStepIndexes.get(stepIndex)) continue;
|
||||
autoScrollJobStepElement = getJobStepLogsContainer(stepIndex);
|
||||
}
|
||||
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
|
||||
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
|
||||
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||
}
|
||||
|
||||
// clear the interval timer if the job is done
|
||||
if (run.value.done && intervalID) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// avoid network error while unloading page, and ignore "abort" error
|
||||
if (e instanceof TypeError || abortController.signal.aborted) return;
|
||||
throw e;
|
||||
} finally {
|
||||
if (loadingAbortController === abortController) loadingAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isDone(status: ActionsRunStatus) {
|
||||
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
||||
}
|
||||
|
||||
function isExpandable(status: ActionsRunStatus) {
|
||||
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
if (menuVisible.value) menuVisible.value = false;
|
||||
}
|
||||
|
||||
function elStepsContainer(): HTMLElement {
|
||||
return stepsContainer.value as HTMLElement;
|
||||
}
|
||||
|
||||
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
||||
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
|
||||
for (const el of elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
|
||||
toggleElem(el, timeVisible.value[`log-time-${type}`]);
|
||||
}
|
||||
saveLocaleStorageOptions();
|
||||
}
|
||||
|
||||
function toggleFullScreenMode() {
|
||||
isFullScreen.value = !isFullScreen.value;
|
||||
toggleFullScreen('.action-view-right', isFullScreen.value, '.action-view-body');
|
||||
}
|
||||
|
||||
async function hashChangeListener() {
|
||||
const selectedLogStep = window.location.hash;
|
||||
if (!selectedLogStep) return;
|
||||
const [_, step, _line] = selectedLogStep.split('-');
|
||||
const stepNum = Number(step);
|
||||
if (!currentJobStepsStates.value[stepNum]) return;
|
||||
if (!currentJobStepsStates.value[stepNum].expanded && currentJobStepsStates.value[stepNum].cursor === null) {
|
||||
currentJobStepsStates.value[stepNum].expanded = true;
|
||||
// need to await for load job if the step log is loaded for the first time
|
||||
// so logline can be selected by querySelector
|
||||
await loadJob();
|
||||
}
|
||||
await nextTick();
|
||||
const logLine = elStepsContainer().querySelector(selectedLogStep);
|
||||
if (!logLine) return;
|
||||
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="job-info-header">
|
||||
<div class="job-info-header-left gt-ellipsis">
|
||||
<h3 class="job-info-header-title gt-ellipsis">
|
||||
{{ currentJob.title }}
|
||||
</h3>
|
||||
<p class="job-info-header-detail">
|
||||
{{ currentJob.detail }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="job-info-header-right">
|
||||
<div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
|
||||
<button class="ui button tw-px-3">
|
||||
<SvgIcon name="octicon-gear" :size="18"/>
|
||||
</button>
|
||||
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
|
||||
<a class="item" @click="toggleTimeDisplay('seconds')">
|
||||
<i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showLogSeconds }}
|
||||
</a>
|
||||
<a class="item" @click="toggleTimeDisplay('stamp')">
|
||||
<i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showTimeStamps }}
|
||||
</a>
|
||||
<a class="item" @click="toggleFullScreenMode()">
|
||||
<i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showFullScreen }}
|
||||
</a>
|
||||
<div class="divider"/>
|
||||
<a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
|
||||
<i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.logsAlwaysAutoScroll }}
|
||||
</a>
|
||||
<a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
|
||||
<i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.logsAlwaysExpandRunning }}
|
||||
</a>
|
||||
<div class="divider"/>
|
||||
<a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link + '/jobs/' + jobId + '/logs'" download>
|
||||
<i class="icon"><SvgIcon name="octicon-download"/></i>
|
||||
{{ locale.downloadLogs }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
||||
<div class="job-step-container" ref="stepsContainer" v-show="currentJob.steps.length">
|
||||
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||
<div
|
||||
class="job-step-summary"
|
||||
@click.stop="isExpandable(jobStep.status) && toggleStepLogs(stepIdx)"
|
||||
:class="[currentJobStepsStates[stepIdx].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']"
|
||||
>
|
||||
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
|
||||
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
|
||||
-->
|
||||
<SvgIcon
|
||||
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
|
||||
name="gitea-running"
|
||||
class="tw-mr-2 rotate-clockwise"
|
||||
/>
|
||||
<SvgIcon
|
||||
v-else
|
||||
:name="currentJobStepsStates[stepIdx].expanded ? 'octicon-chevron-down' : 'octicon-chevron-right'"
|
||||
:class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"
|
||||
/>
|
||||
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
|
||||
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
||||
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
||||
</div>
|
||||
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
|
||||
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
|
||||
<div class="job-step-logs" :ref="(el) => jobStepLogs[stepIdx] = el as StepContainerElement" v-show="currentJobStepsStates[stepIdx].expanded"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* begin fomantic dropdown menu overrides */
|
||||
|
||||
.action-view-right .ui.dropdown .menu {
|
||||
background: var(--color-console-menu-bg);
|
||||
border-color: var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item {
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item:hover {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item:active {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-active-bg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .divider {
|
||||
border-top-color: var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
|
||||
background: var(--color-console-menu-bg);
|
||||
box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
/* end fomantic dropdown menu overrides */
|
||||
|
||||
.job-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 60px;
|
||||
z-index: 1; /* above .job-step-container */
|
||||
background: var(--color-console-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.job-info-header:has(+ .job-step-container) {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.job-info-header .job-info-header-title {
|
||||
color: var(--color-console-fg);
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.job-info-header .job-info-header-detail {
|
||||
color: var(--color-console-fg-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-info-header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-step-container {
|
||||
max-height: 100%;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
border-top: 1px solid var(--color-console-border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary {
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.step-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.step-expandable:hover {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-duration {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.selected {
|
||||
color: var(--color-console-fg);
|
||||
background-color: var(--color-console-active-bg);
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
/* workaround ansi_up issue related to faintStyle generating a CSS stacking context via `opacity`
|
||||
inline style which caused such elements to render above the .job-step-summary header. */
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
|
||||
/* some elements are not managed by vue, so we need to use global style */
|
||||
.job-step-section {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs {
|
||||
font-family: var(--fonts-monospace);
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs .job-log-line {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.job-log-line:hover,
|
||||
.job-log-line:target {
|
||||
background-color: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-log-line:target {
|
||||
scroll-margin-top: 95px;
|
||||
}
|
||||
|
||||
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
||||
.job-log-line .line-num, .log-time-seconds {
|
||||
width: 48px;
|
||||
color: var(--color-text-light-3);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.job-log-line:target > .line-num {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.log-time-seconds {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.job-log-line .log-time,
|
||||
.log-time-stamp {
|
||||
color: var(--color-text-light-3);
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-msg {
|
||||
flex: 1;
|
||||
white-space: break-spaces;
|
||||
margin-left: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-cmd-command {
|
||||
color: var(--color-ansi-blue);
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-cmd-error {
|
||||
color: var(--color-ansi-red);
|
||||
}
|
||||
|
||||
/* selectors here are intentionally exact to only match fullscreen */
|
||||
|
||||
.full.height > .action-view-right {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.full.height > .action-view-right > .job-info-header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.full.height > .action-view-right > .job-step-container {
|
||||
height: calc(100% - 60px);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.job-log-group .job-log-list .job-log-line .log-msg {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.job-log-group-summary {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.job-log-group-summary > .job-log-line {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1; /* to avoid hiding the triangle of the "details" element */
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
63
web_src/js/components/ActionRunSummaryView.vue
Normal file
63
web_src/js/components/ActionRunSummaryView.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import WorkflowGraph from './WorkflowGraph.vue';
|
||||
import type {ActionRunViewStore} from "./ActionRunView.ts";
|
||||
import {computed, onBeforeUnmount, onMounted, toRefs} from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: 'ActionRunSummaryView',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
store: ActionRunViewStore;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const {currentRun: run} = toRefs(props.store.viewData);
|
||||
|
||||
const runTriggeredAtIso = computed(() => {
|
||||
const t = props.store.viewData.currentRun.triggeredAt;
|
||||
return t ? new Date(t * 1000).toISOString() : '';
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await props.store.startPollingCurrentRun();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.store.stopPollingCurrentRun();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="action-run-summary-block">
|
||||
<p class="action-run-summary-trigger">
|
||||
{{ locale.triggeredVia.replace('%s', run.triggerEvent) }}
|
||||
• <relative-time :datetime="runTriggeredAtIso" prefix=" "/>
|
||||
</p>
|
||||
<p class="tw-mb-0">
|
||||
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>
|
||||
<span class="tw-ml-2">{{ locale.status[run.status] }}</span>
|
||||
<span class="tw-ml-3">{{ locale.totalDuration }} {{ run.duration || '–' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<WorkflowGraph
|
||||
v-if="run.jobs.length > 0"
|
||||
:jobs="run.jobs"
|
||||
:run-link="run.link"
|
||||
:workflow-id="run.workflowID"
|
||||
class="workflow-graph-container"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.action-run-summary-block {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.action-run-summary-trigger {
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
</style>
|
||||
@ -1,4 +1,4 @@
|
||||
import {createLogLineMessage, parseLogLineCommand} from './RepoActionView.vue';
|
||||
import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts';
|
||||
|
||||
test('LogLineMessage', () => {
|
||||
const cases = {
|
||||
154
web_src/js/components/ActionRunView.ts
Normal file
154
web_src/js/components/ActionRunView.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import {createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {renderAnsi} from '../render/ansi.ts';
|
||||
import {reactive} from 'vue';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
|
||||
// How GitHub Actions logs work:
|
||||
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
|
||||
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
|
||||
// * The reported logs are the processed logs.
|
||||
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
|
||||
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
||||
'::group::': 'group',
|
||||
'##[group]': 'group',
|
||||
'::endgroup::': 'endgroup',
|
||||
'##[endgroup]': 'endgroup',
|
||||
|
||||
'##[error]': 'error',
|
||||
'[command]': 'command',
|
||||
|
||||
// https://github.com/actions/toolkit/blob/master/docs/commands.md
|
||||
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
|
||||
'::add-matcher::': 'hidden',
|
||||
'##[add-matcher]': 'hidden',
|
||||
'::remove-matcher': 'hidden', // it has arguments
|
||||
};
|
||||
|
||||
export type LogLine = {
|
||||
index: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'hidden';
|
||||
export type LogLineCommand = {
|
||||
name: LogLineCommandName,
|
||||
prefix: string,
|
||||
};
|
||||
|
||||
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
|
||||
// TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
|
||||
for (const prefix of Object.keys(LogLinePrefixCommandMap)) {
|
||||
if (line.message.startsWith(prefix)) {
|
||||
return {name: LogLinePrefixCommandMap[prefix], prefix};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
|
||||
const logMsgAttrs = {class: 'log-msg'};
|
||||
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd?.name}`; // make it easier to add styles to some commands like "error"
|
||||
|
||||
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
|
||||
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
|
||||
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
|
||||
|
||||
const logMsg = createElementFromAttrs('span', logMsgAttrs);
|
||||
logMsg.innerHTML = renderAnsi(msgContent);
|
||||
return logMsg;
|
||||
}
|
||||
|
||||
export function createEmptyActionsRun(): ActionsRun {
|
||||
return {
|
||||
link: '',
|
||||
title: '',
|
||||
titleHTML: '',
|
||||
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
||||
canCancel: false,
|
||||
canApprove: false,
|
||||
canRerun: false,
|
||||
canRerunFailed: false,
|
||||
canDeleteArtifact: false,
|
||||
done: false,
|
||||
workflowID: '',
|
||||
workflowLink: '',
|
||||
isSchedule: false,
|
||||
duration: '',
|
||||
triggeredAt: 0,
|
||||
triggerEvent: '',
|
||||
jobs: [] as Array<ActionsJob>,
|
||||
commit: {
|
||||
localeCommit: '',
|
||||
localePushedBy: '',
|
||||
shortSHA: '',
|
||||
link: '',
|
||||
pusher: {
|
||||
displayName: '',
|
||||
link: '',
|
||||
},
|
||||
branch: {
|
||||
name: '',
|
||||
link: '',
|
||||
isDeleted: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
||||
let loadingAbortController: AbortController | null = null;
|
||||
let intervalID: IntervalId | null = null;
|
||||
const viewData = reactive({
|
||||
currentRun: createEmptyActionsRun(),
|
||||
runArtifacts: [] as Array<ActionsArtifact>,
|
||||
});
|
||||
const loadCurrentRun = async () => {
|
||||
if (loadingAbortController) return;
|
||||
const abortController = new AbortController();
|
||||
loadingAbortController = abortController;
|
||||
try {
|
||||
const url = `${actionsUrl}/runs/${runId}`;
|
||||
const resp = await POST(url, {signal: abortController.signal, data: {}});
|
||||
const runResp = await resp.json();
|
||||
if (loadingAbortController !== abortController) return;
|
||||
|
||||
viewData.runArtifacts = runResp.artifacts || [];
|
||||
viewData.currentRun = runResp.state.run;
|
||||
// clear the interval timer if the job is done
|
||||
if (viewData.currentRun.done && intervalID) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// avoid network error while unloading page, and ignore "abort" error
|
||||
if (e instanceof TypeError || abortController.signal.aborted) return;
|
||||
throw e;
|
||||
} finally {
|
||||
if (loadingAbortController === abortController) loadingAbortController = null;
|
||||
}
|
||||
};
|
||||
|
||||
return reactive({
|
||||
viewData,
|
||||
|
||||
async startPollingCurrentRun() {
|
||||
await loadCurrentRun();
|
||||
intervalID = setInterval(() => loadCurrentRun(), 1000);
|
||||
},
|
||||
async forceReloadCurrentRun() {
|
||||
loadingAbortController?.abort();
|
||||
loadingAbortController = null;
|
||||
await loadCurrentRun();
|
||||
},
|
||||
stopPollingCurrentRun() {
|
||||
if (!intervalID) return;
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
|
||||
@ -1,497 +1,40 @@
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import {defineComponent, type PropType} from 'vue';
|
||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||
import {formatDatetime} from '../utils/time.ts';
|
||||
import {renderAnsi} from '../render/ansi.ts';
|
||||
import {toRefs} from 'vue';
|
||||
import {POST, DELETE} from '../modules/fetch.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
import WorkflowGraph from './WorkflowGraph.vue'
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import type {ActionsRunStatus, ActionsJob} from '../modules/gitea-actions.ts';
|
||||
import ActionRunSummaryView from './ActionRunSummaryView.vue';
|
||||
import ActionRunJobView from './ActionRunJobView.vue';
|
||||
import {createActionRunViewStore} from "./ActionRunView.ts";
|
||||
|
||||
type StepContainerElement = HTMLElement & {
|
||||
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
||||
// then the following batches of logs should still use the same group (active logs container).
|
||||
// maybe it can be refactored to decouple from the HTML element in the future.
|
||||
_stepLogsActiveContainer?: HTMLElement;
|
||||
}
|
||||
|
||||
export type LogLine = {
|
||||
index: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
|
||||
type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'hidden';
|
||||
type LogLineCommand = {
|
||||
name: LogLineCommandName,
|
||||
prefix: string,
|
||||
}
|
||||
|
||||
// How GitHub Actions logs work:
|
||||
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
|
||||
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
|
||||
// * The reported logs are the processed logs.
|
||||
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
|
||||
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
||||
'::group::': 'group',
|
||||
'##[group]': 'group',
|
||||
'::endgroup::': 'endgroup',
|
||||
'##[endgroup]': 'endgroup',
|
||||
|
||||
'##[error]': 'error',
|
||||
'[command]': 'command',
|
||||
|
||||
// https://github.com/actions/toolkit/blob/master/docs/commands.md
|
||||
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
|
||||
'::add-matcher::': 'hidden',
|
||||
'##[add-matcher]': 'hidden',
|
||||
'::remove-matcher': 'hidden', // it has arguments
|
||||
};
|
||||
|
||||
type Step = {
|
||||
summary: string,
|
||||
duration: string,
|
||||
status: ActionsRunStatus,
|
||||
}
|
||||
|
||||
type JobStepState = {
|
||||
cursor: string|null,
|
||||
expanded: boolean,
|
||||
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
||||
}
|
||||
|
||||
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
|
||||
// TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
|
||||
for (const prefix in LogLinePrefixCommandMap) {
|
||||
if (line.message.startsWith(prefix)) {
|
||||
return {name: LogLinePrefixCommandMap[prefix], prefix};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
|
||||
const logMsgAttrs = {class: 'log-msg'};
|
||||
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd?.name}`; // make it easier to add styles to some commands like "error"
|
||||
|
||||
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
|
||||
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
|
||||
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
|
||||
|
||||
const logMsg = createElementFromAttrs('span', logMsgAttrs);
|
||||
logMsg.innerHTML = renderAnsi(msgContent);
|
||||
return logMsg;
|
||||
}
|
||||
|
||||
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// only check whether bottom is in viewport, because the log element can be a log group which is usually tall
|
||||
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
|
||||
}
|
||||
|
||||
type LocaleStorageOptions = {
|
||||
autoScroll: boolean;
|
||||
expandRunning: boolean;
|
||||
showWorkflowGraph: boolean;
|
||||
actionsLogShowSeconds: boolean;
|
||||
actionsLogShowTimestamps: boolean;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
defineOptions({
|
||||
name: 'RepoActionView',
|
||||
components: {
|
||||
SvgIcon,
|
||||
ActionRunStatus,
|
||||
WorkflowGraph,
|
||||
},
|
||||
props: {
|
||||
runId: {type: Number, required: true},
|
||||
jobId: {type: Number, required: true},
|
||||
actionsURL: {type: String, required: true},
|
||||
locale: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
const defaultViewOptions: LocaleStorageOptions = {autoScroll: true, expandRunning: false, showWorkflowGraph: false, actionsLogShowSeconds: false, actionsLogShowTimestamps: false};
|
||||
const {autoScroll, expandRunning, showWorkflowGraph, actionsLogShowSeconds, actionsLogShowTimestamps} = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
|
||||
return {
|
||||
// internal state
|
||||
loadingAbortController: null as AbortController | null,
|
||||
intervalID: null as IntervalId | null,
|
||||
currentJobStepsStates: [] as Array<JobStepState>,
|
||||
artifacts: [] as Array<Record<string, any>>,
|
||||
menuVisible: false,
|
||||
isFullScreen: false,
|
||||
showWorkflowGraph: showWorkflowGraph,
|
||||
timeVisible: {
|
||||
'log-time-stamp': actionsLogShowTimestamps,
|
||||
'log-time-seconds': actionsLogShowSeconds,
|
||||
},
|
||||
optionAlwaysAutoScroll: autoScroll,
|
||||
optionAlwaysExpandRunning: expandRunning,
|
||||
|
||||
// provided by backend
|
||||
run: {
|
||||
link: '',
|
||||
title: '',
|
||||
titleHTML: '',
|
||||
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
||||
canCancel: false,
|
||||
canApprove: false,
|
||||
canRerun: false,
|
||||
canRerunFailed: false,
|
||||
canDeleteArtifact: false,
|
||||
done: false,
|
||||
workflowID: '',
|
||||
workflowLink: '',
|
||||
isSchedule: false,
|
||||
jobs: [
|
||||
// {
|
||||
// id: 0,
|
||||
// name: '',
|
||||
// status: '',
|
||||
// canRerun: false,
|
||||
// duration: '',
|
||||
// },
|
||||
] as Array<ActionsJob>,
|
||||
commit: {
|
||||
localeCommit: '',
|
||||
localePushedBy: '',
|
||||
shortSHA: '',
|
||||
link: '',
|
||||
pusher: {
|
||||
displayName: '',
|
||||
link: '',
|
||||
},
|
||||
branch: {
|
||||
name: '',
|
||||
link: '',
|
||||
isDeleted: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
currentJob: {
|
||||
title: '',
|
||||
detail: '',
|
||||
steps: [
|
||||
// {
|
||||
// summary: '',
|
||||
// duration: '',
|
||||
// status: '',
|
||||
// }
|
||||
] as Array<Step>,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
optionAlwaysAutoScroll() {
|
||||
this.saveLocaleStorageOptions();
|
||||
},
|
||||
optionAlwaysExpandRunning() {
|
||||
this.saveLocaleStorageOptions();
|
||||
},
|
||||
showWorkflowGraph() {
|
||||
this.saveLocaleStorageOptions();
|
||||
},
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
// load job data and then auto-reload periodically
|
||||
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
|
||||
await this.loadJob();
|
||||
|
||||
// auto-scroll to the bottom of the log group when it is opened
|
||||
// "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
|
||||
addDelegatedEventListener(this.elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
|
||||
if (!this.optionAlwaysAutoScroll) return;
|
||||
const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
|
||||
setTimeout(() => {
|
||||
if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
|
||||
elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
this.intervalID = setInterval(() => this.loadJob(), 1000);
|
||||
document.body.addEventListener('click', this.closeDropdown);
|
||||
this.hashChangeListener();
|
||||
window.addEventListener('hashchange', this.hashChangeListener);
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
document.body.removeEventListener('click', this.closeDropdown);
|
||||
window.removeEventListener('hashchange', this.hashChangeListener);
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// clear the interval timer when the component is unmounted
|
||||
// even our page is rendered once, not spa style
|
||||
if (this.intervalID) {
|
||||
clearInterval(this.intervalID);
|
||||
this.intervalID = null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
saveLocaleStorageOptions() {
|
||||
const opts: LocaleStorageOptions = {
|
||||
autoScroll: this.optionAlwaysAutoScroll,
|
||||
expandRunning: this.optionAlwaysExpandRunning,
|
||||
showWorkflowGraph: this.showWorkflowGraph,
|
||||
actionsLogShowSeconds: this.timeVisible['log-time-seconds'],
|
||||
actionsLogShowTimestamps: this.timeVisible['log-time-stamp'],
|
||||
};
|
||||
localUserSettings.setJsonObject('actions-view-options', opts);
|
||||
},
|
||||
|
||||
// get the job step logs container ('.job-step-logs')
|
||||
getJobStepLogsContainer(stepIndex: number): StepContainerElement {
|
||||
return (this.$refs.logs as any)[stepIndex];
|
||||
},
|
||||
|
||||
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
|
||||
getActiveLogsContainer(stepIndex: number): StepContainerElement {
|
||||
const el = this.getJobStepLogsContainer(stepIndex);
|
||||
return el._stepLogsActiveContainer ?? el;
|
||||
},
|
||||
// begin a log group
|
||||
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||
const el = (this.$refs.logs as any)[stepIndex] as StepContainerElement;
|
||||
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
|
||||
this.createLogLine(stepIndex, startTime, line, cmd),
|
||||
);
|
||||
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
|
||||
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
|
||||
elJobLogGroupSummary,
|
||||
elJobLogList,
|
||||
);
|
||||
el.append(elJobLogGroup);
|
||||
el._stepLogsActiveContainer = elJobLogList;
|
||||
},
|
||||
// end a log group
|
||||
endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||
const el = (this.$refs.logs as any)[stepIndex];
|
||||
el._stepLogsActiveContainer = null;
|
||||
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
|
||||
},
|
||||
|
||||
// show/hide the step logs for a step
|
||||
toggleStepLogs(idx: number) {
|
||||
this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
|
||||
if (this.currentJobStepsStates[idx].expanded) {
|
||||
this.loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
|
||||
} else if (this.currentJob.steps[idx].status === 'running') {
|
||||
this.currentJobStepsStates[idx].manuallyCollapsed = true;
|
||||
}
|
||||
},
|
||||
// cancel a run
|
||||
cancelRun() {
|
||||
POST(`${this.run.link}/cancel`);
|
||||
},
|
||||
// approve a run
|
||||
approveRun() {
|
||||
POST(`${this.run.link}/approve`);
|
||||
},
|
||||
|
||||
createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
|
||||
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
|
||||
String(line.index),
|
||||
);
|
||||
|
||||
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
||||
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
|
||||
);
|
||||
|
||||
const logMsg = createLogLineMessage(line, cmd);
|
||||
const seconds = Math.floor(line.timestamp - startTime);
|
||||
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
|
||||
`${seconds}s`, // for "Show seconds"
|
||||
);
|
||||
|
||||
toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
|
||||
toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
|
||||
|
||||
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
|
||||
lineNum, logTimeStamp, logMsg, logTimeSeconds,
|
||||
);
|
||||
},
|
||||
|
||||
shouldAutoScroll(stepIndex: number): boolean {
|
||||
if (!this.optionAlwaysAutoScroll) return false;
|
||||
const el = this.getJobStepLogsContainer(stepIndex);
|
||||
// if the logs container is empty, then auto-scroll if the step is expanded
|
||||
if (!el.lastChild) return this.currentJobStepsStates[stepIndex].expanded;
|
||||
// use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
|
||||
return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
|
||||
},
|
||||
|
||||
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
||||
for (const line of logLines) {
|
||||
const cmd = parseLogLineCommand(line);
|
||||
switch (cmd?.name) {
|
||||
case 'hidden':
|
||||
continue;
|
||||
case 'group':
|
||||
this.beginLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
case 'endgroup':
|
||||
this.endLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
}
|
||||
// the active logs container may change during the loop, for example: entering and leaving a group
|
||||
const el = this.getActiveLogsContainer(stepIndex);
|
||||
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
|
||||
}
|
||||
},
|
||||
|
||||
async deleteArtifact(name: string) {
|
||||
if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;
|
||||
// TODO: should escape the "name"?
|
||||
await DELETE(`${this.run.link}/artifacts/${name}`);
|
||||
await this.loadJobForce();
|
||||
},
|
||||
|
||||
async fetchJobData(abortController: AbortController) {
|
||||
const logCursors = this.currentJobStepsStates.map((it, idx) => {
|
||||
// cursor is used to indicate the last position of the logs
|
||||
// it's only used by backend, frontend just reads it and passes it back, it and can be any type.
|
||||
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
|
||||
return {step: idx, cursor: it.cursor, expanded: it.expanded};
|
||||
});
|
||||
const resp = await POST(`${this.actionsURL}/runs/${this.runId}/jobs/${this.jobId}`, {
|
||||
signal: abortController.signal,
|
||||
data: {logCursors},
|
||||
});
|
||||
return await resp.json();
|
||||
},
|
||||
|
||||
async loadJobForce() {
|
||||
this.loadingAbortController?.abort();
|
||||
this.loadingAbortController = null;
|
||||
await this.loadJob();
|
||||
},
|
||||
|
||||
async loadJob() {
|
||||
if (this.loadingAbortController) return;
|
||||
const abortController = new AbortController();
|
||||
this.loadingAbortController = abortController;
|
||||
try {
|
||||
const job = await this.fetchJobData(abortController);
|
||||
if (this.loadingAbortController !== abortController) return;
|
||||
|
||||
this.artifacts = job.artifacts || [];
|
||||
this.run = job.state.run;
|
||||
this.currentJob = job.state.currentJob;
|
||||
|
||||
// sync the currentJobStepsStates to store the job step states
|
||||
for (let i = 0; i < this.currentJob.steps.length; i++) {
|
||||
const autoExpand = this.optionAlwaysExpandRunning && this.currentJob.steps[i].status === 'running';
|
||||
if (!this.currentJobStepsStates[i]) {
|
||||
// initial states for job steps
|
||||
this.currentJobStepsStates[i] = {cursor: null, expanded: autoExpand, manuallyCollapsed: false};
|
||||
} else {
|
||||
// if the step is not manually collapsed by user, then auto-expand it if option is enabled
|
||||
if (autoExpand && !this.currentJobStepsStates[i].manuallyCollapsed) {
|
||||
this.currentJobStepsStates[i].expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find the step indexes that need to auto-scroll
|
||||
const autoScrollStepIndexes = new Map<number, boolean>();
|
||||
for (const logs of job.logs.stepsLog ?? []) {
|
||||
if (autoScrollStepIndexes.has(logs.step)) continue;
|
||||
autoScrollStepIndexes.set(logs.step, this.shouldAutoScroll(logs.step));
|
||||
}
|
||||
|
||||
// append logs to the UI
|
||||
for (const logs of job.logs.stepsLog ?? []) {
|
||||
// save the cursor, it will be passed to backend next time
|
||||
this.currentJobStepsStates[logs.step].cursor = logs.cursor;
|
||||
this.appendLogs(logs.step, logs.started, logs.lines);
|
||||
}
|
||||
|
||||
// auto-scroll to the last log line of the last step
|
||||
let autoScrollJobStepElement: StepContainerElement | undefined;
|
||||
for (let stepIndex = 0; stepIndex < this.currentJob.steps.length; stepIndex++) {
|
||||
if (!autoScrollStepIndexes.get(stepIndex)) continue;
|
||||
autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
|
||||
}
|
||||
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
|
||||
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
|
||||
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||
}
|
||||
|
||||
// clear the interval timer if the job is done
|
||||
if (this.run.done && this.intervalID) {
|
||||
clearInterval(this.intervalID);
|
||||
this.intervalID = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// avoid network error while unloading page, and ignore "abort" error
|
||||
if (e instanceof TypeError || abortController.signal.aborted) return;
|
||||
throw e;
|
||||
} finally {
|
||||
if (this.loadingAbortController === abortController) this.loadingAbortController = null;
|
||||
}
|
||||
},
|
||||
|
||||
isDone(status: ActionsRunStatus) {
|
||||
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
||||
},
|
||||
|
||||
isExpandable(status: ActionsRunStatus) {
|
||||
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
||||
},
|
||||
|
||||
closeDropdown() {
|
||||
if (this.menuVisible) this.menuVisible = false;
|
||||
},
|
||||
|
||||
elStepsContainer(): HTMLElement {
|
||||
return this.$refs.stepsContainer as HTMLElement;
|
||||
},
|
||||
|
||||
toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
||||
this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
|
||||
for (const el of this.elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
|
||||
toggleElem(el, this.timeVisible[`log-time-${type}`]);
|
||||
}
|
||||
this.saveLocaleStorageOptions();
|
||||
},
|
||||
|
||||
toggleFullScreen() {
|
||||
this.isFullScreen = !this.isFullScreen;
|
||||
toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
|
||||
},
|
||||
|
||||
async hashChangeListener() {
|
||||
const selectedLogStep = window.location.hash;
|
||||
if (!selectedLogStep) return;
|
||||
const [_, step, _line] = selectedLogStep.split('-');
|
||||
const stepNum = Number(step);
|
||||
if (!this.currentJobStepsStates[stepNum]) return;
|
||||
if (!this.currentJobStepsStates[stepNum].expanded && this.currentJobStepsStates[stepNum].cursor === null) {
|
||||
this.currentJobStepsStates[stepNum].expanded = true;
|
||||
// need to await for load job if the step log is loaded for the first time
|
||||
// so logline can be selected by querySelector
|
||||
await this.loadJob();
|
||||
}
|
||||
const logLine = this.elStepsContainer().querySelector(selectedLogStep);
|
||||
if (!logLine) return;
|
||||
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
runId: number;
|
||||
jobId: number;
|
||||
actionsUrl: string;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const locale = props.locale;
|
||||
const store = createActionRunViewStore(props.actionsUrl, props.runId);
|
||||
const {currentRun: run , runArtifacts: artifacts} = toRefs(store.viewData);
|
||||
|
||||
function cancelRun() {
|
||||
POST(`${run.value.link}/cancel`);
|
||||
}
|
||||
|
||||
function approveRun() {
|
||||
POST(`${run.value.link}/approve`);
|
||||
}
|
||||
|
||||
async function deleteArtifact(name: string) {
|
||||
if (!window.confirm(locale.confirmDeleteArtifact.replace('%s', name))) return;
|
||||
await DELETE(`${run.value.link}/artifacts/${encodeURIComponent(name)}`);
|
||||
await store.forceReloadCurrentRun();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<!-- make the view container full width to make users easier to read logs -->
|
||||
@ -504,9 +47,6 @@ export default defineComponent({
|
||||
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
|
||||
</div>
|
||||
<div class="flex-text-block tw-shrink-0 tw-flex-wrap">
|
||||
<button class="ui basic small compact button primary" @click="showWorkflowGraph = !showWorkflowGraph" :class="{ active: showWorkflowGraph }" v-if="run.jobs.length > 1">
|
||||
{{ locale.workflowGraph }}
|
||||
</button>
|
||||
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
|
||||
{{ locale.approve }}
|
||||
</button>
|
||||
@ -553,8 +93,16 @@ export default defineComponent({
|
||||
<div class="action-view-body">
|
||||
<div class="action-view-left">
|
||||
<div class="job-group-section">
|
||||
<a class="job-brief-item" :href="run.link" :class="!props.jobId ? 'selected' : ''">
|
||||
<div class="job-brief-item-left">
|
||||
<SvgIcon name="octicon-list-unordered" class="tw-mr-2"/>
|
||||
<span class="job-brief-name tw-mx-2 gt-ellipsis">{{ locale.summary }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="ui divider"/>
|
||||
<div class="job-brief-list">
|
||||
<a class="job-brief-item" :href="run.link+'/jobs/'+job.id" :class="jobId === job.id ? 'selected' : ''" v-for="job in run.jobs" :key="job.id">
|
||||
<div class="left-list-header">{{ locale.allJobs }}</div>
|
||||
<a class="job-brief-item" :href="run.link+'/jobs/'+job.id" :class="props.jobId === job.id ? 'selected' : ''" v-for="job in run.jobs" :key="job.id">
|
||||
<div class="job-brief-item-left">
|
||||
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
|
||||
<span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
|
||||
@ -567,9 +115,8 @@ export default defineComponent({
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-artifacts" v-if="artifacts.length > 0">
|
||||
<div class="job-artifacts-title">
|
||||
{{ locale.artifactsTitle }}
|
||||
</div>
|
||||
<div class="ui divider"/>
|
||||
<div class="left-list-header">{{ locale.artifactsTitle }} ({{ artifacts.length }})</div>
|
||||
<ul class="job-artifacts-list">
|
||||
<template v-for="artifact in artifacts" :key="artifact.name">
|
||||
<li class="job-artifacts-item">
|
||||
@ -594,82 +141,19 @@ export default defineComponent({
|
||||
</div>
|
||||
|
||||
<div class="action-view-right">
|
||||
<WorkflowGraph
|
||||
v-if="showWorkflowGraph && run.jobs.length > 1"
|
||||
:jobs="run.jobs"
|
||||
:current-job-id="jobId"
|
||||
:run-link="run.link"
|
||||
:workflow-id="run.workflowID"
|
||||
class="workflow-graph-container"
|
||||
<ActionRunSummaryView
|
||||
v-if="!props.jobId"
|
||||
:store="store"
|
||||
:locale="locale"
|
||||
/>
|
||||
<ActionRunJobView
|
||||
v-else
|
||||
:store="store"
|
||||
:locale="locale"
|
||||
:run-id="props.runId"
|
||||
:job-id="props.jobId"
|
||||
:actions-url="props.actionsUrl"
|
||||
/>
|
||||
|
||||
<div class="job-info-header">
|
||||
<div class="job-info-header-left gt-ellipsis">
|
||||
<h3 class="job-info-header-title gt-ellipsis">
|
||||
{{ currentJob.title }}
|
||||
</h3>
|
||||
<p class="job-info-header-detail">
|
||||
{{ currentJob.detail }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="job-info-header-right">
|
||||
<div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
|
||||
<button class="ui button tw-px-3">
|
||||
<SvgIcon name="octicon-gear" :size="18"/>
|
||||
</button>
|
||||
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
|
||||
<a class="item" @click="toggleTimeDisplay('seconds')">
|
||||
<i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showLogSeconds }}
|
||||
</a>
|
||||
<a class="item" @click="toggleTimeDisplay('stamp')">
|
||||
<i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showTimeStamps }}
|
||||
</a>
|
||||
<a class="item" @click="toggleFullScreen()">
|
||||
<i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showFullScreen }}
|
||||
</a>
|
||||
|
||||
<div class="divider"/>
|
||||
<a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
|
||||
<i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.logsAlwaysAutoScroll }}
|
||||
</a>
|
||||
<a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
|
||||
<i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.logsAlwaysExpandRunning }}
|
||||
</a>
|
||||
|
||||
<div class="divider"/>
|
||||
<a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link+'/jobs/'+jobId+'/logs'" download>
|
||||
<i class="icon"><SvgIcon name="octicon-download"/></i>
|
||||
{{ locale.downloadLogs }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
||||
<div class="job-step-container" ref="stepsContainer" v-show="currentJob.steps.length">
|
||||
<div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
|
||||
<div class="job-step-summary" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
|
||||
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
|
||||
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
|
||||
-->
|
||||
<SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
||||
<SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
|
||||
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
|
||||
|
||||
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
||||
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
||||
</div>
|
||||
|
||||
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
|
||||
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
|
||||
<div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -750,11 +234,9 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
.job-artifacts-title {
|
||||
font-size: 18px;
|
||||
margin-top: 16px;
|
||||
padding: 16px 10px 0 20px;
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
.left-list-header {
|
||||
font-size: 12px;
|
||||
color: var(--color-grey);
|
||||
}
|
||||
|
||||
.job-artifacts-item {
|
||||
@ -766,7 +248,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.job-artifacts-list {
|
||||
padding-left: 12px;
|
||||
padding-left: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@ -777,7 +259,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.job-brief-item {
|
||||
padding: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
@ -861,111 +343,6 @@ export default defineComponent({
|
||||
|
||||
/* end fomantic button overrides */
|
||||
|
||||
/* begin fomantic dropdown menu overrides */
|
||||
|
||||
.action-view-right .ui.dropdown .menu {
|
||||
background: var(--color-console-menu-bg);
|
||||
border-color: var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item {
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item:hover {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item:active {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-active-bg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .divider {
|
||||
border-top-color: var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
|
||||
background: var(--color-console-menu-bg);
|
||||
box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
/* end fomantic dropdown menu overrides */
|
||||
|
||||
.job-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 60px;
|
||||
z-index: 1; /* above .job-step-container */
|
||||
background: var(--color-console-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.job-info-header:has(+ .job-step-container) {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.job-info-header .job-info-header-title {
|
||||
color: var(--color-console-fg);
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.job-info-header .job-info-header-detail {
|
||||
color: var(--color-console-fg-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-info-header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-step-container {
|
||||
max-height: 100%;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
border-top: 1px solid var(--color-console-border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary {
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.step-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.step-expandable:hover {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-duration {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.selected {
|
||||
color: var(--color-console-fg);
|
||||
background-color: var(--color-console-active-bg);
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
/* workaround ansi_up issue related to faintStyle generating a CSS stacking context via `opacity`
|
||||
inline style which caused such elements to render above the .job-step-summary header. */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.action-view-body {
|
||||
flex-direction: column;
|
||||
@ -978,101 +355,3 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
|
||||
/* some elements are not managed by vue, so we need to use global style */
|
||||
.job-step-section {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs {
|
||||
font-family: var(--fonts-monospace);
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs .job-log-line {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.job-log-line:hover,
|
||||
.job-log-line:target {
|
||||
background-color: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-log-line:target {
|
||||
scroll-margin-top: 95px;
|
||||
}
|
||||
|
||||
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
||||
.job-log-line .line-num, .log-time-seconds {
|
||||
width: 48px;
|
||||
color: var(--color-text-light-3);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.job-log-line:target > .line-num {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.log-time-seconds {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.job-log-line .log-time,
|
||||
.log-time-stamp {
|
||||
color: var(--color-text-light-3);
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-msg {
|
||||
flex: 1;
|
||||
white-space: break-spaces;
|
||||
margin-left: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-cmd-command {
|
||||
color: var(--color-ansi-blue);
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-cmd-error {
|
||||
color: var(--color-ansi-red);
|
||||
}
|
||||
|
||||
/* selectors here are intentionally exact to only match fullscreen */
|
||||
|
||||
.full.height > .action-view-right {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.full.height > .action-view-right > .job-info-header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.full.height > .action-view-right > .job-step-container {
|
||||
height: calc(100% - 60px);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.job-log-group .job-log-list .job-log-line .log-msg {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.job-log-group-summary {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.job-log-group-summary > .job-log-line {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1; /* to avoid hiding the triangle of the "details" element */
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -40,7 +40,6 @@ interface StoredState {
|
||||
|
||||
const props = defineProps<{
|
||||
jobs: ActionsJob[];
|
||||
currentJobId: number;
|
||||
runLink: string;
|
||||
workflowId: string;
|
||||
}>()
|
||||
@ -86,9 +85,7 @@ const saveState = () => {
|
||||
};
|
||||
|
||||
loadSavedState();
|
||||
watch([translateX, translateY, scale], () => {
|
||||
debounce(500, saveState);
|
||||
})
|
||||
watch([translateX, translateY, scale], debounce(500, saveState))
|
||||
|
||||
const nodeWidth = computed(() => {
|
||||
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
|
||||
@ -588,8 +585,6 @@ function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||||
}
|
||||
|
||||
function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
if (job.id === props.currentJobId) return;
|
||||
|
||||
const link = `${props.runLink}/jobs/${job.id}`;
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
window.open(link, '_blank');
|
||||
@ -652,7 +647,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
<g
|
||||
v-for="job in jobsWithLayout"
|
||||
:key="job.id"
|
||||
:class="{'current-job': job.id === currentJobId}"
|
||||
class="job-node-group"
|
||||
@click="onNodeClick(job, $event)"
|
||||
@mouseenter="handleNodeMouseEnter(job)"
|
||||
@ -665,8 +659,8 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
:height="nodeHeight"
|
||||
rx="8"
|
||||
:fill="getNodeColor(job.status)"
|
||||
:stroke="job.id === currentJobId ? 'var(--color-primary)' : 'var(--color-card-border)'"
|
||||
:stroke-width="job.id === currentJobId ? '3' : '2'"
|
||||
stroke="var(--color-card-border)"
|
||||
stroke-width="2"
|
||||
class="job-rect"
|
||||
/>
|
||||
|
||||
@ -734,18 +728,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
keySplines="0.4, 0, 0.2, 1"
|
||||
/>
|
||||
</rect>
|
||||
|
||||
<text
|
||||
v-if="job.needs?.length"
|
||||
:x="job.x + nodeWidth / 2"
|
||||
:y="job.y - 8"
|
||||
fill="var(--color-text-light-2)"
|
||||
font-size="10"
|
||||
text-anchor="middle"
|
||||
class="job-deps-label"
|
||||
>
|
||||
← {{ job.needs.length }} deps
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
@ -769,10 +751,9 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--color-secondary-alpha-20);
|
||||
gap: 15px;
|
||||
padding: 8px 14px;
|
||||
background: var(--color-box-header);
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@ -786,7 +767,10 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
color: var(--color-text-light-2);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
column-gap: 8px;
|
||||
color: var(--color-text-light-1);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -805,7 +789,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
.graph-container {
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
min-height: 300px;
|
||||
max-height: 600px;
|
||||
@ -844,14 +827,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.job-node-group.current-job {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.job-node-group.current-job .job-rect {
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--color-primary) 30%, transparent));
|
||||
}
|
||||
|
||||
.job-name {
|
||||
max-width: calc(var(--node-width, 150px) - 50px);
|
||||
text-overflow: ellipsis;
|
||||
@ -862,8 +837,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
}
|
||||
|
||||
.job-status,
|
||||
.job-duration,
|
||||
.job-deps-label {
|
||||
.job-duration {
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
34
web_src/js/features/common-actions-permissions.ts
Normal file
34
web_src/js/features/common-actions-permissions.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {toggleElem, toggleElemClass} from '../utils/dom.ts';
|
||||
|
||||
export function initActionsPermissionsForm(): void {
|
||||
registerGlobalInitFunc('initRepoActionsPermissionsForm', initRepoActionsPermissionsForm);
|
||||
registerGlobalInitFunc('initOwnerActionsPermissionsForm', initOwnerActionsPermissionsForm);
|
||||
}
|
||||
|
||||
function initRepoActionsPermissionsForm(form: HTMLFormElement) {
|
||||
initActionsOverrideOwnerConfig(form);
|
||||
initActionsPermissionTable(form);
|
||||
}
|
||||
|
||||
function initOwnerActionsPermissionsForm(form: HTMLFormElement) {
|
||||
initActionsPermissionTable(form);
|
||||
}
|
||||
|
||||
function initActionsPermissionTable(form: HTMLFormElement) {
|
||||
// show or hide permissions table based on enable max permissions checkbox (aka: whether you use custom permissions or not)
|
||||
const permTable = form.querySelector<HTMLTableElement>('.js-permissions-table')!;
|
||||
const enableMaxCheckbox = form.querySelector<HTMLInputElement>('input[name=enable_max_permissions]')!;
|
||||
const onEnableMaxCheckboxChange = () => toggleElem(permTable, enableMaxCheckbox.checked);
|
||||
onEnableMaxCheckboxChange();
|
||||
enableMaxCheckbox.addEventListener('change', onEnableMaxCheckboxChange);
|
||||
}
|
||||
|
||||
function initActionsOverrideOwnerConfig(form: HTMLFormElement) {
|
||||
// enable or disable repo token permissions config section based on override owner config checkbox
|
||||
const overrideOwnerConfig = form.querySelector<HTMLInputElement>('input[name=override_owner_config]')!;
|
||||
const repoTokenPermConfigSection = form.querySelector('.js-repo-token-permissions-config')!;
|
||||
const onOverrideOwnerConfigChange = () => toggleElemClass(repoTokenPermConfigSection, 'container-disabled', !overrideOwnerConfig.checked);
|
||||
onOverrideOwnerConfigChange();
|
||||
overrideOwnerConfig.addEventListener('change', onOverrideOwnerConfigChange);
|
||||
}
|
||||
@ -5,10 +5,15 @@ const {appSubUrl} = window.config;
|
||||
|
||||
export function initCompSearchRepoBox(el: HTMLElement) {
|
||||
const uid = el.getAttribute('data-uid');
|
||||
const exclusive = el.getAttribute('data-exclusive');
|
||||
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
|
||||
if (exclusive === 'true') {
|
||||
url += `&exclusive=true`;
|
||||
}
|
||||
fomanticQuery(el).search({
|
||||
minCharacters: 2,
|
||||
apiSettings: {
|
||||
url: `${appSubUrl}/repo/search?q={query}&uid=${uid}`,
|
||||
url,
|
||||
onResponse(response: any) {
|
||||
const items = [];
|
||||
for (const item of response.data) {
|
||||
|
||||
@ -13,7 +13,7 @@ export function initRepositoryActionView() {
|
||||
const view = createApp(RepoActionView, {
|
||||
runId: parseInt(el.getAttribute('data-run-id')!),
|
||||
jobId: parseInt(el.getAttribute('data-job-id')!),
|
||||
actionsURL: el.getAttribute('data-actions-url'),
|
||||
actionsUrl: el.getAttribute('data-actions-url'),
|
||||
locale: {
|
||||
approve: el.getAttribute('data-locale-approve'),
|
||||
cancel: el.getAttribute('data-locale-cancel'),
|
||||
@ -24,6 +24,10 @@ export function initRepositoryActionView() {
|
||||
commit: el.getAttribute('data-locale-runs-commit'),
|
||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
|
||||
summary: el.getAttribute('data-locale-summary'),
|
||||
allJobs: el.getAttribute('data-locale-all-jobs'),
|
||||
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
||||
totalDuration: el.getAttribute('data-locale-total-duration'),
|
||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||
|
||||
@ -63,6 +63,7 @@ import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton}
|
||||
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
|
||||
import {callInitFunctions} from './modules/init.ts';
|
||||
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
|
||||
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
|
||||
import {initGlobalShortcut} from './modules/shortcut.ts';
|
||||
|
||||
const initStartTime = performance.now();
|
||||
@ -159,6 +160,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initOAuth2SettingsDisableCheckbox,
|
||||
|
||||
initRepoFileView,
|
||||
initActionsPermissionsForm,
|
||||
]);
|
||||
|
||||
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
|
||||
|
||||
@ -1,6 +1,41 @@
|
||||
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
|
||||
export type ActionsRunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
||||
|
||||
export type ActionsRun = {
|
||||
link: string,
|
||||
title: string,
|
||||
titleHTML: string,
|
||||
status: ActionsRunStatus,
|
||||
canCancel: boolean,
|
||||
canApprove: boolean,
|
||||
canRerun: boolean,
|
||||
canRerunFailed: boolean,
|
||||
canDeleteArtifact: boolean,
|
||||
done: boolean,
|
||||
workflowID: string,
|
||||
workflowLink: string,
|
||||
isSchedule: boolean,
|
||||
duration: string,
|
||||
triggeredAt: number,
|
||||
triggerEvent: string,
|
||||
jobs: Array<ActionsJob>,
|
||||
commit: {
|
||||
localeCommit: string,
|
||||
localePushedBy: string,
|
||||
shortSHA: string,
|
||||
link: string,
|
||||
pusher: {
|
||||
displayName: string,
|
||||
link: string,
|
||||
},
|
||||
branch: {
|
||||
name: string,
|
||||
link: string,
|
||||
isDeleted: boolean,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type ActionsJob = {
|
||||
id: number;
|
||||
jobId: string;
|
||||
@ -10,3 +45,8 @@ export type ActionsJob = {
|
||||
needs?: string[];
|
||||
duration: string;
|
||||
};
|
||||
|
||||
export type ActionsArtifact = {
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
@ -322,6 +322,10 @@ class RelativeTime extends HTMLElement {
|
||||
return this.getAttribute('prefix') ?? (this.format === 'datetime' ? '' : 'on');
|
||||
}
|
||||
|
||||
set prefix(v: string) {
|
||||
this.setAttribute('prefix', v);
|
||||
}
|
||||
|
||||
get #thresholdMs(): number {
|
||||
const ms = parseDurationMs(this.getAttribute('threshold') ?? '');
|
||||
return ms >= 0 ? ms : 30 * 86400000;
|
||||
@ -355,6 +359,10 @@ class RelativeTime extends HTMLElement {
|
||||
return this.getAttribute('datetime') || '';
|
||||
}
|
||||
|
||||
set datetime(v: string) {
|
||||
this.setAttribute('datetime', v);
|
||||
}
|
||||
|
||||
get date(): Date | null {
|
||||
const parsed = Date.parse(this.datetime);
|
||||
return Number.isNaN(parsed) ? null : new Date(parsed);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user