From d1f2d5d95bdc56c6aaa8f2cbf2b2c81777f39d61 Mon Sep 17 00:00:00 2001 From: Nick Humrich Date: Fri, 22 Aug 2025 15:26:38 -0600 Subject: [PATCH] Add environments and deployments to the actions. This implements #30079 --- Makefile | 2 +- models/actions/deployment.go | 204 ++++++++++ models/actions/environment.go | 274 ++++++++++++++ models/actions/environment_test.go | 246 ++++++++++++ models/actions/run.go | 43 +-- models/actions/run_job.go | 7 + models/actions/run_list.go | 21 +- models/actions/workflow_environment.go | 179 +++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_25/v322.go | 30 ++ modules/structs/environment.go | 63 ++++ options/locale/locale_en-US.ini | 31 ++ routers/api/v1/api.go | 9 + routers/api/v1/repo/environment.go | 319 ++++++++++++++++ routers/api/v1/swagger/repo.go | 14 + routers/web/repo/actions/actions.go | 81 +++- routers/web/repo/setting/environment.go | 93 +++++ routers/web/repo/view_home.go | 21 ++ routers/web/web.go | 10 + services/actions/notifier_helper.go | 4 +- services/actions/schedule_tasks.go | 2 +- services/actions/workflow.go | 4 +- services/convert/environment.go | 33 ++ templates/repo/actions/list.tmpl | 27 +- templates/repo/actions/runs_list.tmpl | 5 + templates/repo/home_sidebar_bottom.tmpl | 2 + templates/repo/home_sidebar_environments.tmpl | 35 ++ templates/repo/settings/actions.tmpl | 2 + templates/repo/settings/navbar.tmpl | 5 +- .../actions/environment_delete_modal.tmpl | 11 + .../shared/actions/environment_list.tmpl | 56 +++ .../shared/actions/environment_modal.tmpl | 32 ++ templates/swagger/v1_json.tmpl | 351 ++++++++++++++++++ web_src/css/actions.css | 9 + web_src/js/features/common-button.ts | 39 +- 35 files changed, 2222 insertions(+), 43 deletions(-) create mode 100644 models/actions/deployment.go create mode 100644 models/actions/environment.go create mode 100644 models/actions/environment_test.go create mode 100644 models/actions/workflow_environment.go create mode 100644 models/migrations/v1_25/v322.go create mode 100644 modules/structs/environment.go create mode 100644 routers/api/v1/repo/environment.go create mode 100644 routers/web/repo/setting/environment.go create mode 100644 services/convert/environment.go create mode 100644 templates/repo/home_sidebar_environments.tmpl create mode 100644 templates/shared/actions/environment_delete_modal.tmpl create mode 100644 templates/shared/actions/environment_list.tmpl create mode 100644 templates/shared/actions/environment_modal.tmpl diff --git a/Makefile b/Makefile index 9cd32e4c33f9d..12a2ac789af00 100644 --- a/Makefile +++ b/Makefile @@ -286,7 +286,7 @@ endif generate-swagger: $(SWAGGER_SPEC) ## generate the swagger spec from code comments $(SWAGGER_SPEC): $(GO_SOURCES) $(SWAGGER_SPEC_INPUT) - $(GO) run $(SWAGGER_PACKAGE) generate spec --exclude "$(SWAGGER_EXCLUDE)" --input "$(SWAGGER_SPEC_INPUT)" --output './$(SWAGGER_SPEC)' + $(GO) run $(SWAGGER_PACKAGE) generate spec --exclude "$(SWAGGER_EXCLUDE)" --input "$(SWAGGER_SPEC_INPUT)" --output './$(SWAGGER_SPEC)' --scan-models .PHONY: swagger-check swagger-check: generate-swagger diff --git a/models/actions/deployment.go b/models/actions/deployment.go new file mode 100644 index 0000000000000..cfdabfea9f5d4 --- /dev/null +++ b/models/actions/deployment.go @@ -0,0 +1,204 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" +) + +// DeploymentStatus represents the deployment status +type DeploymentStatus string + +const ( + DeploymentStatusQueued DeploymentStatus = "queued" + DeploymentStatusInProgress DeploymentStatus = "in_progress" + DeploymentStatusSuccess DeploymentStatus = "success" + DeploymentStatusFailure DeploymentStatus = "failure" + DeploymentStatusCancelled DeploymentStatus = "cancelled" + DeploymentStatusError DeploymentStatus = "error" +) + +// ActionDeployment represents a deployment attempt to an environment +type ActionDeployment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + Repo *repo_model.Repository `xorm:"-"` + RunID int64 `xorm:"INDEX NOT NULL"` + Run *ActionRun `xorm:"-"` + EnvironmentID int64 `xorm:"INDEX NOT NULL"` + Environment *ActionEnvironment `xorm:"-"` + + // Deployment details + Ref string `xorm:"INDEX"` // the commit/branch/tag being deployed + CommitSHA string `xorm:"INDEX"` // the commit SHA being deployed + Task string // deployment task/job name + Status DeploymentStatus `xorm:"INDEX"` + Description string `xorm:"TEXT"` + LogURL string `xorm:"TEXT"` + + // Creator info + CreatedByID int64 `xorm:"INDEX"` + CreatedBy *user_model.User `xorm:"-"` + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(ActionDeployment)) +} + +// TableName returns the table name for ActionDeployment +func (ActionDeployment) TableName() string { + return "action_deployment" +} + +// LoadEnvironment loads the environment for this deployment +func (d *ActionDeployment) LoadEnvironment(ctx context.Context) error { + if d.Environment != nil { + return nil + } + env := &ActionEnvironment{} + has, err := db.GetEngine(ctx).ID(d.EnvironmentID).Get(env) + if err != nil { + return err + } + if has { + d.Environment = env + } + return nil +} + +// LoadRun loads the run for this deployment +func (d *ActionDeployment) LoadRun(ctx context.Context) error { + if d.Run != nil { + return nil + } + run := &ActionRun{} + has, err := db.GetEngine(ctx).ID(d.RunID).Get(run) + if err != nil { + return err + } + if has { + d.Run = run + } + return nil +} + +// LoadRepo loads the repository for this deployment +func (d *ActionDeployment) LoadRepo(ctx context.Context) error { + if d.Repo != nil { + return nil + } + var err error + d.Repo, err = repo_model.GetRepositoryByID(ctx, d.RepoID) + return err +} + +// LoadCreatedBy loads the user who created this deployment +func (d *ActionDeployment) LoadCreatedBy(ctx context.Context) error { + if d.CreatedBy != nil { + return nil + } + var err error + d.CreatedBy, err = user_model.GetUserByID(ctx, d.CreatedByID) + return err +} + +// CreateDeploymentOptions contains options for creating a deployment +type CreateDeploymentOptions struct { + RepoID int64 + RunID int64 + EnvironmentID int64 + Ref string + CommitSHA string + Task string + Description string + CreatedByID int64 +} + +// CreateDeployment creates a new deployment +func CreateDeployment(ctx context.Context, opts CreateDeploymentOptions) (*ActionDeployment, error) { + deployment := &ActionDeployment{ + RepoID: opts.RepoID, + RunID: opts.RunID, + EnvironmentID: opts.EnvironmentID, + Ref: opts.Ref, + CommitSHA: opts.CommitSHA, + Task: opts.Task, + Status: DeploymentStatusQueued, + Description: opts.Description, + CreatedByID: opts.CreatedByID, + } + + return deployment, db.Insert(ctx, deployment) +} + +// FindDeploymentsOptions contains options for finding deployments +type FindDeploymentsOptions struct { + db.ListOptions + RepoID int64 + RunID int64 + EnvironmentID int64 + Status []DeploymentStatus + Ref string +} + +func (opts FindDeploymentsOptions) ToConds() builder.Cond { + cond := builder.NewCond() + + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } + + if opts.RunID > 0 { + cond = cond.And(builder.Eq{"run_id": opts.RunID}) + } + + if opts.EnvironmentID > 0 { + cond = cond.And(builder.Eq{"environment_id": opts.EnvironmentID}) + } + + if len(opts.Status) > 0 { + cond = cond.And(builder.In("status", opts.Status)) + } + + if opts.Ref != "" { + cond = cond.And(builder.Eq{"ref": opts.Ref}) + } + + return cond +} + +// FindDeployments finds deployments with the given options +func FindDeployments(ctx context.Context, opts FindDeploymentsOptions) ([]*ActionDeployment, error) { + return db.Find[ActionDeployment](ctx, opts) +} + +// UpdateDeploymentStatus updates the deployment status +func UpdateDeploymentStatus(ctx context.Context, deploymentID int64, status DeploymentStatus, logURL string) error { + deployment := &ActionDeployment{ + Status: status, + LogURL: logURL, + } + _, err := db.GetEngine(ctx).ID(deploymentID).Cols("status", "log_url", "updated_unix").Update(deployment) + return err +} + +// DeleteDeployment deletes a deployment +func DeleteDeployment(ctx context.Context, deploymentID int64) error { + _, err := db.GetEngine(ctx).ID(deploymentID).Delete(&ActionDeployment{}) + return err +} + +// CountDeployments counts deployments for a repository +func CountDeployments(ctx context.Context, repoID int64) (int64, error) { + return db.GetEngine(ctx).Where("repo_id = ?", repoID).Count(&ActionDeployment{}) +} diff --git a/models/actions/environment.go b/models/actions/environment.go new file mode 100644 index 0000000000000..8a50734cddfce --- /dev/null +++ b/models/actions/environment.go @@ -0,0 +1,274 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// ActionEnvironment represents a deployment environment +type ActionEnvironment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX UNIQUE(repo_name) NOT NULL"` + Name string `xorm:"UNIQUE(repo_name) NOT NULL"` + Description string `xorm:"TEXT"` + ExternalURL string `xorm:"TEXT"` + + // Protection rules as JSON + ProtectionRules string `xorm:"LONGTEXT"` + + // Audit fields + CreatedByID int64 `xorm:"INDEX"` + CreatedBy *user_model.User `xorm:"-"` + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + + // Relationships + Repo *repo_model.Repository `xorm:"-"` +} + +const ( + EnvironmentNameMaxLength = 255 + EnvironmentDescriptionMaxLength = 4096 + EnvironmentURLMaxLength = 2048 + ProtectionRulesMaxLength = 65536 +) + +func init() { + db.RegisterModel(new(ActionEnvironment)) +} + +// TableName returns the table name for ActionEnvironment +func (ActionEnvironment) TableName() string { + return "action_environment" +} + +// LoadRepo loads the repository for this environment +func (env *ActionEnvironment) LoadRepo(ctx context.Context) error { + if env.Repo != nil { + return nil + } + var err error + env.Repo, err = repo_model.GetRepositoryByID(ctx, env.RepoID) + return err +} + +// LoadCreatedBy loads the user who created this environment +func (env *ActionEnvironment) LoadCreatedBy(ctx context.Context) error { + if env.CreatedBy != nil { + return nil + } + var err error + env.CreatedBy, err = user_model.GetUserByID(ctx, env.CreatedByID) + return err +} + +// CreateEnvironment creates a new deployment environment +func CreateEnvironment(ctx context.Context, opts CreateEnvironmentOptions) (*ActionEnvironment, error) { + if err := opts.Validate(); err != nil { + return nil, err + } + + env := &ActionEnvironment{ + RepoID: opts.RepoID, + Name: opts.Name, + Description: opts.Description, + ExternalURL: opts.ExternalURL, + ProtectionRules: opts.ProtectionRules, + CreatedByID: opts.CreatedByID, + } + + return env, db.Insert(ctx, env) +} + +// CreateEnvironmentOptions contains the options for creating an environment +type CreateEnvironmentOptions struct { + RepoID int64 + Name string + Description string + ExternalURL string + ProtectionRules string + CreatedByID int64 +} + +// Validate validates the create environment options +func (opts *CreateEnvironmentOptions) Validate() error { + if opts.RepoID <= 0 { + return util.NewInvalidArgumentErrorf("repository ID is required") + } + + if strings.TrimSpace(opts.Name) == "" { + return util.NewInvalidArgumentErrorf("environment name is required") + } + + opts.Name = strings.TrimSpace(opts.Name) + if utf8.RuneCountInString(opts.Name) > EnvironmentNameMaxLength { + return util.NewInvalidArgumentErrorf("environment name too long") + } + + if utf8.RuneCountInString(opts.Description) > EnvironmentDescriptionMaxLength { + return util.NewInvalidArgumentErrorf("description too long") + } + + if utf8.RuneCountInString(opts.ExternalURL) > EnvironmentURLMaxLength { + return util.NewInvalidArgumentErrorf("external URL too long") + } + + if utf8.RuneCountInString(opts.ProtectionRules) > ProtectionRulesMaxLength { + return util.NewInvalidArgumentErrorf("protection rules too long") + } + + opts.Description = util.TruncateRunes(opts.Description, EnvironmentDescriptionMaxLength) + + return nil +} + +// FindEnvironmentsOptions contains options for finding environments +type FindEnvironmentsOptions struct { + db.ListOptions + RepoID int64 + Name string +} + +func (opts FindEnvironmentsOptions) ToConds() builder.Cond { + cond := builder.NewCond() + + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } + + if opts.Name != "" { + cond = cond.And(builder.Eq{"name": opts.Name}) + } + + return cond +} + +// FindEnvironments finds environments with the given options +func FindEnvironments(ctx context.Context, opts FindEnvironmentsOptions) ([]*ActionEnvironment, error) { + return db.Find[ActionEnvironment](ctx, opts) +} + +// GetEnvironmentByRepoIDAndName gets an environment by repository ID and name +func GetEnvironmentByRepoIDAndName(ctx context.Context, repoID int64, name string) (*ActionEnvironment, error) { + env := &ActionEnvironment{} + has, err := db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Get(env) + if err != nil { + return nil, err + } + if !has { + return nil, util.NewNotExistErrorf("environment does not exist") + } + return env, nil +} + +// UpdateEnvironmentOptions contains options for updating an environment +type UpdateEnvironmentOptions struct { + Description *string + ExternalURL *string + ProtectionRules *string +} + +// Validate validates the update environment options +func (opts *UpdateEnvironmentOptions) Validate() error { + if opts.Description != nil && utf8.RuneCountInString(*opts.Description) > EnvironmentDescriptionMaxLength { + return util.NewInvalidArgumentErrorf("description too long") + } + + if opts.ExternalURL != nil && utf8.RuneCountInString(*opts.ExternalURL) > EnvironmentURLMaxLength { + return util.NewInvalidArgumentErrorf("external URL too long") + } + + if opts.ProtectionRules != nil && utf8.RuneCountInString(*opts.ProtectionRules) > ProtectionRulesMaxLength { + return util.NewInvalidArgumentErrorf("protection rules too long") + } + + return nil +} + +// UpdateEnvironment updates an environment +func UpdateEnvironment(ctx context.Context, env *ActionEnvironment, opts UpdateEnvironmentOptions) error { + if err := opts.Validate(); err != nil { + return err + } + + cols := make([]string, 0) + + if opts.Description != nil { + env.Description = util.TruncateRunes(*opts.Description, EnvironmentDescriptionMaxLength) + cols = append(cols, "description") + } + + if opts.ExternalURL != nil { + env.ExternalURL = *opts.ExternalURL + cols = append(cols, "external_url") + } + + if opts.ProtectionRules != nil { + env.ProtectionRules = *opts.ProtectionRules + cols = append(cols, "protection_rules") + } + + if len(cols) == 0 { + return nil + } + + cols = append(cols, "updated_unix") + + _, err := db.GetEngine(ctx).ID(env.ID).Cols(cols...).Update(env) + return err +} + +// DeleteEnvironment deletes an environment +func DeleteEnvironment(ctx context.Context, repoID int64, name string) error { + _, err := db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Delete(&ActionEnvironment{}) + return err +} + +// CountEnvironments counts environments for a repository +func CountEnvironments(ctx context.Context, repoID int64) (int64, error) { + return db.GetEngine(ctx).Where("repo_id = ?", repoID).Count(&ActionEnvironment{}) +} + +// CheckEnvironmentExists checks if an environment exists +func CheckEnvironmentExists(ctx context.Context, repoID int64, name string) (bool, error) { + return db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Exist(&ActionEnvironment{}) +} + +// CreateOrGetEnvironmentByName creates an environment if it doesn't exist, otherwise returns the existing one +func CreateOrGetEnvironmentByName(ctx context.Context, repoID int64, name string, createdByID int64, externalURL string) (*ActionEnvironment, error) { + // First try to get existing environment + env, err := GetEnvironmentByRepoIDAndName(ctx, repoID, name) + if err == nil { + return env, nil + } + + // If not found, create a new environment with default values + if errors.Is(err, util.ErrNotExist) { + createOpts := CreateEnvironmentOptions{ + RepoID: repoID, + Name: name, + Description: "Auto-created from Actions workflow", + ExternalURL: externalURL, + ProtectionRules: "", + CreatedByID: createdByID, + } + + return CreateEnvironment(ctx, createOpts) + } + + // Return any other error + return nil, err +} diff --git a/models/actions/environment_test.go b/models/actions/environment_test.go new file mode 100644 index 0000000000000..aa6a872bd7af7 --- /dev/null +++ b/models/actions/environment_test.go @@ -0,0 +1,246 @@ +//go:build sqlite + +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateEnvironmentOptions_Validate(t *testing.T) { + tests := []struct { + name string + opts CreateEnvironmentOptions + wantErr bool + }{ + { + name: "valid options", + opts: CreateEnvironmentOptions{ + RepoID: 1, + Name: "production", + Description: "Production environment", + CreatedByID: 1, + }, + wantErr: false, + }, + { + name: "empty name", + opts: CreateEnvironmentOptions{ + RepoID: 1, + Name: "", + CreatedByID: 1, + }, + wantErr: true, + }, + { + name: "invalid repo ID", + opts: CreateEnvironmentOptions{ + RepoID: 0, + Name: "test", + CreatedByID: 1, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.opts.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestEnvironmentCRUD(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + const repoID = 1 + const userID = 1 + + // Test Create + opts := CreateEnvironmentOptions{ + RepoID: repoID, + Name: "test-env", + Description: "Test environment", + ExternalURL: "https://test.example.com", + CreatedByID: userID, + } + + env, err := CreateEnvironment(db.DefaultContext, opts) + require.NoError(t, err) + assert.Equal(t, opts.Name, env.Name) + assert.Equal(t, opts.Description, env.Description) + assert.Equal(t, opts.ExternalURL, env.ExternalURL) + assert.Equal(t, opts.RepoID, env.RepoID) + assert.Equal(t, opts.CreatedByID, env.CreatedByID) + + // Test Get + retrievedEnv, err := GetEnvironmentByRepoIDAndName(db.DefaultContext, repoID, "test-env") + require.NoError(t, err) + assert.Equal(t, env.ID, retrievedEnv.ID) + assert.Equal(t, env.Name, retrievedEnv.Name) + + // Test Find + envs, err := FindEnvironments(db.DefaultContext, FindEnvironmentsOptions{ + RepoID: repoID, + }) + require.NoError(t, err) + assert.Len(t, envs, 1) + assert.Equal(t, env.ID, envs[0].ID) + + // Test Update + updateOpts := UpdateEnvironmentOptions{ + Description: func() *string { s := "Updated description"; return &s }(), + } + err = UpdateEnvironment(db.DefaultContext, env, updateOpts) + require.NoError(t, err) + + // Verify update + updatedEnv, err := GetEnvironmentByRepoIDAndName(db.DefaultContext, repoID, "test-env") + require.NoError(t, err) + assert.Equal(t, "Updated description", updatedEnv.Description) + + // Test Count + count, err := CountEnvironments(db.DefaultContext, repoID) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Test Check Exists + exists, err := CheckEnvironmentExists(db.DefaultContext, repoID, "test-env") + require.NoError(t, err) + assert.True(t, exists) + + // Test Delete + err = DeleteEnvironment(db.DefaultContext, repoID, "test-env") + require.NoError(t, err) + + // Verify deletion + _, err = GetEnvironmentByRepoIDAndName(db.DefaultContext, repoID, "test-env") + assert.Error(t, err) + + exists, err = CheckEnvironmentExists(db.DefaultContext, repoID, "test-env") + require.NoError(t, err) + assert.False(t, exists) + + count, err = CountEnvironments(db.DefaultContext, repoID) + require.NoError(t, err) + assert.Equal(t, int64(0), count) +} + +func TestCreateOrGetEnvironmentByName(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + const repoID = 1 + const userID = 1 + + // Test creating new environment + env, err := CreateOrGetEnvironmentByName(db.DefaultContext, repoID, "new-env", userID, "https://new.example.com") + require.NoError(t, err) + assert.Equal(t, "new-env", env.Name) + assert.Equal(t, int64(repoID), env.RepoID) + assert.Equal(t, int64(userID), env.CreatedByID) + assert.Equal(t, "Auto-created from Actions workflow", env.Description) + assert.Equal(t, "https://new.example.com", env.ExternalURL) + + // Test getting existing environment (should return same environment) + env2, err := CreateOrGetEnvironmentByName(db.DefaultContext, repoID, "new-env", userID+1, "https://different.example.com") + require.NoError(t, err) + assert.Equal(t, env.ID, env2.ID) + assert.Equal(t, env.Name, env2.Name) + assert.Equal(t, int64(userID), env2.CreatedByID) // Should keep original creator + assert.Equal(t, "Auto-created from Actions workflow", env2.Description) // Should keep original description + assert.Equal(t, "https://new.example.com", env2.ExternalURL) // Should keep original URL + + // Cleanup + err = DeleteEnvironment(db.DefaultContext, repoID, "new-env") + require.NoError(t, err) +} + +func TestExtractEnvironmentsFromWorkflow(t *testing.T) { + tests := []struct { + name string + workflow string + expected []*WorkflowEnvironmentInfo + }{ + { + name: "simple string environment", + workflow: ` +jobs: + deploy: + environment: production +`, + expected: []*WorkflowEnvironmentInfo{ + {JobID: "deploy", Environment: "production"}, + }, + }, + { + name: "object environment with URL", + workflow: ` +jobs: + deploy: + environment: + name: staging + url: https://staging.example.com +`, + expected: []*WorkflowEnvironmentInfo{ + {JobID: "deploy", Environment: "staging", URL: "https://staging.example.com"}, + }, + }, + { + name: "multiple jobs with environments", + workflow: ` +jobs: + test: + runs-on: ubuntu-latest + deploy-staging: + environment: staging + deploy-prod: + environment: + name: production + url: https://prod.example.com +`, + expected: []*WorkflowEnvironmentInfo{ + {JobID: "deploy-staging", Environment: "staging"}, + {JobID: "deploy-prod", Environment: "production", URL: "https://prod.example.com"}, + }, + }, + { + name: "no environments", + workflow: ` +jobs: + test: + runs-on: ubuntu-latest +`, + expected: []*WorkflowEnvironmentInfo{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envInfos, err := ExtractEnvironmentsFromWorkflow([]byte(tt.workflow)) + require.NoError(t, err) + + assert.Len(t, envInfos, len(tt.expected)) + + for i, expected := range tt.expected { + if i < len(envInfos) { + assert.Equal(t, expected.JobID, envInfos[i].JobID) + assert.Equal(t, expected.Environment, envInfos[i].Environment) + assert.Equal(t, expected.URL, envInfos[i].URL) + } + } + }) + } +} diff --git a/models/actions/run.go b/models/actions/run.go index f5ccba06c22b3..dd479d63b5928 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -28,27 +28,28 @@ import ( // ActionRun represents a run of a workflow file type ActionRun struct { - ID int64 - Title string - RepoID int64 `xorm:"index unique(repo_index)"` - Repo *repo_model.Repository `xorm:"-"` - OwnerID int64 `xorm:"index"` - WorkflowID string `xorm:"index"` // the name of workflow file - Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository - TriggerUserID int64 `xorm:"index"` - TriggerUser *user_model.User `xorm:"-"` - ScheduleID int64 - Ref string `xorm:"index"` // the commit/tag/… that caused the run - IsRefDeleted bool `xorm:"-"` - CommitSHA string - IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow. - NeedApproval bool // may need approval if it's a fork pull request - ApprovedBy int64 `xorm:"index"` // who approved - Event webhook_module.HookEventType // the webhook event that causes the workflow to run - EventPayload string `xorm:"LONGTEXT"` - TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow - Status Status `xorm:"index"` - Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed + ID int64 + Title string + RepoID int64 `xorm:"index unique(repo_index)"` + Repo *repo_model.Repository `xorm:"-"` + OwnerID int64 `xorm:"index"` + WorkflowID string `xorm:"index"` // the name of workflow file + Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository + TriggerUserID int64 `xorm:"index"` + TriggerUser *user_model.User `xorm:"-"` + ScheduleID int64 + Ref string `xorm:"index"` // the commit/tag/… that caused the run + IsRefDeleted bool `xorm:"-"` + EnvironmentJobIndex int64 `xorm:"-"` // For template use: the job index that targets the current environment filter + CommitSHA string + IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow. + NeedApproval bool // may need approval if it's a fork pull request + ApprovedBy int64 `xorm:"index"` // who approved + Event webhook_module.HookEventType // the webhook event that causes the workflow to run + EventPayload string `xorm:"LONGTEXT"` + TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow + Status Status `xorm:"index"` + Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed // Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0 Started timeutil.TimeStamp Stopped timeutil.TimeStamp diff --git a/models/actions/run_job.go b/models/actions/run_job.go index e7fa21270c11a..a2bf7c7bc4bfb 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -160,6 +161,12 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil { return 0, fmt.Errorf("update run %d: %w", run.ID, err) } + + // Update deployment status for any deployments associated with this run + if err := UpdateDeploymentStatusForRun(ctx, run); err != nil { + // Don't fail the job update if deployment status update fails + log.Error("Failed to update deployment status for run %d: %v", run.ID, err) + } } return affected, nil diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 12c55e538e7f7..ff6fa7de95c1e 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -73,6 +73,7 @@ type FindRunOptions struct { Approved bool // not util.OptionalBool, it works only when it's true Status []Status CommitSHA string + Environment string // filter by environment name through deployments } func (opts FindRunOptions) ToConds() builder.Cond { @@ -105,13 +106,27 @@ func (opts FindRunOptions) ToConds() builder.Cond { } func (opts FindRunOptions) ToJoins() []db.JoinFunc { + joins := make([]db.JoinFunc, 0, 3) + if opts.OwnerID > 0 { - return []db.JoinFunc{func(sess db.Engine) error { + joins = append(joins, func(sess db.Engine) error { sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID) return nil - }} + }) } - return nil + + if opts.Environment != "" { + joins = append(joins, func(sess db.Engine) error { + sess.Join("INNER", "action_deployment", "`action_deployment`.run_id = `action_run`.id") + sess.Join("INNER", "action_environment", "`action_environment`.id = `action_deployment`.environment_id AND `action_environment`.name = ?", opts.Environment) + return nil + }) + } + + if len(joins) == 0 { + return nil + } + return joins } func (opts FindRunOptions) ToOrders() string { diff --git a/models/actions/workflow_environment.go b/models/actions/workflow_environment.go new file mode 100644 index 0000000000000..3e471fe2edb58 --- /dev/null +++ b/models/actions/workflow_environment.go @@ -0,0 +1,179 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + + "github.com/nektos/act/pkg/jobparser" + "gopkg.in/yaml.v3" +) + +// WorkflowEnvironmentInfo represents environment information extracted from a workflow +type WorkflowEnvironmentInfo struct { + JobID string + Environment string + URL string // optional environment URL +} + +// ExtractEnvironmentsFromWorkflow parses workflow content and extracts environment information +func ExtractEnvironmentsFromWorkflow(workflowContent []byte) ([]*WorkflowEnvironmentInfo, error) { + var workflow struct { + Jobs map[string]struct { + Environment any `yaml:"environment"` + } `yaml:"jobs"` + } + + if err := yaml.Unmarshal(workflowContent, &workflow); err != nil { + return nil, err + } + + var environments []*WorkflowEnvironmentInfo + for jobID, job := range workflow.Jobs { + if job.Environment == nil { + continue + } + + envInfo := &WorkflowEnvironmentInfo{JobID: jobID} + + switch env := job.Environment.(type) { + case string: + // Simple string format: environment: production + envInfo.Environment = env + case map[string]any: + // Object format: environment: { name: production, url: https://example.com } + if name, ok := env["name"].(string); ok { + envInfo.Environment = name + } + if url, ok := env["url"].(string); ok { + envInfo.URL = url + } + } + + if envInfo.Environment != "" { + environments = append(environments, envInfo) + } + } + + return environments, nil +} + +// CreateDeploymentsForRun creates deployment records for a workflow run that targets environments +func CreateDeploymentsForRun(ctx context.Context, run *ActionRun, workflowContent []byte, jobs []*jobparser.SingleWorkflow) error { + // Extract environment information from workflow + envInfos, err := ExtractEnvironmentsFromWorkflow(workflowContent) + if err != nil { + log.Warn("Failed to extract environments from workflow: %v", err) + return nil // Don't fail the run if environment parsing fails + } + + if len(envInfos) == 0 { + return nil // No environments specified + } + + // Create deployments for each environment + for _, envInfo := range envInfos { + // Find or create the environment by name + env, err := CreateOrGetEnvironmentByName(ctx, run.RepoID, envInfo.Environment, run.TriggerUserID, envInfo.URL) + if err != nil { + log.Error("Failed to create or get environment '%s' for repo %d: %v", envInfo.Environment, run.RepoID, err) + continue // Skip if environment creation/retrieval fails + } + + // Find the job name for this job ID + var jobName string + for _, job := range jobs { + jobID, _ := job.Job() + if strings.EqualFold(jobID, envInfo.JobID) { + _, jobModel := job.Job() + jobName = jobModel.Name + break + } + } + if jobName == "" { + jobName = envInfo.JobID + } + + // Create deployment record + deployment := &ActionDeployment{ + RepoID: run.RepoID, + RunID: run.ID, + EnvironmentID: env.ID, + Ref: run.Ref, + CommitSHA: run.CommitSHA, + Task: jobName, + Status: DeploymentStatusQueued, + Description: "Deployment to " + envInfo.Environment + " environment", + CreatedByID: run.TriggerUserID, + } + + if err := db.Insert(ctx, deployment); err != nil { + log.Error("Failed to create deployment for environment '%s': %v", envInfo.Environment, err) + continue + } + + log.Info("Created deployment %d for run %d to environment '%s'", deployment.ID, run.ID, envInfo.Environment) + } + + return nil +} + +// UpdateDeploymentStatusForRun updates deployment status when workflow run status changes +func UpdateDeploymentStatusForRun(ctx context.Context, run *ActionRun) error { + // Find deployments for this run + deployments, err := FindDeployments(ctx, FindDeploymentsOptions{ + RunID: run.ID, + }) + if err != nil { + return err + } + + if len(deployments) == 0 { + return nil // No deployments to update + } + + // Map run status to deployment status + var deploymentStatus DeploymentStatus + switch run.Status { + case StatusWaiting, StatusBlocked: + deploymentStatus = DeploymentStatusQueued + case StatusRunning: + deploymentStatus = DeploymentStatusInProgress + case StatusSuccess: + deploymentStatus = DeploymentStatusSuccess + case StatusFailure, StatusCancelled: + deploymentStatus = DeploymentStatusFailure + default: + return nil // Don't update for unknown statuses + } + + // Update all deployments for this run + for _, deployment := range deployments { + if err := UpdateDeploymentStatus(ctx, deployment.ID, deploymentStatus, ""); err != nil { + log.Error("Failed to update deployment %d status to %s: %v", deployment.ID, deploymentStatus, err) + } + } + + return nil +} + +// InsertRunWithDeployments creates a run and automatically creates deployments for any environments specified +func InsertRunWithDeployments(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow, workflowContent []byte) error { + // First create the run using the existing logic + if err := InsertRun(ctx, run, jobs); err != nil { + return err + } + + // Then create deployments if environments are specified + if err := CreateDeploymentsForRun(ctx, run, workflowContent, jobs); err != nil { + log.Error("Failed to create deployments for run %d: %v", run.ID, err) + // Don't fail the run creation if deployment creation fails + } + + return nil +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 4f899453b5f57..e88c2a89a4141 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -386,6 +386,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.24.0 ends at database version 321 newMigration(321, "Use LONGTEXT for some columns and fix review_state.updated_files column", v1_25.UseLongTextInSomeColumnsAndFixBugs), + newMigration(322, "Create action_environment table for deployment environments", v1_25.CreateActionEnvironmentTable), } return preparedMigrations } diff --git a/models/migrations/v1_25/v322.go b/models/migrations/v1_25/v322.go new file mode 100644 index 0000000000000..e379cf4379b54 --- /dev/null +++ b/models/migrations/v1_25/v322.go @@ -0,0 +1,30 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_25 + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func CreateActionEnvironmentTable(x *xorm.Engine) error { + type ActionEnvironment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX UNIQUE(repo_name) NOT NULL"` + Name string `xorm:"UNIQUE(repo_name) NOT NULL"` + Description string `xorm:"TEXT"` + ExternalURL string `xorm:"TEXT"` + + // Protection rules as JSON + ProtectionRules string `xorm:"LONGTEXT"` + + // Audit fields + CreatedByID int64 `xorm:"INDEX"` + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + + return x.Sync(new(ActionEnvironment)) +} diff --git a/modules/structs/environment.go b/modules/structs/environment.go new file mode 100644 index 0000000000000..a91bf9df52be5 --- /dev/null +++ b/modules/structs/environment.go @@ -0,0 +1,63 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import ( + "time" +) + +// Environment represents a deployment environment +// swagger:model +type Environment struct { + // ID of the environment + ID int64 `json:"id"` + // Name of the environment + Name string `json:"name"` + // Description of the environment + Description string `json:"description,omitempty"` + // External URL associated with the environment + ExternalURL string `json:"external_url,omitempty"` + // Protection rules as JSON string + ProtectionRules string `json:"protection_rules,omitempty"` + // swagger:strfmt date-time + CreatedAt time.Time `json:"created_at"` + // swagger:strfmt date-time + UpdatedAt time.Time `json:"updated_at"` + // User who created the environment + CreatedBy *User `json:"created_by,omitempty"` +} + +// CreateEnvironmentOption options for creating an environment +// swagger:model +type CreateEnvironmentOption struct { + // required: true + // Name of the environment + Name string `json:"name" binding:"Required"` + // Description of the environment + Description string `json:"description,omitempty"` + // External URL associated with the environment + ExternalURL string `json:"external_url,omitempty"` + // Protection rules as JSON string + ProtectionRules string `json:"protection_rules,omitempty"` +} + +// UpdateEnvironmentOption options for updating an environment +// swagger:model +type UpdateEnvironmentOption struct { + // Description of the environment + Description *string `json:"description,omitempty"` + // External URL associated with the environment + ExternalURL *string `json:"external_url,omitempty"` + // Protection rules as JSON string + ProtectionRules *string `json:"protection_rules,omitempty"` +} + +// EnvironmentListResponse returns environments list +// swagger:model +type EnvironmentListResponse struct { + // List of environments + Environments []*Environment `json:"environments"` + // Total number of environments + TotalCount int64 `json:"total_count"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 315241a4174ab..3417bdfb551eb 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3837,6 +3837,17 @@ runners.version = Version runners.reset_registration_token = Reset registration token runners.reset_registration_token_confirm = Would you like to invalidate the current token and generate a new one? runners.reset_registration_token_success = Runner registration token reset successfully +add_environment = Add Environment +no_environments = No environments available yet. +environment_description = Deployment environments allow you to configure custom deployment rules and protection rules. +environment_name_placeholder = production +environment_description_placeholder = Description of this deployment environment +delete_environment = Delete Environment +delete_environment_desc = Deleting an environment will remove all associated protection rules and deployment history. +delete_environment_confirm = Are you sure you want to delete this environment? + +workflows = Workflows +deployments = Deployments runs.all_workflows = All Workflows runs.commit = Commit @@ -3893,6 +3904,26 @@ variables.creation.success = The variable "%s" has been added. variables.update.failed = Failed to edit variable. variables.update.success = The variable has been edited. +environments = Environments +environments.management = Environments Management +environments.creation = Add Environment +environments.none = There are no environments yet. +environments.deletion = Remove environment +environments.deletion.description = Removing an environment is permanent and cannot be undone. Continue? +environments.description = Environments allow you to configure deployment targets with protection rules. +environments.id_not_exist = Environment with ID %d does not exist. +environments.edit = Edit Environment +environments.deletion.failed = Failed to remove environment. +environments.deletion.success = The environment has been removed. +environments.creation.failed = Failed to add environment. +environments.creation.success = The environment "%s" has been added. +environments.update.failed = Failed to edit environment. +environments.update.success = The environment has been edited. +environments.tier.production = Production +environments.tier.staging = Staging +environments.tier.testing = Testing +environments.tier.development = Development + logs.always_auto_scroll = Always auto scroll logs logs.always_expand_running = Always expand running logs diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f412e8a06caca..6cb433c7b9ed6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1299,6 +1299,15 @@ func Routes() *web.Router { m.Delete("", reqRepoWriter(unit.TypeActions), repo.DeleteArtifact) }) m.Get("/artifacts/{artifact_id}/zip", repo.DownloadArtifact) + // Environment management endpoints + m.Group("/environments", func() { + m.Get("", repo.ListEnvironments) + m.Group("/{environment_name}", func() { + m.Get("", repo.GetEnvironment) + m.Put("", reqToken(), reqRepoWriter(unit.TypeActions), bind(api.CreateEnvironmentOption{}), repo.CreateOrUpdateEnvironment) + m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteEnvironment) + }) + }) }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true)) m.Group("/keys", func() { m.Combo("").Get(repo.ListDeployKeys). diff --git a/routers/api/v1/repo/environment.go b/routers/api/v1/repo/environment.go new file mode 100644 index 0000000000000..fcd59c90b624d --- /dev/null +++ b/routers/api/v1/repo/environment.go @@ -0,0 +1,319 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + unit_model "code.gitea.io/gitea/models/unit" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListEnvironments list environments for a repository +func ListEnvironments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/environments repository listEnvironments + // --- + // summary: List environments for a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/EnvironmentList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.CanRead(unit_model.TypeActions) { + ctx.APIError(http.StatusForbidden, "no permission to access actions") + return + } + + listOptions := utils.GetListOptions(ctx) + + opts := actions_model.FindEnvironmentsOptions{ + ListOptions: listOptions, + RepoID: ctx.Repo.Repository.ID, + } + + environments, err := actions_model.FindEnvironments(ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + count, err := actions_model.CountEnvironments(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiEnvironments := make([]*api.Environment, len(environments)) + for i, env := range environments { + apiEnv, err := convert.ToEnvironment(ctx, env) + if err != nil { + ctx.APIErrorInternal(err) + return + } + apiEnvironments[i] = apiEnv + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &api.EnvironmentListResponse{ + Environments: apiEnvironments, + TotalCount: count, + }) +} + +// GetEnvironment get a single environment +func GetEnvironment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/environments/{environment_name} repository getEnvironment + // --- + // summary: Get a specific environment + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: environment_name + // in: path + // description: name of the environment + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Environment" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.CanRead(unit_model.TypeActions) { + ctx.APIError(http.StatusForbidden, "no permission to access actions") + return + } + + environmentName := ctx.PathParam("environment_name") + env, err := actions_model.GetEnvironmentByRepoIDAndName(ctx, ctx.Repo.Repository.ID, environmentName) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + apiEnv, err := convert.ToEnvironment(ctx, env) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, apiEnv) +} + +// CreateOrUpdateEnvironment create or update an environment +func CreateOrUpdateEnvironment(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/environments/{environment_name} repository createOrUpdateEnvironment + // --- + // summary: Create or update a deployment environment + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: environment_name + // in: path + // description: name of the environment + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateEnvironmentOption" + // responses: + // "200": + // "$ref": "#/responses/Environment" + // "201": + // "$ref": "#/responses/Environment" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if !ctx.Repo.CanWrite(unit_model.TypeActions) { + ctx.APIError(http.StatusForbidden, "no permission to create environments") + return + } + + environmentName := ctx.PathParam("environment_name") + form := web.GetForm(ctx).(*api.CreateEnvironmentOption) + + // Override the name from the path parameter + form.Name = environmentName + + // Check if environment already exists + existingEnv, err := actions_model.GetEnvironmentByRepoIDAndName(ctx, ctx.Repo.Repository.ID, environmentName) + isUpdate := err == nil + + if isUpdate { + // Update existing environment + opts := actions_model.UpdateEnvironmentOptions{} + if form.Description != "" { + opts.Description = &form.Description + } + if form.ExternalURL != "" { + opts.ExternalURL = &form.ExternalURL + } + if form.ProtectionRules != "" { + opts.ProtectionRules = &form.ProtectionRules + } + + if err := actions_model.UpdateEnvironment(ctx, existingEnv, opts); err != nil { + ctx.APIErrorInternal(err) + return + } + + // Reload the environment to get updated data + env, err := actions_model.GetEnvironmentByRepoIDAndName(ctx, ctx.Repo.Repository.ID, environmentName) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiEnv, err := convert.ToEnvironment(ctx, env) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, apiEnv) + } else { + // Create new environment + createOpts := actions_model.CreateEnvironmentOptions{ + RepoID: ctx.Repo.Repository.ID, + Name: form.Name, + Description: form.Description, + ExternalURL: form.ExternalURL, + ProtectionRules: form.ProtectionRules, + CreatedByID: ctx.Doer.ID, + } + + env, err := actions_model.CreateEnvironment(ctx, createOpts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiEnv, err := convert.ToEnvironment(ctx, env) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, apiEnv) + } +} + +// DeleteEnvironment delete an environment +func DeleteEnvironment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/environments/{environment_name} repository deleteEnvironment + // --- + // summary: Delete a deployment environment + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: environment_name + // in: path + // description: name of the environment + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.CanWrite(unit_model.TypeActions) { + ctx.APIError(http.StatusForbidden, "no permission to delete environments") + return + } + + environmentName := ctx.PathParam("environment_name") + + // Check if environment exists + _, err := actions_model.GetEnvironmentByRepoIDAndName(ctx, ctx.Repo.Repository.ID, environmentName) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if err := actions_model.DeleteEnvironment(ctx, ctx.Repo.Repository.ID, environmentName); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 9e20c0533b9ce..74873392f9b9a 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -522,3 +522,17 @@ type swaggerMergeUpstreamResponse struct { // in:body Body api.MergeUpstreamResponse `json:"body"` } + +// Environment +// swagger:response Environment +type swaggerEnvironment struct { + // in:body + Body api.Environment `json:"body"` +} + +// EnvironmentList +// swagger:response EnvironmentList +type swaggerEnvironmentList struct { + // in:body + Body api.EnvironmentListResponse `json:"body"` +} diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 202da407d298b..4c4361765274f 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -87,6 +87,16 @@ func List(ctx *context.Context) { return } + // Load environments for the deployments sidebar + envs, err := actions_model.FindEnvironments(ctx, actions_model.FindEnvironmentsOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("FindEnvironments", err) + return + } + ctx.Data["Environments"] = envs + ctx.HTML(http.StatusOK, tplListActions) } @@ -255,6 +265,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { actorID := ctx.FormInt64("actor") status := ctx.FormInt("status") workflowID := ctx.FormString("workflow") + environmentName := ctx.FormString("environment") page := ctx.FormInt("page") if page <= 0 { page = 1 @@ -264,7 +275,8 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { // they will be 0 by default, which indicates get all status or actors ctx.Data["CurActor"] = actorID ctx.Data["CurStatus"] = status - if actorID > 0 || status > int(actions_model.StatusUnknown) { + ctx.Data["CurEnvironment"] = environmentName + if actorID > 0 || status > int(actions_model.StatusUnknown) || environmentName != "" { ctx.Data["IsFiltered"] = true } @@ -276,6 +288,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { RepoID: ctx.Repo.Repository.ID, WorkflowID: workflowID, TriggerUserID: actorID, + Environment: environmentName, } // if status is not StatusUnknown, it means user has selected a status filter @@ -302,6 +315,13 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { log.Error("LoadIsRefDeleted", err) } + // If viewing by environment, find the job index for each run that targets this environment + if environmentName != "" { + if err := loadEnvironmentJobIndexes(ctx, ctx.Repo.Repository.ID, environmentName, runs); err != nil { + log.Error("LoadEnvironmentJobIndexes", err) + } + } + ctx.Data["Runs"] = runs actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) @@ -349,6 +369,65 @@ func loadIsRefDeleted(ctx stdCtx.Context, repoID int64, runs actions_model.RunLi return nil } +// loadEnvironmentJobIndexes loads the job index for each run that targets the specified environment +func loadEnvironmentJobIndexes(ctx stdCtx.Context, repoID int64, environmentName string, runs actions_model.RunList) error { + if len(runs) == 0 { + return nil + } + + // Create a map of run IDs for quick lookup + runMap := make(map[int64]*actions_model.ActionRun) + for _, run := range runs { + runMap[run.ID] = run + } + + // Find deployments for these runs in the specified environment + env, err := actions_model.GetEnvironmentByRepoIDAndName(ctx, repoID, environmentName) + if err != nil { + return err // Environment not found, no job indexes to set + } + + deployments, err := actions_model.FindDeployments(ctx, actions_model.FindDeploymentsOptions{ + RepoID: repoID, + EnvironmentID: env.ID, + }) + if err != nil { + return err + } + + // Create a map of run ID to job name (from deployments) + runJobNames := make(map[int64]string) + for _, deployment := range deployments { + if _, exists := runMap[deployment.RunID]; exists { + runJobNames[deployment.RunID] = deployment.Task + } + } + + // For each run, find the job index that matches the deployment task name + for _, run := range runs { + jobName, exists := runJobNames[run.ID] + if !exists { + continue + } + + // Get the jobs for this run to find the index + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + continue // Skip this run if we can't get jobs + } + + // Find the job index by matching the job name + for i, job := range jobs { + if job.Name == jobName { + run.EnvironmentJobIndex = int64(i) + break + } + } + } + + return nil +} + type WorkflowDispatchInput struct { Name string `yaml:"name"` Description string `yaml:"description"` diff --git a/routers/web/repo/setting/environment.go b/routers/web/repo/setting/environment.go new file mode 100644 index 0000000000000..1fe431788aa54 --- /dev/null +++ b/routers/web/repo/setting/environment.go @@ -0,0 +1,93 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strconv" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// EnvironmentsPost handles the creation and deletion of environments +func EnvironmentsPost(ctx *context.Context) { + if !setting.Actions.Enabled { + ctx.NotFound(util.NewNotExistErrorf("Actions not enabled")) + return + } + + form := web.GetForm(ctx).(*forms.AddSecretForm) + + switch ctx.FormString("action") { + case "add": + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + envOpts := actions_model.CreateEnvironmentOptions{ + RepoID: ctx.Repo.Repository.ID, + Name: form.Name, + Description: form.Data, // Reusing the data field for description + CreatedByID: ctx.Doer.ID, + } + + if _, err := actions_model.CreateEnvironment(ctx, envOpts); err != nil { + ctx.JSONError(err.Error()) + return + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/actions/environments") + case "remove": + id, _ := strconv.ParseInt(ctx.FormString("id"), 10, 64) + env, err := actions_model.GetEnvironmentByRepoIDAndName(ctx, ctx.Repo.Repository.ID, ctx.FormString("name")) + if err != nil { + ctx.JSONError(err.Error()) + return + } + + if env.ID != id { + ctx.JSONError("Environment not found") + return + } + + if err := actions_model.DeleteEnvironment(ctx, ctx.Repo.Repository.ID, env.Name); err != nil { + ctx.JSONError(err.Error()) + return + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/actions/environments") + } +} + +// Environments displays the environment management page +func Environments(ctx *context.Context) { + if !setting.Actions.Enabled { + ctx.NotFound(util.NewNotExistErrorf("Actions not enabled")) + return + } + + ctx.Data["Title"] = ctx.Tr("actions.environments") + ctx.Data["PageIsRepoSettings"] = true + ctx.Data["PageIsRepoSettingsActions"] = true + ctx.Data["PageIsSharedSettingsEnvironments"] = true + + envs, err := actions_model.FindEnvironments(ctx, actions_model.FindEnvironmentsOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("FindEnvironments", err) + return + } + + ctx.Data["Environments"] = envs + ctx.Data["PageType"] = "environment" + + ctx.HTML(http.StatusOK, "repo/settings/actions") +} diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index f475e93f60489..6b1fc56b979fa 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -13,6 +13,7 @@ import ( "strings" "time" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" @@ -181,6 +182,25 @@ func prepareHomeSidebarLatestRelease(ctx *context.Context) { } } +func prepareHomeSidebarEnvironments(ctx *context.Context) { + if !setting.Actions.Enabled || !ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { + return + } + + environments, err := actions_model.FindEnvironments(ctx, actions_model.FindEnvironmentsOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("FindEnvironments", err) + return + } + + if len(environments) > 0 { + ctx.Data["Environments"] = environments + ctx.Data["NumEnvironments"] = len(environments) + } +} + func prepareUpstreamDivergingInfo(ctx *context.Context) { if !ctx.Repo.Repository.IsFork || !ctx.Repo.RefFullName.IsBranch() || ctx.Repo.TreePath != "" { return @@ -437,6 +457,7 @@ func Home(ctx *context.Context) { prepareHomeSidebarCitationFile(entry), prepareHomeSidebarLanguageStats, prepareHomeSidebarLatestRelease, + prepareHomeSidebarEnvironments, ) } diff --git a/routers/web/web.go b/routers/web/web.go index 4a274c171a3a0..51adb6ee0fc9a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -463,6 +463,13 @@ func registerWebRoutes(m *web.Router) { }) } + addSettingsEnvironmentsRoutes := func() { + m.Group("/environments", func() { + m.Get("", repo_setting.Environments) + m.Post("", web.Bind(forms.AddSecretForm{}), repo_setting.EnvironmentsPost) + }) + } + addSettingsRunnersRoutes := func() { m.Group("/runners", func() { m.Get("", shared_actions.Runners) @@ -666,6 +673,7 @@ func registerWebRoutes(m *web.Router) { addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() + addSettingsEnvironmentsRoutes() }, actions.MustEnableActions) m.Get("/organization", user_setting.Organization) @@ -967,6 +975,7 @@ func registerWebRoutes(m *web.Router) { addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() + addSettingsEnvironmentsRoutes() }, actions.MustEnableActions) m.Post("/rename", web.Bind(forms.RenameOrgForm{}), org.SettingsRenamePost) @@ -1160,6 +1169,7 @@ func registerWebRoutes(m *web.Router) { addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() + addSettingsEnvironmentsRoutes() }, actions.MustEnableActions) // the follow handler must be under "settings", otherwise this incomplete repo can't be accessed m.Group("/migrate", func() { diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index b8bc20cdbb1a7..8ef0f52f039ea 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -383,8 +383,8 @@ func handleWorkflows( } } - if err := actions_model.InsertRun(ctx, run, jobs); err != nil { - log.Error("InsertRun: %v", err) + if err := actions_model.InsertRunWithDeployments(ctx, run, jobs, dwf.Content); err != nil { + log.Error("InsertRunWithDeployments: %v", err) continue } diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index c029c5a1a2c8e..4de7637b3434b 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -146,7 +146,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) } // Insert the action run and its associated jobs into the database - if err := actions_model.InsertRun(ctx, run, workflows); err != nil { + if err := actions_model.InsertRunWithDeployments(ctx, run, workflows, cron.Content); err != nil { return err } allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 233e22b5ddfe2..44d605c6f891d 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -194,8 +194,8 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re } // Insert the action run and its associated jobs into the database - if err := actions_model.InsertRun(ctx, run, workflows); err != nil { - return fmt.Errorf("InsertRun: %w", err) + if err := actions_model.InsertRunWithDeployments(ctx, run, workflows, content); err != nil { + return fmt.Errorf("InsertRunWithDeployments: %w", err) } allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) diff --git a/services/convert/environment.go b/services/convert/environment.go new file mode 100644 index 0000000000000..36d9b6740b443 --- /dev/null +++ b/services/convert/environment.go @@ -0,0 +1,33 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + actions_model "code.gitea.io/gitea/models/actions" + api "code.gitea.io/gitea/modules/structs" +) + +// ToEnvironment converts ActionEnvironment to API format +func ToEnvironment(ctx context.Context, env *actions_model.ActionEnvironment) (*api.Environment, error) { + result := &api.Environment{ + ID: env.ID, + Name: env.Name, + Description: env.Description, + ExternalURL: env.ExternalURL, + ProtectionRules: env.ProtectionRules, + CreatedAt: env.CreatedUnix.AsTime(), + UpdatedAt: env.UpdatedUnix.AsTime(), + } + + // Load and convert the creator if available + if env.CreatedByID > 0 { + if err := env.LoadCreatedBy(ctx); err == nil && env.CreatedBy != nil { + result.CreatedBy = ToUser(ctx, env.CreatedBy, nil) + } + } + + return result, nil +} diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index 7d782c0adebdf..667da51d91a8c 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -8,7 +8,10 @@
+ {{if .Environments}} + + {{end}}
- + {{ctx.Locale.Tr "actions.runs.actors_no_select"}} {{range .Actors}} - + {{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}} {{end}} @@ -54,11 +69,11 @@ {{svg "octicon-search"}}
- + {{ctx.Locale.Tr "actions.runs.status_no_select"}} {{range .StatusInfoList}} - + {{.DisplayedStatus}} {{end}} @@ -69,7 +84,7 @@
{{end}} + + {{template "repo/home_sidebar_environments" .}} diff --git a/templates/repo/home_sidebar_environments.tmpl b/templates/repo/home_sidebar_environments.tmpl new file mode 100644 index 0000000000000..e1b6f9d146cb7 --- /dev/null +++ b/templates/repo/home_sidebar_environments.tmpl @@ -0,0 +1,35 @@ +{{if and .Environments (not .Repository.IsArchived)}} +
+
+ +
+
+ {{range .Environments}} +
+
+
{{svg "octicon-rocket" 16}}
+
+
+
+
+ {{.Name}} +
+
+ {{if .Description}} +
+ {{.Description}} +
+ {{end}} +
+
+ {{end}} +
+
+
+
+{{end}} diff --git a/templates/repo/settings/actions.tmpl b/templates/repo/settings/actions.tmpl index f38ab5b658412..1d87f9f934d21 100644 --- a/templates/repo/settings/actions.tmpl +++ b/templates/repo/settings/actions.tmpl @@ -6,6 +6,8 @@ {{template "shared/secrets/add_list" .}} {{else if eq .PageType "variables"}} {{template "shared/variables/variable_list" .}} + {{else if eq .PageType "environment"}} + {{template "shared/actions/environment_list" .}} {{end}} {{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 3dd86d1f6a3a4..3e1f9526bc57b 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -39,7 +39,7 @@ {{end}} {{end}} {{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}} -
+
{{ctx.Locale.Tr "actions.actions"}}
{{end}} diff --git a/templates/shared/actions/environment_delete_modal.tmpl b/templates/shared/actions/environment_delete_modal.tmpl new file mode 100644 index 0000000000000..817e5560324cc --- /dev/null +++ b/templates/shared/actions/environment_delete_modal.tmpl @@ -0,0 +1,11 @@ +{{/* Delete environment dialog */}} + diff --git a/templates/shared/actions/environment_list.tmpl b/templates/shared/actions/environment_list.tmpl new file mode 100644 index 0000000000000..e960371a09706 --- /dev/null +++ b/templates/shared/actions/environment_list.tmpl @@ -0,0 +1,56 @@ +

+ {{ctx.Locale.Tr "actions.environments"}} +
+ +
+

+
+ {{if .Environments}} +
+ {{range .Environments}} +
+
+
+ {{.Name}} +
+
+ {{if .Description}}{{.Description}}{{else}}-{{end}} +
+ {{if .ExternalURL}} + + {{end}} +
+
+ +
+
+ {{end}} +
+ {{else}} +
+
+ {{ctx.Locale.Tr "actions.no_environments"}} +
+ {{end}} +
+ +{{template "shared/actions/environment_modal" .}} +{{template "shared/actions/environment_delete_modal" .}} diff --git a/templates/shared/actions/environment_modal.tmpl b/templates/shared/actions/environment_modal.tmpl new file mode 100644 index 0000000000000..bdc19dc4fb081 --- /dev/null +++ b/templates/shared/actions/environment_modal.tmpl @@ -0,0 +1,32 @@ +{{/* Add environment dialog */}} + diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 749d86901de93..78b06af11372f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7910,6 +7910,204 @@ } } }, + "/repos/{owner}/{repo}/environments": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List environments for a repository", + "operationId": "listEnvironments", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/EnvironmentList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/environments/{environment_name}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a specific environment", + "operationId": "getEnvironment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the environment", + "name": "environment_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Environment" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create or update a deployment environment", + "operationId": "createOrUpdateEnvironment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the environment", + "name": "environment_name", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateEnvironmentOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Environment" + }, + "201": { + "$ref": "#/responses/Environment" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete a deployment environment", + "operationId": "deleteEnvironment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the environment", + "name": "environment_name", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/file-contents": { "get": { "description": "See the POST method. This GET method supports using JSON encoded request body in query parameter.", @@ -22706,6 +22904,35 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateEnvironmentOption": { + "description": "CreateEnvironmentOption options for creating an environment", + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "description": "Description of the environment", + "type": "string", + "x-go-name": "Description" + }, + "external_url": { + "description": "External URL associated with the environment", + "type": "string", + "x-go-name": "ExternalURL" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "protection_rules": { + "description": "Protection rules as JSON string", + "type": "string", + "x-go-name": "ProtectionRules" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateFileOptions": { "description": "CreateFileOptions options for creating files\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", "type": "object", @@ -24590,6 +24817,7 @@ "x-go-name": "Website" } }, + "x-go-name": "swaggerModelEditUserOption", "x-go-package": "code.gitea.io/gitea/modules/structs" }, "Email": { @@ -24622,6 +24850,73 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Environment": { + "description": "Environment represents a deployment environment", + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt" + }, + "created_by": { + "$ref": "#/definitions/User" + }, + "description": { + "description": "Description of the environment", + "type": "string", + "x-go-name": "Description" + }, + "external_url": { + "description": "External URL associated with the environment", + "type": "string", + "x-go-name": "ExternalURL" + }, + "id": { + "description": "ID of the environment", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "description": "Name of the environment", + "type": "string", + "x-go-name": "Name" + }, + "protection_rules": { + "description": "Protection rules as JSON string", + "type": "string", + "x-go-name": "ProtectionRules" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "UpdatedAt" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "EnvironmentListResponse": { + "description": "EnvironmentListResponse returns environments list", + "type": "object", + "properties": { + "environments": { + "description": "List of environments", + "type": "array", + "items": { + "$ref": "#/definitions/Environment" + }, + "x-go-name": "Environments" + }, + "total_count": { + "description": "Total number of environments", + "type": "integer", + "format": "int64", + "x-go-name": "TotalCount" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "ExternalTracker": { "description": "ExternalTracker represents settings for external tracker", "type": "object", @@ -28109,6 +28404,28 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateEnvironmentOption": { + "description": "UpdateEnvironmentOption options for updating an environment", + "type": "object", + "properties": { + "description": { + "description": "Description of the environment", + "type": "string", + "x-go-name": "Description" + }, + "external_url": { + "description": "External URL associated with the environment", + "type": "string", + "x-go-name": "ExternalURL" + }, + "protection_rules": { + "description": "Protection rules as JSON string", + "type": "string", + "x-go-name": "ProtectionRules" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "UpdateFileOptions": { "description": "UpdateFileOptions options for updating files\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", "type": "object", @@ -28334,6 +28651,28 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UserBadge": { + "description": "UserBadge represents a user badge", + "type": "object", + "properties": { + "badge_id": { + "type": "integer", + "format": "int64", + "x-go-name": "BadgeID" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "user_id": { + "type": "integer", + "format": "int64", + "x-go-name": "UserID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "UserBadgeOption": { "description": "UserBadgeOption options for link between users and badges", "type": "object", @@ -28888,6 +29227,18 @@ "$ref": "#/definitions/APIError" } }, + "Environment": { + "description": "Environment", + "schema": { + "$ref": "#/definitions/Environment" + } + }, + "EnvironmentList": { + "description": "EnvironmentList", + "schema": { + "$ref": "#/definitions/EnvironmentListResponse" + } + }, "FileDeleteResponse": { "description": "FileDeleteResponse", "schema": { diff --git a/web_src/css/actions.css b/web_src/css/actions.css index c43ebe21a0599..eb0754659c344 100644 --- a/web_src/css/actions.css +++ b/web_src/css/actions.css @@ -74,3 +74,12 @@ max-width: 110px; } } + +.environment-list .environment-item { + border-bottom: 1px solid var(--color-secondary); + padding: 8px 0; +} + +.environment-list .environment-item:last-child { + border-bottom: none; +} diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index 0326956222c96..c98c48903a606 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -27,7 +27,12 @@ export function initGlobalDeleteButton(): void { const dataObj = btn.dataset; const modalId = btn.getAttribute('data-modal-id'); - const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`); + const modal = modalId ? document.querySelector(`#${modalId}`) : document.querySelector('.delete.modal'); + + if (!modal) { + console.error(`Modal not found for modalId: ${modalId}`); + return; + } // set the modal "display name" by `data-name` const modalNameEl = modal.querySelector('.name'); @@ -57,6 +62,10 @@ export function initGlobalDeleteButton(): void { // prepare an AJAX form by data attributes const postData = new FormData(); + + // Add the action parameter to indicate this is a remove operation + postData.append('action', 'remove'); + for (const [key, value] of Object.entries(dataObj)) { if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form) postData.append(key.slice(4), value); @@ -64,12 +73,32 @@ export function initGlobalDeleteButton(): void { if (key === 'id') { // for data-id="..." postData.append('id', value); } + if (key === 'name') { // for data-name="..." + postData.append('name', value); + } } (async () => { - const response = await POST(btn.getAttribute('data-url'), {data: postData}); - if (response.ok) { - const data = await response.json(); - window.location.href = data.redirect; + try { + const response = await POST(btn.getAttribute('data-url'), {data: postData}); + if (response.ok) { + const text = await response.text(); + if (text) { + const data = JSON.parse(text); + if (data.redirect) { + window.location.href = data.redirect; + } else { + // Fallback: reload the page if no redirect URL + window.location.reload(); + } + } else { + // Empty response, reload the page + window.location.reload(); + } + } else { + console.error('Delete request failed:', response.status, response.statusText); + } + } catch (error) { + console.error('Error during delete operation:', error); } })(); modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal