diff --git a/models/actions/runner.go b/models/actions/runner.go
index 0411a48393..b55723efa0 100644
--- a/models/actions/runner.go
+++ b/models/actions/runner.go
@@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/models/shared/types"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
@@ -123,8 +124,15 @@ func (r *ActionRunner) IsOnline() bool {
return false
}
-// Editable checks if the runner is editable by the user
-func (r *ActionRunner) Editable(ownerID, repoID int64) bool {
+// EditableInContext checks if the runner is editable by the "context" owner/repo
+// ownerID == 0 and repoID == 0 means "admin" context, any runner including global runners could be edited
+// ownerID == 0 and repoID != 0 means "repo" context, any runner belonging to the given repo could be edited
+// ownerID != 0 and repoID == 0 means "owner(org/user)" context, any runner belonging to the given user/org could be edited
+// ownerID != 0 and repoID != 0 means "owner" OR "repo" context, legacy behavior, but we should forbid using it
+func (r *ActionRunner) EditableInContext(ownerID, repoID int64) bool {
+ if ownerID != 0 && repoID != 0 {
+ setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+ }
if ownerID == 0 && repoID == 0 {
return true
}
@@ -168,6 +176,12 @@ func init() {
db.RegisterModel(&ActionRunner{})
}
+// FindRunnerOptions
+// ownerID == 0 and repoID == 0 means any runner including global runners
+// repoID != 0 and WithAvailable == false means any runner for the given repo
+// repoID != 0 and WithAvailable == true means any runner for the given repo, parent user/org, and global runners
+// ownerID != 0 and repoID == 0 and WithAvailable == false means any runner for the given user/org
+// ownerID != 0 and repoID == 0 and WithAvailable == true means any runner for the given user/org and global runners
type FindRunnerOptions struct {
db.ListOptions
IDs []int64
diff --git a/models/fixtures/action_runner.yml b/models/fixtures/action_runner.yml
new file mode 100644
index 0000000000..dce2d41cfb
--- /dev/null
+++ b/models/fixtures/action_runner.yml
@@ -0,0 +1,40 @@
+-
+ id: 34346
+ name: runner_to_be_deleted-user
+ uuid: 3EF231BD-FBB7-4E4B-9602-E6F28363EF18
+ token_hash: 3EF231BD-FBB7-4E4B-9602-E6F28363EF18
+ version: "1.0.0"
+ owner_id: 1
+ repo_id: 0
+ description: "This runner is going to be deleted"
+ agent_labels: '["runner_to_be_deleted","linux"]'
+-
+ id: 34347
+ name: runner_to_be_deleted-org
+ uuid: 3EF231BD-FBB7-4E4B-9602-E6F28363EF19
+ token_hash: 3EF231BD-FBB7-4E4B-9602-E6F28363EF19
+ version: "1.0.0"
+ owner_id: 3
+ repo_id: 0
+ description: "This runner is going to be deleted"
+ agent_labels: '["runner_to_be_deleted","linux"]'
+-
+ id: 34348
+ name: runner_to_be_deleted-repo1
+ uuid: 3EF231BD-FBB7-4E4B-9602-E6F28363EF20
+ token_hash: 3EF231BD-FBB7-4E4B-9602-E6F28363EF20
+ version: "1.0.0"
+ owner_id: 0
+ repo_id: 1
+ description: "This runner is going to be deleted"
+ agent_labels: '["runner_to_be_deleted","linux"]'
+-
+ id: 34349
+ name: runner_to_be_deleted
+ uuid: 3EF231BD-FBB7-4E4B-9602-E6F28363EF17
+ token_hash: 3EF231BD-FBB7-4E4B-9602-E6F28363EF17
+ version: "1.0.0"
+ owner_id: 0
+ repo_id: 0
+ description: "This runner is going to be deleted"
+ agent_labels: '["runner_to_be_deleted","linux"]'
diff --git a/models/packages/package_version.go b/models/packages/package_version.go
index 278e8e3a86..bb7fd895f8 100644
--- a/models/packages/package_version.go
+++ b/models/packages/package_version.go
@@ -279,9 +279,7 @@ func (opts *PackageSearchOptions) configureOrderBy(e db.Engine) {
default:
e.Desc("package_version.created_unix")
}
-
- // Sort by id for stable order with duplicates in the other field
- e.Asc("package_version.id")
+ e.Desc("package_version.id") // Sort by id for stable order with duplicates in the other field
}
// SearchVersions gets all versions of packages matching the search options
diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index 22409b4aff..75f8e188dd 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -133,3 +133,26 @@ type ActionWorkflowJob struct {
// swagger:strfmt date-time
CompletedAt time.Time `json:"completed_at,omitempty"`
}
+
+// ActionRunnerLabel represents a Runner Label
+type ActionRunnerLabel struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+}
+
+// ActionRunner represents a Runner
+type ActionRunner struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Busy bool `json:"busy"`
+ Ephemeral bool `json:"ephemeral"`
+ Labels []*ActionRunnerLabel `json:"labels"`
+}
+
+// ActionRunnersResponse returns Runners
+type ActionRunnersResponse struct {
+ Entries []*ActionRunner `json:"runners"`
+ TotalCount int64 `json:"total_count"`
+}
diff --git a/routers/api/packages/swift/swift.go b/routers/api/packages/swift/swift.go
index 4d7fb8b1a6..47439c4c3b 100644
--- a/routers/api/packages/swift/swift.go
+++ b/routers/api/packages/swift/swift.go
@@ -290,7 +290,24 @@ func DownloadManifest(ctx *context.Context) {
})
}
-// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-6
+// formFileOptionalReadCloser returns (nil, nil) if the formKey is not present.
+func formFileOptionalReadCloser(ctx *context.Context, formKey string) (io.ReadCloser, error) {
+ multipartFile, _, err := ctx.Req.FormFile(formKey)
+ if err != nil && !errors.Is(err, http.ErrMissingFile) {
+ return nil, err
+ }
+ if multipartFile != nil {
+ return multipartFile, nil
+ }
+
+ content := ctx.Req.FormValue(formKey)
+ if content == "" {
+ return nil, nil
+ }
+ return io.NopCloser(strings.NewReader(content)), nil
+}
+
+// UploadPackageFile refers to https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-6
func UploadPackageFile(ctx *context.Context) {
packageScope := ctx.PathParam("scope")
packageName := ctx.PathParam("name")
@@ -304,9 +321,9 @@ func UploadPackageFile(ctx *context.Context) {
packageVersion := v.Core().String()
- file, _, err := ctx.Req.FormFile("source-archive")
- if err != nil {
- apiError(ctx, http.StatusBadRequest, err)
+ file, err := formFileOptionalReadCloser(ctx, "source-archive")
+ if file == nil || err != nil {
+ apiError(ctx, http.StatusBadRequest, "unable to read source-archive file")
return
}
defer file.Close()
@@ -318,10 +335,13 @@ func UploadPackageFile(ctx *context.Context) {
}
defer buf.Close()
- var mr io.Reader
- metadata := ctx.Req.FormValue("metadata")
- if metadata != "" {
- mr = strings.NewReader(metadata)
+ mr, err := formFileOptionalReadCloser(ctx, "metadata")
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, "unable to read metadata file")
+ return
+ }
+ if mr != nil {
+ defer mr.Close()
}
pck, err := swift_module.ParsePackage(buf, buf.Size(), mr)
diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go
index 329242d9f6..736c421229 100644
--- a/routers/api/v1/admin/runners.go
+++ b/routers/api/v1/admin/runners.go
@@ -24,3 +24,81 @@ func GetRegistrationToken(ctx *context.APIContext) {
shared.GetRegistrationToken(ctx, 0, 0)
}
+
+// CreateRegistrationToken returns the token to register global runners
+func CreateRegistrationToken(ctx *context.APIContext) {
+ // swagger:operation POST /admin/actions/runners/registration-token admin adminCreateRunnerRegistrationToken
+ // ---
+ // summary: Get an global actions runner registration token
+ // produces:
+ // - application/json
+ // parameters:
+ // responses:
+ // "200":
+ // "$ref": "#/responses/RegistrationToken"
+
+ shared.GetRegistrationToken(ctx, 0, 0)
+}
+
+// ListRunners get all runners
+func ListRunners(ctx *context.APIContext) {
+ // swagger:operation GET /admin/actions/runners admin getAdminRunners
+ // ---
+ // summary: Get all runners
+ // produces:
+ // - application/json
+ // responses:
+ // "200":
+ // "$ref": "#/definitions/ActionRunnersResponse"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.ListRunners(ctx, 0, 0)
+}
+
+// GetRunner get an global runner
+func GetRunner(ctx *context.APIContext) {
+ // swagger:operation GET /admin/actions/runners/{runner_id} admin getAdminRunner
+ // ---
+ // summary: Get an global runner
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: runner_id
+ // in: path
+ // description: id of the runner
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/definitions/ActionRunner"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.GetRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
+}
+
+// DeleteRunner delete an global runner
+func DeleteRunner(ctx *context.APIContext) {
+ // swagger:operation DELETE /admin/actions/runners/{runner_id} admin deleteAdminRunner
+ // ---
+ // summary: Delete an global runner
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: runner_id
+ // in: path
+ // description: id of the runner
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // description: runner has been deleted
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.DeleteRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 984095d070..e579aa6f54 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -912,7 +912,11 @@ func Routes() *web.Router {
})
m.Group("/runners", func() {
+ m.Get("", reqToken(), reqChecker, act.ListRunners)
m.Get("/registration-token", reqToken(), reqChecker, act.GetRegistrationToken)
+ m.Post("/registration-token", reqToken(), reqChecker, act.CreateRegistrationToken)
+ m.Get("/{runner_id}", reqToken(), reqChecker, act.GetRunner)
+ m.Delete("/{runner_id}", reqToken(), reqChecker, act.DeleteRunner)
})
})
}
@@ -1043,7 +1047,11 @@ func Routes() *web.Router {
})
m.Group("/runners", func() {
+ m.Get("", reqToken(), user.ListRunners)
m.Get("/registration-token", reqToken(), user.GetRegistrationToken)
+ m.Post("/registration-token", reqToken(), user.CreateRegistrationToken)
+ m.Get("/{runner_id}", reqToken(), user.GetRunner)
+ m.Delete("/{runner_id}", reqToken(), user.DeleteRunner)
})
})
@@ -1697,6 +1705,12 @@ func Routes() *web.Router {
Patch(bind(api.EditHookOption{}), admin.EditHook).
Delete(admin.DeleteHook)
})
+ m.Group("/actions/runners", func() {
+ m.Get("", admin.ListRunners)
+ m.Post("/registration-token", admin.CreateRegistrationToken)
+ m.Get("/{runner_id}", admin.GetRunner)
+ m.Delete("/{runner_id}", admin.DeleteRunner)
+ })
m.Group("/runners", func() {
m.Get("/registration-token", admin.GetRegistrationToken)
})
diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go
index b1cd2f0c3c..700a5ef8ea 100644
--- a/routers/api/v1/org/action.go
+++ b/routers/api/v1/org/action.go
@@ -190,6 +190,27 @@ func (Action) GetRegistrationToken(ctx *context.APIContext) {
shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0)
}
+// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization
+// CreateRegistrationToken returns the token to register org runners
+func (Action) CreateRegistrationToken(ctx *context.APIContext) {
+ // swagger:operation POST /orgs/{org}/actions/runners/registration-token organization orgCreateRunnerRegistrationToken
+ // ---
+ // 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 (Action) ListVariables(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList
@@ -470,6 +491,85 @@ func (Action) UpdateVariable(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
+// ListRunners get org-level runners
+func (Action) ListRunners(ctx *context.APIContext) {
+ // swagger:operation GET /orgs/{org}/actions/runners organization getOrgRunners
+ // ---
+ // summary: Get org-level runners
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: org
+ // in: path
+ // description: name of the organization
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/definitions/ActionRunnersResponse"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.ListRunners(ctx, ctx.Org.Organization.ID, 0)
+}
+
+// GetRunner get an org-level runner
+func (Action) GetRunner(ctx *context.APIContext) {
+ // swagger:operation GET /orgs/{org}/actions/runners/{runner_id} organization getOrgRunner
+ // ---
+ // summary: Get an org-level runner
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: org
+ // in: path
+ // description: name of the organization
+ // type: string
+ // required: true
+ // - name: runner_id
+ // in: path
+ // description: id of the runner
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/definitions/ActionRunner"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.GetRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
+}
+
+// DeleteRunner delete an org-level runner
+func (Action) DeleteRunner(ctx *context.APIContext) {
+ // swagger:operation DELETE /orgs/{org}/actions/runners/{runner_id} organization deleteOrgRunner
+ // ---
+ // summary: Delete an org-level runner
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: org
+ // in: path
+ // description: name of the organization
+ // type: string
+ // required: true
+ // - name: runner_id
+ // in: path
+ // description: id of the runner
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // description: runner has been deleted
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
+}
+
var _ actions_service.API = new(Action)
// Action implements actions_service.API
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index ed2017a372..6aef529f98 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -183,7 +183,7 @@ func (Action) DeleteSecret(ctx *context.APIContext) {
// required: true
// responses:
// "204":
- // description: delete one secret of the organization
+ // description: delete one secret of the repository
// "400":
// "$ref": "#/responses/error"
// "404":
@@ -531,6 +531,125 @@ func (Action) GetRegistrationToken(ctx *context.APIContext) {
shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID)
}
+// CreateRegistrationToken returns the token to register repo runners
+func (Action) CreateRegistrationToken(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/actions/runners/registration-token repository repoCreateRunnerRegistrationToken
+ // ---
+ // 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, 0, ctx.Repo.Repository.ID)
+}
+
+// ListRunners get repo-level runners
+func (Action) ListRunners(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/actions/runners repository getRepoRunners
+ // ---
+ // summary: Get repo-level runners
+ // 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": "#/definitions/ActionRunnersResponse"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.ListRunners(ctx, 0, ctx.Repo.Repository.ID)
+}
+
+// GetRunner get an repo-level runner
+func (Action) GetRunner(ctx *context.APIContext) {
+ // swagger:operation GET /repos/{owner}/{repo}/actions/runners/{runner_id} repository getRepoRunner
+ // ---
+ // summary: Get an repo-level runner
+ // 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
+ // - name: runner_id
+ // in: path
+ // description: id of the runner
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/definitions/ActionRunner"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.GetRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
+}
+
+// DeleteRunner delete an repo-level runner
+func (Action) DeleteRunner(ctx *context.APIContext) {
+ // swagger:operation DELETE /repos/{owner}/{repo}/actions/runners/{runner_id} repository deleteRepoRunner
+ // ---
+ // summary: Delete an repo-level runner
+ // 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
+ // - name: runner_id
+ // in: path
+ // description: id of the runner
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // description: runner has been deleted
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
+}
+
var _ actions_service.API = new(Action)
// Action implements actions_service.API
diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go
index f31d9e5d0b..d42f330d1c 100644
--- a/routers/api/v1/shared/runners.go
+++ b/routers/api/v1/shared/runners.go
@@ -8,8 +8,13 @@ import (
"net/http"
actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
)
// RegistrationToken is response related to registration token
@@ -30,3 +35,84 @@ func GetRegistrationToken(ctx *context.APIContext, ownerID, repoID int64) {
ctx.JSON(http.StatusOK, RegistrationToken{Token: token.Token})
}
+
+// ListRunners lists runners for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means all runners including global runners, does not appear in sql where clause
+// ownerID == 0 and repoID != 0 means all runners for the given repo
+// ownerID != 0 and repoID == 0 means all runners for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// Access rights are checked at the API route level
+func ListRunners(ctx *context.APIContext, ownerID, repoID int64) {
+ if ownerID != 0 && repoID != 0 {
+ setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+ }
+ runners, total, err := db.FindAndCount[actions_model.ActionRunner](ctx, &actions_model.FindRunnerOptions{
+ OwnerID: ownerID,
+ RepoID: repoID,
+ ListOptions: utils.GetListOptions(ctx),
+ })
+ if err != nil {
+ ctx.APIErrorInternal(err)
+ return
+ }
+
+ res := new(api.ActionRunnersResponse)
+ res.TotalCount = total
+
+ res.Entries = make([]*api.ActionRunner, len(runners))
+ for i, runner := range runners {
+ res.Entries[i] = convert.ToActionRunner(ctx, runner)
+ }
+
+ ctx.JSON(http.StatusOK, &res)
+}
+
+// GetRunner get the runner for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means any runner including global runners
+// ownerID == 0 and repoID != 0 means any runner for the given repo
+// ownerID != 0 and repoID == 0 means any runner for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// Access rights are checked at the API route level
+func GetRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
+ if ownerID != 0 && repoID != 0 {
+ setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+ }
+ runner, err := actions_model.GetRunnerByID(ctx, runnerID)
+ if err != nil {
+ ctx.APIErrorNotFound(err)
+ return
+ }
+ if !runner.EditableInContext(ownerID, repoID) {
+ ctx.APIErrorNotFound("No permission to get this runner")
+ return
+ }
+ ctx.JSON(http.StatusOK, convert.ToActionRunner(ctx, runner))
+}
+
+// DeleteRunner deletes the runner for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means any runner including global runners
+// ownerID == 0 and repoID != 0 means any runner for the given repo
+// ownerID != 0 and repoID == 0 means any runner for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// Access rights are checked at the API route level
+func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
+ if ownerID != 0 && repoID != 0 {
+ setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+ }
+ runner, err := actions_model.GetRunnerByID(ctx, runnerID)
+ if err != nil {
+ ctx.APIErrorInternal(err)
+ return
+ }
+ if !runner.EditableInContext(ownerID, repoID) {
+ ctx.APIErrorNotFound("No permission to delete this runner")
+ return
+ }
+
+ err = actions_model.DeleteRunner(ctx, runner.ID)
+ if 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 25f137f3bf..df0c8a805a 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -457,6 +457,20 @@ type swaggerRepoArtifact struct {
Body api.ActionArtifact `json:"body"`
}
+// RunnerList
+// swagger:response RunnerList
+type swaggerRunnerList struct {
+ // in:body
+ Body api.ActionRunnersResponse `json:"body"`
+}
+
+// Runner
+// swagger:response Runner
+type swaggerRunner struct {
+ // in:body
+ Body api.ActionRunner `json:"body"`
+}
+
// swagger:response Compare
type swaggerCompare struct {
// in:body
diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index 899218473e..be3f63cc5e 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -24,3 +24,81 @@ func GetRegistrationToken(ctx *context.APIContext) {
shared.GetRegistrationToken(ctx, ctx.Doer.ID, 0)
}
+
+// CreateRegistrationToken returns the token to register user runners
+func CreateRegistrationToken(ctx *context.APIContext) {
+ // swagger:operation POST /user/actions/runners/registration-token user userCreateRunnerRegistrationToken
+ // ---
+ // summary: Get an user's actions runner registration token
+ // produces:
+ // - application/json
+ // parameters:
+ // responses:
+ // "200":
+ // "$ref": "#/responses/RegistrationToken"
+
+ shared.GetRegistrationToken(ctx, ctx.Doer.ID, 0)
+}
+
+// ListRunners get user-level runners
+func ListRunners(ctx *context.APIContext) {
+ // swagger:operation GET /user/actions/runners user getUserRunners
+ // ---
+ // summary: Get user-level runners
+ // produces:
+ // - application/json
+ // responses:
+ // "200":
+ // "$ref": "#/definitions/ActionRunnersResponse"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.ListRunners(ctx, ctx.Doer.ID, 0)
+}
+
+// GetRunner get an user-level runner
+func GetRunner(ctx *context.APIContext) {
+ // swagger:operation GET /user/actions/runners/{runner_id} user getUserRunner
+ // ---
+ // summary: Get an user-level runner
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: runner_id
+ // in: path
+ // description: id of the runner
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/definitions/ActionRunner"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.GetRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
+}
+
+// DeleteRunner delete an user-level runner
+func DeleteRunner(ctx *context.APIContext) {
+ // swagger:operation DELETE /user/actions/runners/{runner_id} user deleteUserRunner
+ // ---
+ // summary: Delete an user-level runner
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: runner_id
+ // in: path
+ // description: id of the runner
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // description: runner has been deleted
+ // "400":
+ // "$ref": "#/responses/error"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ shared.DeleteRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
+}
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index 0124c163f3..a33542fd37 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -181,6 +181,7 @@ func setMergeTarget(ctx *context.Context, pull *issues_model.PullRequest) {
// GetPullDiffStats get Pull Requests diff stats
func GetPullDiffStats(ctx *context.Context) {
+ // FIXME: this getPullInfo seems to be a duplicate call with other route handlers
issue, ok := getPullInfo(ctx)
if !ok {
return
@@ -188,21 +189,19 @@ func GetPullDiffStats(ctx *context.Context) {
pull := issue.PullRequest
mergeBaseCommitID := GetMergedBaseCommitID(ctx, issue)
-
if mergeBaseCommitID == "" {
- ctx.NotFound(nil)
- return
+ return // no merge base, do nothing, do not stop the route handler, see below
}
+ // do not report 500 server error to end users if error occurs, otherwise a PR missing ref won't be able to view.
headCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(pull.GetGitRefName())
if err != nil {
- ctx.ServerError("GetRefCommitID", err)
+ log.Error("Failed to GetRefCommitID: %v, repo: %v", err, ctx.Repo.Repository.FullName())
return
}
-
diffShortStat, err := gitdiff.GetDiffShortStat(ctx.Repo.GitRepo, mergeBaseCommitID, headCommitID)
if err != nil {
- ctx.ServerError("GetDiffShortStat", err)
+ log.Error("Failed to GetDiffShortStat: %v, repo: %v", err, ctx.Repo.Repository.FullName())
return
}
diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go
index a87f6ce4dc..a642cfd66d 100644
--- a/routers/web/shared/actions/runners.go
+++ b/routers/web/shared/actions/runners.go
@@ -197,7 +197,7 @@ func RunnersEdit(ctx *context.Context) {
ctx.ServerError("LoadAttributes", err)
return
}
- if !runner.Editable(ownerID, repoID) {
+ if !runner.EditableInContext(ownerID, repoID) {
err = errors.New("no permission to edit this runner")
ctx.NotFound(err)
return
@@ -250,7 +250,7 @@ func RunnersEditPost(ctx *context.Context) {
ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err)
return
}
- if !runner.Editable(ownerID, repoID) {
+ if !runner.EditableInContext(ownerID, repoID) {
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner"))
return
}
@@ -304,7 +304,7 @@ func RunnerDeletePost(ctx *context.Context) {
return
}
- if !runner.Editable(rCtx.OwnerID, rCtx.RepoID) {
+ if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) {
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to delete this runner"))
return
}
diff --git a/services/actions/interface.go b/services/actions/interface.go
index d4fa782fec..b407f5c6c8 100644
--- a/services/actions/interface.go
+++ b/services/actions/interface.go
@@ -25,4 +25,12 @@ type API interface {
UpdateVariable(*context.APIContext)
// GetRegistrationToken get registration token
GetRegistrationToken(*context.APIContext)
+ // CreateRegistrationToken get registration token
+ CreateRegistrationToken(*context.APIContext)
+ // ListRunners list runners
+ ListRunners(*context.APIContext)
+ // GetRunner get a runner
+ GetRunner(*context.APIContext)
+ // DeleteRunner delete runner
+ DeleteRunner(*context.APIContext)
}
diff --git a/services/convert/convert.go b/services/convert/convert.go
index ac2680766c..9d2afdea30 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -30,6 +30,8 @@ import (
"code.gitea.io/gitea/modules/util"
asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/gitdiff"
+
+ runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
)
// ToEmail convert models.EmailAddress to api.Email
@@ -252,6 +254,30 @@ func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArti
}, nil
}
+func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *api.ActionRunner {
+ status := runner.Status()
+ apiStatus := "offline"
+ if runner.IsOnline() {
+ apiStatus = "online"
+ }
+ labels := make([]*api.ActionRunnerLabel, len(runner.AgentLabels))
+ for i, label := range runner.AgentLabels {
+ labels[i] = &api.ActionRunnerLabel{
+ ID: int64(i),
+ Name: label,
+ Type: "custom",
+ }
+ }
+ return &api.ActionRunner{
+ ID: runner.ID,
+ Name: runner.Name,
+ Status: apiStatus,
+ Busy: status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE,
+ Ephemeral: runner.Ephemeral,
+ Labels: labels,
+ }
+}
+
// ToVerification convert a git.Commit.Signature to an api.PayloadCommitVerification
func ToVerification(ctx context.Context, c *git.Commit) *api.PayloadCommitVerification {
verif := asymkey_service.ParseCommitWithSignature(ctx, c)
diff --git a/templates/repo/tag/name.tmpl b/templates/repo/tag/name.tmpl
index c3042014d3..b6692dfc53 100644
--- a/templates/repo/tag/name.tmpl
+++ b/templates/repo/tag/name.tmpl
@@ -1,3 +1,3 @@
-
+
{{svg "octicon-tag"}} {{.TagName}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 244bc9f9c0..97438aced9 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -75,6 +75,108 @@
}
}
},
+ "/admin/actions/runners": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin"
+ ],
+ "summary": "Get all runners",
+ "operationId": "getAdminRunners",
+ "responses": {
+ "200": {
+ "$ref": "#/definitions/ActionRunnersResponse"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ }
+ },
+ "/admin/actions/runners/registration-token": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin"
+ ],
+ "summary": "Get an global actions runner registration token",
+ "operationId": "adminCreateRunnerRegistrationToken",
+ "responses": {
+ "200": {
+ "$ref": "#/responses/RegistrationToken"
+ }
+ }
+ }
+ },
+ "/admin/actions/runners/{runner_id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin"
+ ],
+ "summary": "Get an global runner",
+ "operationId": "getAdminRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/definitions/ActionRunner"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin"
+ ],
+ "summary": "Delete an global runner",
+ "operationId": "deleteAdminRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "runner has been deleted"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ }
+ },
"/admin/cron": {
"get": {
"produces": [
@@ -1697,6 +1799,38 @@
}
}
},
+ "/orgs/{org}/actions/runners": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "organization"
+ ],
+ "summary": "Get org-level runners",
+ "operationId": "getOrgRunners",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "name of the organization",
+ "name": "org",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/definitions/ActionRunnersResponse"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ }
+ },
"/orgs/{org}/actions/runners/registration-token": {
"get": {
"produces": [
@@ -1721,6 +1855,106 @@
"$ref": "#/responses/RegistrationToken"
}
}
+ },
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "organization"
+ ],
+ "summary": "Get an organization's actions runner registration token",
+ "operationId": "orgCreateRunnerRegistrationToken",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "name of the organization",
+ "name": "org",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/RegistrationToken"
+ }
+ }
+ }
+ },
+ "/orgs/{org}/actions/runners/{runner_id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "organization"
+ ],
+ "summary": "Get an org-level runner",
+ "operationId": "getOrgRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "name of the organization",
+ "name": "org",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/definitions/ActionRunner"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "organization"
+ ],
+ "summary": "Delete an org-level runner",
+ "operationId": "deleteOrgRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "name of the organization",
+ "name": "org",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "runner has been deleted"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
}
},
"/orgs/{org}/actions/secrets": {
@@ -4331,6 +4565,45 @@
}
}
},
+ "/repos/{owner}/{repo}/actions/runners": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository"
+ ],
+ "summary": "Get repo-level runners",
+ "operationId": "getRepoRunners",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "owner of the repo",
+ "name": "owner",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repo",
+ "name": "repo",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/definitions/ActionRunnersResponse"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ }
+ },
"/repos/{owner}/{repo}/actions/runners/registration-token": {
"get": {
"produces": [
@@ -4362,6 +4635,127 @@
"$ref": "#/responses/RegistrationToken"
}
}
+ },
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository"
+ ],
+ "summary": "Get a repository's actions runner registration token",
+ "operationId": "repoCreateRunnerRegistrationToken",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "owner of the repo",
+ "name": "owner",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repo",
+ "name": "repo",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/RegistrationToken"
+ }
+ }
+ }
+ },
+ "/repos/{owner}/{repo}/actions/runners/{runner_id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository"
+ ],
+ "summary": "Get an repo-level runner",
+ "operationId": "getRepoRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "owner of the repo",
+ "name": "owner",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repo",
+ "name": "repo",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/definitions/ActionRunner"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository"
+ ],
+ "summary": "Delete an repo-level runner",
+ "operationId": "deleteRepoRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "owner of the repo",
+ "name": "owner",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repo",
+ "name": "repo",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "runner has been deleted"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
@@ -4559,7 +4953,7 @@
],
"responses": {
"204": {
- "description": "delete one secret of the organization"
+ "description": "delete one secret of the repository"
},
"400": {
"$ref": "#/responses/error"
@@ -16869,6 +17263,29 @@
}
}
},
+ "/user/actions/runners": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "Get user-level runners",
+ "operationId": "getUserRunners",
+ "responses": {
+ "200": {
+ "$ref": "#/definitions/ActionRunnersResponse"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ }
+ },
"/user/actions/runners/registration-token": {
"get": {
"produces": [
@@ -16884,6 +17301,83 @@
"$ref": "#/responses/RegistrationToken"
}
}
+ },
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "Get an user's actions runner registration token",
+ "operationId": "userCreateRunnerRegistrationToken",
+ "responses": {
+ "200": {
+ "$ref": "#/responses/RegistrationToken"
+ }
+ }
+ }
+ },
+ "/user/actions/runners/{runner_id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "Get an user-level runner",
+ "operationId": "getUserRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/definitions/ActionRunner"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "Delete an user-level runner",
+ "operationId": "deleteUserRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "runner has been deleted"
+ },
+ "400": {
+ "$ref": "#/responses/error"
+ },
+ "404": {
+ "$ref": "#/responses/notFound"
+ }
+ }
}
},
"/user/actions/secrets/{secretname}": {
@@ -19377,6 +19871,80 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
+ "ActionRunner": {
+ "description": "ActionRunner represents a Runner",
+ "type": "object",
+ "properties": {
+ "busy": {
+ "type": "boolean",
+ "x-go-name": "Busy"
+ },
+ "ephemeral": {
+ "type": "boolean",
+ "x-go-name": "Ephemeral"
+ },
+ "id": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "ID"
+ },
+ "labels": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/ActionRunnerLabel"
+ },
+ "x-go-name": "Labels"
+ },
+ "name": {
+ "type": "string",
+ "x-go-name": "Name"
+ },
+ "status": {
+ "type": "string",
+ "x-go-name": "Status"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
+ "ActionRunnerLabel": {
+ "description": "ActionRunnerLabel represents a Runner Label",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "ID"
+ },
+ "name": {
+ "type": "string",
+ "x-go-name": "Name"
+ },
+ "type": {
+ "type": "string",
+ "x-go-name": "Type"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
+ "ActionRunnersResponse": {
+ "description": "ActionRunnersResponse returns Runners",
+ "type": "object",
+ "properties": {
+ "runners": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/ActionRunner"
+ },
+ "x-go-name": "Entries"
+ },
+ "total_count": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "TotalCount"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
"ActionTask": {
"description": "ActionTask represents a ActionTask",
"type": "object",
@@ -27409,6 +27977,18 @@
}
}
},
+ "Runner": {
+ "description": "Runner",
+ "schema": {
+ "$ref": "#/definitions/ActionRunner"
+ }
+ },
+ "RunnerList": {
+ "description": "RunnerList",
+ "schema": {
+ "$ref": "#/definitions/ActionRunnersResponse"
+ }
+ },
"SearchResults": {
"description": "SearchResults",
"schema": {
diff --git a/tests/integration/api_actions_runner_test.go b/tests/integration/api_actions_runner_test.go
new file mode 100644
index 0000000000..ace7aa381a
--- /dev/null
+++ b/tests/integration/api_actions_runner_test.go
@@ -0,0 +1,332 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "slices"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIActionsRunner(t *testing.T) {
+ t.Run("AdminRunner", testActionsRunnerAdmin)
+ t.Run("UserRunner", testActionsRunnerUser)
+ t.Run("OwnerRunner", testActionsRunnerOwner)
+ t.Run("RepoRunner", testActionsRunnerRepo)
+}
+
+func testActionsRunnerAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+ req := NewRequest(t, "POST", "/api/v1/admin/actions/runners/registration-token").AddTokenAuth(token)
+ tokenResp := MakeRequest(t, req, http.StatusOK)
+ var registrationToken struct {
+ Token string `json:"token"`
+ }
+ DecodeJSON(t, tokenResp, ®istrationToken)
+ assert.NotEmpty(t, registrationToken.Token)
+
+ req = NewRequest(t, "GET", "/api/v1/admin/actions/runners").AddTokenAuth(token)
+ runnerListResp := MakeRequest(t, req, http.StatusOK)
+ runnerList := api.ActionRunnersResponse{}
+ DecodeJSON(t, runnerListResp, &runnerList)
+
+ assert.Len(t, runnerList.Entries, 4)
+
+ idx := slices.IndexFunc(runnerList.Entries, func(e *api.ActionRunner) bool { return e.ID == 34349 })
+ require.NotEqual(t, -1, idx)
+ expectedRunner := runnerList.Entries[idx]
+ assert.Equal(t, "runner_to_be_deleted", expectedRunner.Name)
+ assert.False(t, expectedRunner.Ephemeral)
+ assert.Len(t, expectedRunner.Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", expectedRunner.Labels[0].Name)
+ assert.Equal(t, "linux", expectedRunner.Labels[1].Name)
+
+ // Verify all returned runners can be requested and deleted
+ for _, runnerEntry := range runnerList.Entries {
+ // Verify get the runner by id
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", runnerEntry.ID)).AddTokenAuth(token)
+ runnerResp := MakeRequest(t, req, http.StatusOK)
+
+ runner := api.ActionRunner{}
+ DecodeJSON(t, runnerResp, &runner)
+
+ assert.Equal(t, runnerEntry.Name, runner.Name)
+ assert.Equal(t, runnerEntry.ID, runner.ID)
+ assert.Equal(t, runnerEntry.Ephemeral, runner.Ephemeral)
+ assert.ElementsMatch(t, runnerEntry.Labels, runner.Labels)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/admin/actions/runners/%d", runnerEntry.ID)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Verify runner deletion
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", runnerEntry.ID)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+}
+
+func testActionsRunnerUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ userUsername := "user1"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteUser)
+ req := NewRequest(t, "POST", "/api/v1/user/actions/runners/registration-token").AddTokenAuth(token)
+ tokenResp := MakeRequest(t, req, http.StatusOK)
+ var registrationToken struct {
+ Token string `json:"token"`
+ }
+ DecodeJSON(t, tokenResp, ®istrationToken)
+ assert.NotEmpty(t, registrationToken.Token)
+
+ req = NewRequest(t, "GET", "/api/v1/user/actions/runners").AddTokenAuth(token)
+ runnerListResp := MakeRequest(t, req, http.StatusOK)
+ runnerList := api.ActionRunnersResponse{}
+ DecodeJSON(t, runnerListResp, &runnerList)
+
+ assert.Len(t, runnerList.Entries, 1)
+ assert.Equal(t, "runner_to_be_deleted-user", runnerList.Entries[0].Name)
+ assert.Equal(t, int64(34346), runnerList.Entries[0].ID)
+ assert.False(t, runnerList.Entries[0].Ephemeral)
+ assert.Len(t, runnerList.Entries[0].Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name)
+ assert.Equal(t, "linux", runnerList.Entries[0].Labels[1].Name)
+
+ // Verify get the runner by id
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ runnerResp := MakeRequest(t, req, http.StatusOK)
+
+ runner := api.ActionRunner{}
+ DecodeJSON(t, runnerResp, &runner)
+
+ assert.Equal(t, "runner_to_be_deleted-user", runner.Name)
+ assert.Equal(t, int64(34346), runner.ID)
+ assert.False(t, runner.Ephemeral)
+ assert.Len(t, runner.Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
+ assert.Equal(t, "linux", runner.Labels[1].Name)
+
+ // Verify delete the runner by id
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Verify runner deletion
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func testActionsRunnerOwner(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("GetRunner", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+ // Verify get the runner by id with read scope
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347)).AddTokenAuth(token)
+ runnerResp := MakeRequest(t, req, http.StatusOK)
+
+ runner := api.ActionRunner{}
+ DecodeJSON(t, runnerResp, &runner)
+
+ assert.Equal(t, "runner_to_be_deleted-org", runner.Name)
+ assert.Equal(t, int64(34347), runner.ID)
+ assert.False(t, runner.Ephemeral)
+ assert.Len(t, runner.Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
+ assert.Equal(t, "linux", runner.Labels[1].Name)
+ })
+
+ t.Run("Access", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteOrganization)
+ req := NewRequest(t, "POST", "/api/v1/orgs/org3/actions/runners/registration-token").AddTokenAuth(token)
+ tokenResp := MakeRequest(t, req, http.StatusOK)
+ var registrationToken struct {
+ Token string `json:"token"`
+ }
+ DecodeJSON(t, tokenResp, ®istrationToken)
+ assert.NotEmpty(t, registrationToken.Token)
+
+ req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runners").AddTokenAuth(token)
+ runnerListResp := MakeRequest(t, req, http.StatusOK)
+ runnerList := api.ActionRunnersResponse{}
+ DecodeJSON(t, runnerListResp, &runnerList)
+
+ assert.Len(t, runnerList.Entries, 1)
+ assert.Equal(t, "runner_to_be_deleted-org", runnerList.Entries[0].Name)
+ assert.Equal(t, int64(34347), runnerList.Entries[0].ID)
+ assert.False(t, runnerList.Entries[0].Ephemeral)
+ assert.Len(t, runnerList.Entries[0].Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name)
+ assert.Equal(t, "linux", runnerList.Entries[0].Labels[1].Name)
+
+ // Verify get the runner by id
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ runnerResp := MakeRequest(t, req, http.StatusOK)
+
+ runner := api.ActionRunner{}
+ DecodeJSON(t, runnerResp, &runner)
+
+ assert.Equal(t, "runner_to_be_deleted-org", runner.Name)
+ assert.Equal(t, int64(34347), runner.ID)
+ assert.False(t, runner.Ephemeral)
+ assert.Len(t, runner.Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
+ assert.Equal(t, "linux", runner.Labels[1].Name)
+
+ // Verify delete the runner by id
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Verify runner deletion
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("DeleteReadScopeForbidden", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+
+ // Verify delete the runner by id is forbidden with read scope
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("GetRepoScopeForbidden", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+ // Verify get the runner by id with read scope
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("GetAdminRunner", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+ // Verify get a runner by id of different entity is not found
+ // runner.EditableInContext(ownerID, repoID) false
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34349)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("DeleteAdminRunner", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteOrganization)
+ // Verify delete a runner by id of different entity is not found
+ // runner.EditableInContext(ownerID, repoID) false
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34349)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+}
+
+func testActionsRunnerRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("GetRunner", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+ // Verify get the runner by id with read scope
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348)).AddTokenAuth(token)
+ runnerResp := MakeRequest(t, req, http.StatusOK)
+
+ runner := api.ActionRunner{}
+ DecodeJSON(t, runnerResp, &runner)
+
+ assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name)
+ assert.Equal(t, int64(34348), runner.ID)
+ assert.False(t, runner.Ephemeral)
+ assert.Len(t, runner.Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
+ assert.Equal(t, "linux", runner.Labels[1].Name)
+ })
+
+ t.Run("Access", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequest(t, "POST", "/api/v1/repos/user2/repo1/actions/runners/registration-token").AddTokenAuth(token)
+ tokenResp := MakeRequest(t, req, http.StatusOK)
+ var registrationToken struct {
+ Token string `json:"token"`
+ }
+ DecodeJSON(t, tokenResp, ®istrationToken)
+ assert.NotEmpty(t, registrationToken.Token)
+
+ req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/actions/runners").AddTokenAuth(token)
+ runnerListResp := MakeRequest(t, req, http.StatusOK)
+ runnerList := api.ActionRunnersResponse{}
+ DecodeJSON(t, runnerListResp, &runnerList)
+
+ assert.Len(t, runnerList.Entries, 1)
+ assert.Equal(t, "runner_to_be_deleted-repo1", runnerList.Entries[0].Name)
+ assert.Equal(t, int64(34348), runnerList.Entries[0].ID)
+ assert.False(t, runnerList.Entries[0].Ephemeral)
+ assert.Len(t, runnerList.Entries[0].Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name)
+ assert.Equal(t, "linux", runnerList.Entries[0].Labels[1].Name)
+
+ // Verify get the runner by id
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ runnerResp := MakeRequest(t, req, http.StatusOK)
+
+ runner := api.ActionRunner{}
+ DecodeJSON(t, runnerResp, &runner)
+
+ assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name)
+ assert.Equal(t, int64(34348), runner.ID)
+ assert.False(t, runner.Ephemeral)
+ assert.Len(t, runner.Labels, 2)
+ assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
+ assert.Equal(t, "linux", runner.Labels[1].Name)
+
+ // Verify delete the runner by id
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Verify runner deletion
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("DeleteReadScopeForbidden", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+
+ // Verify delete the runner by id is forbidden with read scope
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("GetOrganizationScopeForbidden", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+ // Verify get the runner by id with read scope
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("GetAdminRunnerNotFound", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+ // Verify get a runner by id of different entity is not found
+ // runner.EditableInContext(ownerID, repoID) false
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34349)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("DeleteAdminRunnerNotFound", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteRepository)
+ // Verify delete a runner by id of different entity is not found
+ // runner.EditableInContext(ownerID, repoID) false
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34349)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+}
diff --git a/tests/integration/api_packages_swift_test.go b/tests/integration/api_packages_swift_test.go
index c0e0dccfab..b29e8459ff 100644
--- a/tests/integration/api_packages_swift_test.go
+++ b/tests/integration/api_packages_swift_test.go
@@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestPackageSwift(t *testing.T) {
@@ -34,6 +35,7 @@ func TestPackageSwift(t *testing.T) {
packageName := "test_package"
packageID := packageScope + "." + packageName
packageVersion := "1.0.3"
+ packageVersion2 := "1.0.4"
packageAuthor := "KN4CK3R"
packageDescription := "Gitea Test Package"
packageRepositoryURL := "https://gitea.io/gitea/gitea"
@@ -183,6 +185,94 @@ func TestPackageSwift(t *testing.T) {
)
})
+ t.Run("UploadMultipart", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadPackage := func(t *testing.T, url string, expectedStatus int, sr io.Reader, metadata string) {
+ var body bytes.Buffer
+ mpw := multipart.NewWriter(&body)
+
+ // Read the source archive content
+ sourceContent, err := io.ReadAll(sr)
+ assert.NoError(t, err)
+ mpw.WriteField("source-archive", string(sourceContent))
+
+ if metadata != "" {
+ mpw.WriteField("metadata", metadata)
+ }
+
+ mpw.Close()
+
+ req := NewRequestWithBody(t, "PUT", url, &body).
+ SetHeader("Content-Type", mpw.FormDataContentType()).
+ SetHeader("Accept", swift_router.AcceptJSON).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ createArchive := func(files map[string]string) *bytes.Buffer {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for filename, content := range files {
+ w, _ := zw.Create(filename)
+ w.Write([]byte(content))
+ }
+ zw.Close()
+ return &buf
+ }
+
+ uploadURL := fmt.Sprintf("%s/%s/%s/%s", url, packageScope, packageName, packageVersion2)
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ // Test with metadata as form field
+ uploadPackage(
+ t,
+ uploadURL,
+ http.StatusCreated,
+ createArchive(map[string]string{
+ "Package.swift": contentManifest1,
+ "Package@swift-5.6.swift": contentManifest2,
+ }),
+ `{"name":"`+packageName+`","version":"`+packageVersion2+`","description":"`+packageDescription+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`,
+ )
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeSwift)
+ assert.NoError(t, err)
+ require.Len(t, pvs, 2) // ATTENTION: many subtests are unable to run separately, they depend on the results of previous tests
+ thisPackageVersion := pvs[0]
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, thisPackageVersion)
+ assert.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.Equal(t, packageID, pd.Package.Name)
+ assert.Equal(t, packageVersion2, pd.Version.Version)
+ assert.IsType(t, &swift_module.Metadata{}, pd.Metadata)
+ metadata := pd.Metadata.(*swift_module.Metadata)
+ assert.Equal(t, packageDescription, metadata.Description)
+ assert.Len(t, metadata.Manifests, 2)
+ assert.Equal(t, contentManifest1, metadata.Manifests[""].Content)
+ assert.Equal(t, contentManifest2, metadata.Manifests["5.6"].Content)
+ assert.Len(t, pd.VersionProperties, 1)
+ assert.Equal(t, packageRepositoryURL, pd.VersionProperties.GetByName(swift_module.PropertyRepositoryURL))
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, thisPackageVersion.ID)
+ assert.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, fmt.Sprintf("%s-%s.zip", packageName, packageVersion2), pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ uploadPackage(
+ t,
+ uploadURL,
+ http.StatusConflict,
+ createArchive(map[string]string{
+ "Package.swift": contentManifest1,
+ }),
+ "",
+ )
+ })
+
t.Run("Download", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
@@ -211,7 +301,7 @@ func TestPackageSwift(t *testing.T) {
SetHeader("Accept", swift_router.AcceptJSON)
resp := MakeRequest(t, req, http.StatusOK)
- versionURL := setting.AppURL + url[1:] + fmt.Sprintf("/%s/%s/%s", packageScope, packageName, packageVersion)
+ versionURL := setting.AppURL + url[1:] + fmt.Sprintf("/%s/%s/%s", packageScope, packageName, packageVersion2)
assert.Equal(t, "1", resp.Header().Get("Content-Version"))
assert.Equal(t, fmt.Sprintf(`<%s>; rel="latest-version"`, versionURL), resp.Header().Get("Link"))
@@ -221,9 +311,9 @@ func TestPackageSwift(t *testing.T) {
var result *swift_router.EnumeratePackageVersionsResponse
DecodeJSON(t, resp, &result)
- assert.Len(t, result.Releases, 1)
- assert.Contains(t, result.Releases, packageVersion)
- assert.Equal(t, versionURL, result.Releases[packageVersion].URL)
+ assert.Len(t, result.Releases, 2)
+ assert.Contains(t, result.Releases, packageVersion2)
+ assert.Equal(t, versionURL, result.Releases[packageVersion2].URL)
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.json", url, packageScope, packageName)).
AddBasicAuth(user.Name)