From 7b456a28d18ae5a016341934184bf1e0a4dad986 Mon Sep 17 00:00:00 2001
From: Bo-Yi Wu <appleboy.tw@gmail.com>
Date: Fri, 26 Apr 2024 21:11:49 +0800
Subject: [PATCH] feat(api): enhance Actions Secrets Management API for
 repository (#30656)

- Add endpoint to list repository action secrets in API routes
- Implement `ListActionsSecrets` function to retrieve action secrets
from the database
- Update Swagger documentation to include the new
`/repos/{owner}/{repo}/actions/secrets` endpoint
- Add `actions` package import and define new routes for actions,
secrets, variables, and runners in `api.go`.
- Refactor action-related API functions into `Action` struct methods in
`org/action.go` and `repo/action.go`.
- Remove `actionAPI` struct and related functions, replacing them with
`NewAction()` calls.
- Rename `variables.go` to `action.go` in `org` directory.
- Delete `runners.go` and `secrets.go` in both `org` and `repo`
directories, consolidating their content into `action.go`.
- Update copyright year and add new imports in `org/action.go`.
- Implement `API` interface in `services/actions/interface.go` for
action-related methods.
- Remove individual action-related functions and replace them with
methods on the `Action` struct in `repo/action.go`.

---------

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Signed-off-by: appleboy <appleboy.tw@gmail.com>
(cherry picked from commit 852547d0dc70299589c7bf8d00ea462ed709b8e5)

Conflicts:
	routers/api/v1/api.go
	trivial conflict because of Fix #2512 /api/forgejo/v1/version auth check (#2582)
---
 routers/api/v1/api.go                         |  80 ++++----
 .../api/v1/org/{variables.go => action.go}    | 192 +++++++++++++++++-
 routers/api/v1/org/runners.go                 |  31 ---
 routers/api/v1/org/secrets.go                 | 166 ---------------
 routers/api/v1/repo/action.go                 | 108 +++++++++-
 routers/api/v1/repo/runners.go                |  34 ----
 services/actions/interface.go                 |  28 +++
 templates/swagger/v1_json.tmpl                |  48 +++++
 tests/integration/api_repo_secrets_test.go    |   8 +-
 9 files changed, 410 insertions(+), 285 deletions(-)
 rename routers/api/v1/org/{variables.go => action.go} (58%)
 delete mode 100644 routers/api/v1/org/runners.go
 delete mode 100644 routers/api/v1/org/secrets.go
 delete mode 100644 routers/api/v1/repo/runners.go
 create mode 100644 services/actions/interface.go

diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index a15210ac51..e4c848cd2f 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -93,6 +93,7 @@ import (
 	"code.gitea.io/gitea/routers/api/v1/repo"
 	"code.gitea.io/gitea/routers/api/v1/settings"
 	"code.gitea.io/gitea/routers/api/v1/user"
+	"code.gitea.io/gitea/services/actions"
 	"code.gitea.io/gitea/services/auth"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/forms"
@@ -753,6 +754,34 @@ func Routes() *web.Route {
 
 	m.Use(shared.Middlewares()...)
 
+	addActionsRoutes := func(
+		m *web.Route,
+		reqChecker func(ctx *context.APIContext),
+		act actions.API,
+	) {
+		m.Group("/actions", func() {
+			m.Group("/secrets", func() {
+				m.Get("", reqToken(), reqChecker, act.ListActionsSecrets)
+				m.Combo("/{secretname}").
+					Put(reqToken(), reqChecker, bind(api.CreateOrUpdateSecretOption{}), act.CreateOrUpdateSecret).
+					Delete(reqToken(), reqChecker, act.DeleteSecret)
+			})
+
+			m.Group("/variables", func() {
+				m.Get("", reqToken(), reqChecker, act.ListVariables)
+				m.Combo("/{variablename}").
+					Get(reqToken(), reqChecker, act.GetVariable).
+					Delete(reqToken(), reqChecker, act.DeleteVariable).
+					Post(reqToken(), reqChecker, bind(api.CreateVariableOption{}), act.CreateVariable).
+					Put(reqToken(), reqChecker, bind(api.UpdateVariableOption{}), act.UpdateVariable)
+			})
+
+			m.Group("/runners", func() {
+				m.Get("/registration-token", reqToken(), reqChecker, act.GetRegistrationToken)
+			})
+		})
+	}
+
 	m.Group("", func() {
 		// Miscellaneous (no scope required)
 		if setting.API.EnableSwagger {
@@ -994,26 +1023,11 @@ func Routes() *web.Route {
 					m.Post("/accept", repo.AcceptTransfer)
 					m.Post("/reject", repo.RejectTransfer)
 				}, reqToken())
-				m.Group("/actions", func() {
-					m.Group("/secrets", func() {
-						m.Combo("/{secretname}").
-							Put(reqToken(), reqOwner(), bind(api.CreateOrUpdateSecretOption{}), repo.CreateOrUpdateSecret).
-							Delete(reqToken(), reqOwner(), repo.DeleteSecret)
-					})
-
-					m.Group("/variables", func() {
-						m.Get("", reqToken(), reqOwner(), repo.ListVariables)
-						m.Combo("/{variablename}").
-							Get(reqToken(), reqOwner(), repo.GetVariable).
-							Delete(reqToken(), reqOwner(), repo.DeleteVariable).
-							Post(reqToken(), reqOwner(), bind(api.CreateVariableOption{}), repo.CreateVariable).
-							Put(reqToken(), reqOwner(), bind(api.UpdateVariableOption{}), repo.UpdateVariable)
-					})
-
-					m.Group("/runners", func() {
-						m.Get("/registration-token", reqToken(), reqOwner(), repo.GetRegistrationToken)
-					})
-				})
+				addActionsRoutes(
+					m,
+					reqOwner(),
+					repo.NewAction(),
+				)
 				m.Group("/hooks/git", func() {
 					m.Combo("").Get(repo.ListGitHooks)
 					m.Group("/{id}", func() {
@@ -1405,27 +1419,11 @@ func Routes() *web.Route {
 				m.Combo("/{username}").Get(reqToken(), org.IsMember).
 					Delete(reqToken(), reqOrgOwnership(), org.DeleteMember)
 			})
-			m.Group("/actions", func() {
-				m.Group("/secrets", func() {
-					m.Get("", reqToken(), reqOrgOwnership(), org.ListActionsSecrets)
-					m.Combo("/{secretname}").
-						Put(reqToken(), reqOrgOwnership(), bind(api.CreateOrUpdateSecretOption{}), org.CreateOrUpdateSecret).
-						Delete(reqToken(), reqOrgOwnership(), org.DeleteSecret)
-				})
-
-				m.Group("/variables", func() {
-					m.Get("", reqToken(), reqOrgOwnership(), org.ListVariables)
-					m.Combo("/{variablename}").
-						Get(reqToken(), reqOrgOwnership(), org.GetVariable).
-						Delete(reqToken(), reqOrgOwnership(), org.DeleteVariable).
-						Post(reqToken(), reqOrgOwnership(), bind(api.CreateVariableOption{}), org.CreateVariable).
-						Put(reqToken(), reqOrgOwnership(), bind(api.UpdateVariableOption{}), org.UpdateVariable)
-				})
-
-				m.Group("/runners", func() {
-					m.Get("/registration-token", reqToken(), reqOrgOwnership(), org.GetRegistrationToken)
-				})
-			})
+			addActionsRoutes(
+				m,
+				reqOrgOwnership(),
+				org.NewAction(),
+			)
 			m.Group("/public_members", func() {
 				m.Get("", org.ListPublicMembers)
 				m.Combo("/{username}").Get(org.IsPublicMember).
diff --git a/routers/api/v1/org/variables.go b/routers/api/v1/org/action.go
similarity index 58%
rename from routers/api/v1/org/variables.go
rename to routers/api/v1/org/action.go
index eaf7bdc45b..03a1fa8ccc 100644
--- a/routers/api/v1/org/variables.go
+++ b/routers/api/v1/org/action.go
@@ -9,16 +9,188 @@ import (
 
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
+	secret_model "code.gitea.io/gitea/models/secret"
 	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/shared"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	actions_service "code.gitea.io/gitea/services/actions"
 	"code.gitea.io/gitea/services/context"
+	secret_service "code.gitea.io/gitea/services/secrets"
 )
 
+// ListActionsSecrets list an organization's actions secrets
+func (Action) ListActionsSecrets(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/actions/secrets organization orgListActionsSecrets
+	// ---
+	// summary: List an organization's actions secrets
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the organization
+	//   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/SecretList"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	opts := &secret_model.FindSecretsOptions{
+		OwnerID:     ctx.Org.Organization.ID,
+		ListOptions: utils.GetListOptions(ctx),
+	}
+
+	secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	apiSecrets := make([]*api.Secret, len(secrets))
+	for k, v := range secrets {
+		apiSecrets[k] = &api.Secret{
+			Name:    v.Name,
+			Created: v.CreatedUnix.AsTime(),
+		}
+	}
+
+	ctx.SetTotalCountHeader(count)
+	ctx.JSON(http.StatusOK, apiSecrets)
+}
+
+// create or update one secret of the organization
+func (Action) CreateOrUpdateSecret(ctx *context.APIContext) {
+	// swagger:operation PUT /orgs/{org}/actions/secrets/{secretname} organization updateOrgSecret
+	// ---
+	// summary: Create or Update a secret value in an organization
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of organization
+	//   type: string
+	//   required: true
+	// - name: secretname
+	//   in: path
+	//   description: name of the secret
+	//   type: string
+	//   required: true
+	// - name: body
+	//   in: body
+	//   schema:
+	//     "$ref": "#/definitions/CreateOrUpdateSecretOption"
+	// responses:
+	//   "201":
+	//     description: response when creating a secret
+	//   "204":
+	//     description: response when updating a secret
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
+
+	_, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname"), opt.Data)
+	if err != nil {
+		if errors.Is(err, util.ErrInvalidArgument) {
+			ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
+		} else if errors.Is(err, util.ErrNotExist) {
+			ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
+		}
+		return
+	}
+
+	if created {
+		ctx.Status(http.StatusCreated)
+	} else {
+		ctx.Status(http.StatusNoContent)
+	}
+}
+
+// DeleteSecret delete one secret of the organization
+func (Action) DeleteSecret(ctx *context.APIContext) {
+	// swagger:operation DELETE /orgs/{org}/actions/secrets/{secretname} organization deleteOrgSecret
+	// ---
+	// summary: Delete a secret in an organization
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of organization
+	//   type: string
+	//   required: true
+	// - name: secretname
+	//   in: path
+	//   description: name of the secret
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     description: delete one secret of the organization
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname"))
+	if err != nil {
+		if errors.Is(err, util.ErrInvalidArgument) {
+			ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
+		} else if errors.Is(err, util.ErrNotExist) {
+			ctx.Error(http.StatusNotFound, "DeleteSecret", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
+		}
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization
+// GetRegistrationToken returns the token to register org runners
+func (Action) GetRegistrationToken(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/actions/runners/registration-token organization orgGetRunnerRegistrationToken
+	// ---
+	// summary: Get an organization's actions runner registration token
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the organization
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/RegistrationToken"
+
+	shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0)
+}
+
 // ListVariables list org-level variables
-func ListVariables(ctx *context.APIContext) {
+func (Action) ListVariables(ctx *context.APIContext) {
 	// swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList
 	// ---
 	// summary: Get an org-level variables list
@@ -70,7 +242,7 @@ func ListVariables(ctx *context.APIContext) {
 }
 
 // GetVariable get an org-level variable
-func GetVariable(ctx *context.APIContext) {
+func (Action) GetVariable(ctx *context.APIContext) {
 	// swagger:operation GET /orgs/{org}/actions/variables/{variablename} organization getOrgVariable
 	// ---
 	// summary: Get an org-level variable
@@ -119,7 +291,7 @@ func GetVariable(ctx *context.APIContext) {
 }
 
 // DeleteVariable delete an org-level variable
-func DeleteVariable(ctx *context.APIContext) {
+func (Action) DeleteVariable(ctx *context.APIContext) {
 	// swagger:operation DELETE /orgs/{org}/actions/variables/{variablename} organization deleteOrgVariable
 	// ---
 	// summary: Delete an org-level variable
@@ -163,7 +335,7 @@ func DeleteVariable(ctx *context.APIContext) {
 }
 
 // CreateVariable create an org-level variable
-func CreateVariable(ctx *context.APIContext) {
+func (Action) CreateVariable(ctx *context.APIContext) {
 	// swagger:operation POST /orgs/{org}/actions/variables/{variablename} organization createOrgVariable
 	// ---
 	// summary: Create an org-level variable
@@ -227,7 +399,7 @@ func CreateVariable(ctx *context.APIContext) {
 }
 
 // UpdateVariable update an org-level variable
-func UpdateVariable(ctx *context.APIContext) {
+func (Action) UpdateVariable(ctx *context.APIContext) {
 	// swagger:operation PUT /orgs/{org}/actions/variables/{variablename} organization updateOrgVariable
 	// ---
 	// summary: Update an org-level variable
@@ -289,3 +461,13 @@ func UpdateVariable(ctx *context.APIContext) {
 
 	ctx.Status(http.StatusNoContent)
 }
+
+var _ actions_service.API = new(Action)
+
+// Action implements actions_service.API
+type Action struct{}
+
+// NewAction creates a new Action service
+func NewAction() actions_service.API {
+	return Action{}
+}
diff --git a/routers/api/v1/org/runners.go b/routers/api/v1/org/runners.go
deleted file mode 100644
index 2a52bd8778..0000000000
--- a/routers/api/v1/org/runners.go
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright 2023 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package org
-
-import (
-	"code.gitea.io/gitea/routers/api/v1/shared"
-	"code.gitea.io/gitea/services/context"
-)
-
-// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization
-
-// GetRegistrationToken returns the token to register org runners
-func GetRegistrationToken(ctx *context.APIContext) {
-	// swagger:operation GET /orgs/{org}/actions/runners/registration-token organization orgGetRunnerRegistrationToken
-	// ---
-	// summary: Get an organization's actions runner registration token
-	// produces:
-	// - application/json
-	// parameters:
-	// - name: org
-	//   in: path
-	//   description: name of the organization
-	//   type: string
-	//   required: true
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/RegistrationToken"
-
-	shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0)
-}
diff --git a/routers/api/v1/org/secrets.go b/routers/api/v1/org/secrets.go
deleted file mode 100644
index abb6bb26c4..0000000000
--- a/routers/api/v1/org/secrets.go
+++ /dev/null
@@ -1,166 +0,0 @@
-// Copyright 2023 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package org
-
-import (
-	"errors"
-	"net/http"
-
-	"code.gitea.io/gitea/models/db"
-	secret_model "code.gitea.io/gitea/models/secret"
-	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"
-	secret_service "code.gitea.io/gitea/services/secrets"
-)
-
-// ListActionsSecrets list an organization's actions secrets
-func ListActionsSecrets(ctx *context.APIContext) {
-	// swagger:operation GET /orgs/{org}/actions/secrets organization orgListActionsSecrets
-	// ---
-	// summary: List an organization's actions secrets
-	// produces:
-	// - application/json
-	// parameters:
-	// - name: org
-	//   in: path
-	//   description: name of the organization
-	//   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/SecretList"
-	//   "404":
-	//     "$ref": "#/responses/notFound"
-
-	opts := &secret_model.FindSecretsOptions{
-		OwnerID:     ctx.Org.Organization.ID,
-		ListOptions: utils.GetListOptions(ctx),
-	}
-
-	secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts)
-	if err != nil {
-		ctx.InternalServerError(err)
-		return
-	}
-
-	apiSecrets := make([]*api.Secret, len(secrets))
-	for k, v := range secrets {
-		apiSecrets[k] = &api.Secret{
-			Name:    v.Name,
-			Created: v.CreatedUnix.AsTime(),
-		}
-	}
-
-	ctx.SetTotalCountHeader(count)
-	ctx.JSON(http.StatusOK, apiSecrets)
-}
-
-// create or update one secret of the organization
-func CreateOrUpdateSecret(ctx *context.APIContext) {
-	// swagger:operation PUT /orgs/{org}/actions/secrets/{secretname} organization updateOrgSecret
-	// ---
-	// summary: Create or Update a secret value in an organization
-	// consumes:
-	// - application/json
-	// produces:
-	// - application/json
-	// parameters:
-	// - name: org
-	//   in: path
-	//   description: name of organization
-	//   type: string
-	//   required: true
-	// - name: secretname
-	//   in: path
-	//   description: name of the secret
-	//   type: string
-	//   required: true
-	// - name: body
-	//   in: body
-	//   schema:
-	//     "$ref": "#/definitions/CreateOrUpdateSecretOption"
-	// responses:
-	//   "201":
-	//     description: response when creating a secret
-	//   "204":
-	//     description: response when updating a secret
-	//   "400":
-	//     "$ref": "#/responses/error"
-	//   "404":
-	//     "$ref": "#/responses/notFound"
-
-	opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
-
-	_, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname"), opt.Data)
-	if err != nil {
-		if errors.Is(err, util.ErrInvalidArgument) {
-			ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
-		} else if errors.Is(err, util.ErrNotExist) {
-			ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err)
-		} else {
-			ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
-		}
-		return
-	}
-
-	if created {
-		ctx.Status(http.StatusCreated)
-	} else {
-		ctx.Status(http.StatusNoContent)
-	}
-}
-
-// DeleteSecret delete one secret of the organization
-func DeleteSecret(ctx *context.APIContext) {
-	// swagger:operation DELETE /orgs/{org}/actions/secrets/{secretname} organization deleteOrgSecret
-	// ---
-	// summary: Delete a secret in an organization
-	// consumes:
-	// - application/json
-	// produces:
-	// - application/json
-	// parameters:
-	// - name: org
-	//   in: path
-	//   description: name of organization
-	//   type: string
-	//   required: true
-	// - name: secretname
-	//   in: path
-	//   description: name of the secret
-	//   type: string
-	//   required: true
-	// responses:
-	//   "204":
-	//     description: delete one secret of the organization
-	//   "400":
-	//     "$ref": "#/responses/error"
-	//   "404":
-	//     "$ref": "#/responses/notFound"
-
-	err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname"))
-	if err != nil {
-		if errors.Is(err, util.ErrInvalidArgument) {
-			ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
-		} else if errors.Is(err, util.ErrNotExist) {
-			ctx.Error(http.StatusNotFound, "DeleteSecret", err)
-		} else {
-			ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
-		}
-		return
-	}
-
-	ctx.Status(http.StatusNoContent)
-}
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index 03321d956d..311cfca6e9 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -9,17 +9,76 @@ import (
 
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
+	secret_model "code.gitea.io/gitea/models/secret"
 	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/shared"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	actions_service "code.gitea.io/gitea/services/actions"
 	"code.gitea.io/gitea/services/context"
 	secret_service "code.gitea.io/gitea/services/secrets"
 )
 
+// ListActionsSecrets list an repo's actions secrets
+func (Action) ListActionsSecrets(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/secrets repository repoListActionsSecrets
+	// ---
+	// summary: List an repo's actions secrets
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repository
+	//   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/SecretList"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	repo := ctx.Repo.Repository
+
+	opts := &secret_model.FindSecretsOptions{
+		RepoID:      repo.ID,
+		ListOptions: utils.GetListOptions(ctx),
+	}
+
+	secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	apiSecrets := make([]*api.Secret, len(secrets))
+	for k, v := range secrets {
+		apiSecrets[k] = &api.Secret{
+			Name:    v.Name,
+			Created: v.CreatedUnix.AsTime(),
+		}
+	}
+
+	ctx.SetTotalCountHeader(count)
+	ctx.JSON(http.StatusOK, apiSecrets)
+}
+
 // create or update one secret of the repository
-func CreateOrUpdateSecret(ctx *context.APIContext) {
+func (Action) CreateOrUpdateSecret(ctx *context.APIContext) {
 	// swagger:operation PUT /repos/{owner}/{repo}/actions/secrets/{secretname} repository updateRepoSecret
 	// ---
 	// summary: Create or Update a secret value in a repository
@@ -82,7 +141,7 @@ func CreateOrUpdateSecret(ctx *context.APIContext) {
 }
 
 // DeleteSecret delete one secret of the repository
-func DeleteSecret(ctx *context.APIContext) {
+func (Action) DeleteSecret(ctx *context.APIContext) {
 	// swagger:operation DELETE /repos/{owner}/{repo}/actions/secrets/{secretname} repository deleteRepoSecret
 	// ---
 	// summary: Delete a secret in a repository
@@ -133,7 +192,7 @@ func DeleteSecret(ctx *context.APIContext) {
 }
 
 // GetVariable get a repo-level variable
-func GetVariable(ctx *context.APIContext) {
+func (Action) GetVariable(ctx *context.APIContext) {
 	// swagger:operation GET /repos/{owner}/{repo}/actions/variables/{variablename} repository getRepoVariable
 	// ---
 	// summary: Get a repo-level variable
@@ -186,7 +245,7 @@ func GetVariable(ctx *context.APIContext) {
 }
 
 // DeleteVariable delete a repo-level variable
-func DeleteVariable(ctx *context.APIContext) {
+func (Action) DeleteVariable(ctx *context.APIContext) {
 	// swagger:operation DELETE /repos/{owner}/{repo}/actions/variables/{variablename} repository deleteRepoVariable
 	// ---
 	// summary: Delete a repo-level variable
@@ -235,7 +294,7 @@ func DeleteVariable(ctx *context.APIContext) {
 }
 
 // CreateVariable create a repo-level variable
-func CreateVariable(ctx *context.APIContext) {
+func (Action) CreateVariable(ctx *context.APIContext) {
 	// swagger:operation POST /repos/{owner}/{repo}/actions/variables/{variablename} repository createRepoVariable
 	// ---
 	// summary: Create a repo-level variable
@@ -302,7 +361,7 @@ func CreateVariable(ctx *context.APIContext) {
 }
 
 // UpdateVariable update a repo-level variable
-func UpdateVariable(ctx *context.APIContext) {
+func (Action) UpdateVariable(ctx *context.APIContext) {
 	// swagger:operation PUT /repos/{owner}/{repo}/actions/variables/{variablename} repository updateRepoVariable
 	// ---
 	// summary: Update a repo-level variable
@@ -369,7 +428,7 @@ func UpdateVariable(ctx *context.APIContext) {
 }
 
 // ListVariables list repo-level variables
-func ListVariables(ctx *context.APIContext) {
+func (Action) ListVariables(ctx *context.APIContext) {
 	// swagger:operation GET /repos/{owner}/{repo}/actions/variables repository getRepoVariablesList
 	// ---
 	// summary: Get repo-level variables list
@@ -423,3 +482,38 @@ func ListVariables(ctx *context.APIContext) {
 	ctx.SetTotalCountHeader(count)
 	ctx.JSON(http.StatusOK, variables)
 }
+
+// GetRegistrationToken returns the token to register repo runners
+func (Action) GetRegistrationToken(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/runners/registration-token repository repoGetRunnerRegistrationToken
+	// ---
+	// summary: Get a repository's actions runner registration token
+	// 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 repo
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/RegistrationToken"
+
+	shared.GetRegistrationToken(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
+}
+
+var _ actions_service.API = new(Action)
+
+// Action implements actions_service.API
+type Action struct{}
+
+// NewAction creates a new Action service
+func NewAction() actions_service.API {
+	return Action{}
+}
diff --git a/routers/api/v1/repo/runners.go b/routers/api/v1/repo/runners.go
deleted file mode 100644
index fe133b311d..0000000000
--- a/routers/api/v1/repo/runners.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2023 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package repo
-
-import (
-	"code.gitea.io/gitea/routers/api/v1/shared"
-	"code.gitea.io/gitea/services/context"
-)
-
-// GetRegistrationToken returns the token to register repo runners
-func GetRegistrationToken(ctx *context.APIContext) {
-	// swagger:operation GET /repos/{owner}/{repo}/runners/registration-token repository repoGetRunnerRegistrationToken
-	// ---
-	// summary: Get a repository's actions runner registration token
-	// 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 repo
-	//   type: string
-	//   required: true
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/RegistrationToken"
-
-	shared.GetRegistrationToken(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
-}
diff --git a/services/actions/interface.go b/services/actions/interface.go
new file mode 100644
index 0000000000..d4fa782fec
--- /dev/null
+++ b/services/actions/interface.go
@@ -0,0 +1,28 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import "code.gitea.io/gitea/services/context"
+
+// API for actions of a repository or organization
+type API interface {
+	// ListActionsSecrets list secrets
+	ListActionsSecrets(*context.APIContext)
+	// CreateOrUpdateSecret create or update a secret
+	CreateOrUpdateSecret(*context.APIContext)
+	// DeleteSecret delete a secret
+	DeleteSecret(*context.APIContext)
+	// ListVariables list variables
+	ListVariables(*context.APIContext)
+	// GetVariable get a variable
+	GetVariable(*context.APIContext)
+	// DeleteVariable delete a variable
+	DeleteVariable(*context.APIContext)
+	// CreateVariable create a variable
+	CreateVariable(*context.APIContext)
+	// UpdateVariable update a variable
+	UpdateVariable(*context.APIContext)
+	// GetRegistrationToken get registration token
+	GetRegistrationToken(*context.APIContext)
+}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 2473e96006..dbf9eb89e2 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -3711,6 +3711,54 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/actions/secrets": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "List an repo's actions secrets",
+        "operationId": "repoListActionsSecrets",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repository",
+            "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/SecretList"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/actions/secrets/{secretname}": {
       "put": {
         "consumes": [
diff --git a/tests/integration/api_repo_secrets_test.go b/tests/integration/api_repo_secrets_test.go
index feb9bae2b2..c3074d9ece 100644
--- a/tests/integration/api_repo_secrets_test.go
+++ b/tests/integration/api_repo_secrets_test.go
@@ -24,6 +24,12 @@ func TestAPIRepoSecrets(t *testing.T) {
 	session := loginUser(t, user.Name)
 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
 
+	t.Run("List", func(t *testing.T) {
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/secrets", repo.FullName())).
+			AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusOK)
+	})
+
 	t.Run("Create", func(t *testing.T) {
 		cases := []struct {
 			Name           string
@@ -31,7 +37,7 @@ func TestAPIRepoSecrets(t *testing.T) {
 		}{
 			{
 				Name:           "",
-				ExpectedStatus: http.StatusNotFound,
+				ExpectedStatus: http.StatusMethodNotAllowed,
 			},
 			{
 				Name:           "-",