loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[gitea] week 2025-09 cherry pick (gitea/main -> forgejo) (#7031)

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7031
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Reviewed-by: 0ko <0ko@noreply.codeberg.org>

Gusted 2212923d 53540814

+523 -169
+1 -1
Makefile
··· 534 534 535 535 .PHONY: lint-yaml 536 536 lint-yaml: .venv 537 - @poetry run yamllint . 537 + @poetry run yamllint -s . 538 538 539 539 .PHONY: security-check 540 540 security-check:
+3 -2
models/actions/run_list.go
··· 10 10 repo_model "code.gitea.io/gitea/models/repo" 11 11 user_model "code.gitea.io/gitea/models/user" 12 12 "code.gitea.io/gitea/modules/container" 13 + "code.gitea.io/gitea/modules/translation" 13 14 webhook_module "code.gitea.io/gitea/modules/webhook" 14 15 15 16 "xorm.io/builder" ··· 112 113 } 113 114 114 115 // GetStatusInfoList returns a slice of StatusInfo 115 - func GetStatusInfoList(ctx context.Context) []StatusInfo { 116 + func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo { 116 117 // same as those in aggregateJobStatus 117 118 allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning} 118 119 statusInfoList := make([]StatusInfo, 0, 4) 119 120 for _, s := range allStatus { 120 121 statusInfoList = append(statusInfoList, StatusInfo{ 121 122 Status: int(s), 122 - DisplayedStatus: s.String(), 123 + DisplayedStatus: s.LocaleString(lang), 123 124 }) 124 125 } 125 126 return statusInfoList
+1 -1
models/admin/task.go
··· 44 44 // TranslatableMessage represents JSON struct that can be translated with a Locale 45 45 type TranslatableMessage struct { 46 46 Format string 47 - Args []any `json:"omitempty"` 47 + Args []any `json:",omitempty"` 48 48 } 49 49 50 50 // LoadRepo loads repository of the task
+5
models/packages/package.go
··· 242 242 return err 243 243 } 244 244 245 + func UnlinkRepository(ctx context.Context, packageID int64) error { 246 + _, err := db.GetEngine(ctx).ID(packageID).Cols("repo_id").Update(&Package{RepoID: 0}) 247 + return err 248 + } 249 + 245 250 // UnlinkRepositoryFromAllPackages unlinks every package from the repository 246 251 func UnlinkRepositoryFromAllPackages(ctx context.Context, repoID int64) error { 247 252 _, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Cols("repo_id").Update(&Package{})
+1 -1
routers/api/actions/runner/runner.go
··· 158 158 // if the task version in request is not equal to the version in db, 159 159 // it means there may still be some tasks not be assigned. 160 160 // try to pick a task for the runner that send the request. 161 - if t, ok, err := pickTask(ctx, runner); err != nil { 161 + if t, ok, err := actions_service.PickTask(ctx, runner); err != nil { 162 162 log.Error("pick task failed: %v", err) 163 163 return nil, status.Errorf(codes.Internal, "pick task: %v", err) 164 164 } else if ok {
-95
routers/api/actions/runner/utils.go
··· 1 - // Copyright 2022 The Gitea Authors. All rights reserved. 2 - // SPDX-License-Identifier: MIT 3 - 4 - package runner 5 - 6 - import ( 7 - "context" 8 - "fmt" 9 - 10 - actions_model "code.gitea.io/gitea/models/actions" 11 - secret_model "code.gitea.io/gitea/models/secret" 12 - "code.gitea.io/gitea/modules/log" 13 - "code.gitea.io/gitea/services/actions" 14 - 15 - runnerv1 "code.gitea.io/actions-proto-go/runner/v1" 16 - "google.golang.org/protobuf/types/known/structpb" 17 - ) 18 - 19 - func pickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) { 20 - t, ok, err := actions_model.CreateTaskForRunner(ctx, runner) 21 - if err != nil { 22 - return nil, false, fmt.Errorf("CreateTaskForRunner: %w", err) 23 - } 24 - if !ok { 25 - return nil, false, nil 26 - } 27 - 28 - secrets, err := secret_model.GetSecretsOfTask(ctx, t) 29 - if err != nil { 30 - return nil, false, fmt.Errorf("GetSecretsOfTask: %w", err) 31 - } 32 - 33 - vars, err := actions_model.GetVariablesOfRun(ctx, t.Job.Run) 34 - if err != nil { 35 - return nil, false, fmt.Errorf("GetVariablesOfRun: %w", err) 36 - } 37 - 38 - actions.CreateCommitStatus(ctx, t.Job) 39 - 40 - task := &runnerv1.Task{ 41 - Id: t.ID, 42 - WorkflowPayload: t.Job.WorkflowPayload, 43 - Context: generateTaskContext(t), 44 - Secrets: secrets, 45 - Vars: vars, 46 - } 47 - 48 - if needs, err := findTaskNeeds(ctx, t); err != nil { 49 - log.Error("Cannot find needs for task %v: %v", t.ID, err) 50 - // Go on with empty needs. 51 - // If return error, the task will be wild, which means the runner will never get it when it has been assigned to the runner. 52 - // In contrast, missing needs is less serious. 53 - // And the task will fail and the runner will report the error in the logs. 54 - } else { 55 - task.Needs = needs 56 - } 57 - 58 - return task, true, nil 59 - } 60 - 61 - func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { 62 - giteaRuntimeToken, err := actions.CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID) 63 - if err != nil { 64 - log.Error("actions.CreateAuthorizationToken failed: %v", err) 65 - } 66 - 67 - gitCtx := actions.GenerateGiteaContext(t.Job.Run, t.Job) 68 - gitCtx["token"] = t.Token 69 - gitCtx["gitea_runtime_token"] = giteaRuntimeToken 70 - 71 - taskContext, err := structpb.NewStruct(gitCtx) 72 - if err != nil { 73 - log.Error("structpb.NewStruct failed: %v", err) 74 - } 75 - 76 - return taskContext 77 - } 78 - 79 - func findTaskNeeds(ctx context.Context, task *actions_model.ActionTask) (map[string]*runnerv1.TaskNeed, error) { 80 - if err := task.LoadAttributes(ctx); err != nil { 81 - return nil, fmt.Errorf("task LoadAttributes: %w", err) 82 - } 83 - taskNeeds, err := actions.FindTaskNeeds(ctx, task.Job) 84 - if err != nil { 85 - return nil, err 86 - } 87 - ret := make(map[string]*runnerv1.TaskNeed, len(taskNeeds)) 88 - for jobID, taskNeed := range taskNeeds { 89 - ret[jobID] = &runnerv1.TaskNeed{ 90 - Outputs: taskNeed.Outputs, 91 - Result: runnerv1.Result(taskNeed.Result), 92 - } 93 - } 94 - return ret, nil 95 - }
+12 -6
routers/api/v1/api.go
··· 1485 1485 1486 1486 // NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs 1487 1487 m.Group("/packages/{username}", func() { 1488 - m.Group("/{type}/{name}/{version}", func() { 1489 - m.Get("", reqToken(), packages.GetPackage) 1490 - m.Delete("", reqToken(), reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) 1491 - m.Get("/files", reqToken(), packages.ListPackageFiles) 1488 + m.Group("/{type}/{name}", func() { 1489 + m.Group("/{version}", func() { 1490 + m.Get("", packages.GetPackage) 1491 + m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) 1492 + m.Get("/files", packages.ListPackageFiles) 1493 + }) 1494 + 1495 + m.Post("/-/link/{repo_name}", reqPackageAccess(perm.AccessModeWrite), packages.LinkPackage) 1496 + m.Post("/-/unlink", reqPackageAccess(perm.AccessModeWrite), packages.UnlinkPackage) 1492 1497 }) 1493 - m.Get("/", reqToken(), packages.ListPackages) 1494 - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) 1498 + 1499 + m.Get("/", packages.ListPackages) 1500 + }, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) 1495 1501 1496 1502 // Organizations 1497 1503 m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs)
+122
routers/api/v1/packages/package.go
··· 4 4 package packages 5 5 6 6 import ( 7 + "errors" 7 8 "net/http" 8 9 9 10 "code.gitea.io/gitea/models/packages" 11 + repo_model "code.gitea.io/gitea/models/repo" 10 12 "code.gitea.io/gitea/modules/optional" 11 13 api "code.gitea.io/gitea/modules/structs" 14 + "code.gitea.io/gitea/modules/util" 12 15 "code.gitea.io/gitea/routers/api/v1/utils" 13 16 "code.gitea.io/gitea/services/context" 14 17 "code.gitea.io/gitea/services/convert" ··· 213 216 214 217 ctx.JSON(http.StatusOK, apiPackageFiles) 215 218 } 219 + 220 + // LinkPackage sets a repository link for a package 221 + func LinkPackage(ctx *context.APIContext) { 222 + // swagger:operation POST /packages/{owner}/{type}/{name}/-/link/{repo_name} package linkPackage 223 + // --- 224 + // summary: Link a package to a repository 225 + // parameters: 226 + // - name: owner 227 + // in: path 228 + // description: owner of the package 229 + // type: string 230 + // required: true 231 + // - name: type 232 + // in: path 233 + // description: type of the package 234 + // type: string 235 + // required: true 236 + // - name: name 237 + // in: path 238 + // description: name of the package 239 + // type: string 240 + // required: true 241 + // - name: repo_name 242 + // in: path 243 + // description: name of the repository to link. 244 + // type: string 245 + // required: true 246 + // responses: 247 + // "201": 248 + // "$ref": "#/responses/empty" 249 + // "404": 250 + // "$ref": "#/responses/notFound" 251 + 252 + pkg, err := packages.GetPackageByName(ctx, ctx.ContextUser.ID, packages.Type(ctx.PathParamRaw("type")), ctx.PathParamRaw("name")) 253 + if err != nil { 254 + if errors.Is(err, util.ErrNotExist) { 255 + ctx.Error(http.StatusNotFound, "GetPackageByName", err) 256 + } else { 257 + ctx.Error(http.StatusInternalServerError, "GetPackageByName", err) 258 + } 259 + return 260 + } 261 + 262 + repo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ctx.PathParamRaw("repo_name")) 263 + if err != nil { 264 + if errors.Is(err, util.ErrNotExist) { 265 + ctx.Error(http.StatusNotFound, "GetRepositoryByName", err) 266 + } else { 267 + ctx.Error(http.StatusInternalServerError, "GetRepositoryByName", err) 268 + } 269 + return 270 + } 271 + 272 + err = packages_service.LinkToRepository(ctx, pkg, repo, ctx.Doer) 273 + if err != nil { 274 + switch { 275 + case errors.Is(err, util.ErrInvalidArgument): 276 + ctx.Error(http.StatusBadRequest, "LinkToRepository", err) 277 + case errors.Is(err, util.ErrPermissionDenied): 278 + ctx.Error(http.StatusForbidden, "LinkToRepository", err) 279 + default: 280 + ctx.Error(http.StatusInternalServerError, "LinkToRepository", err) 281 + } 282 + return 283 + } 284 + ctx.Status(http.StatusCreated) 285 + } 286 + 287 + // UnlinkPackage sets a repository link for a package 288 + func UnlinkPackage(ctx *context.APIContext) { 289 + // swagger:operation POST /packages/{owner}/{type}/{name}/-/unlink package unlinkPackage 290 + // --- 291 + // summary: Unlink a package from a repository 292 + // parameters: 293 + // - name: owner 294 + // in: path 295 + // description: owner of the package 296 + // type: string 297 + // required: true 298 + // - name: type 299 + // in: path 300 + // description: type of the package 301 + // type: string 302 + // required: true 303 + // - name: name 304 + // in: path 305 + // description: name of the package 306 + // type: string 307 + // required: true 308 + // responses: 309 + // "201": 310 + // "$ref": "#/responses/empty" 311 + // "404": 312 + // "$ref": "#/responses/notFound" 313 + 314 + pkg, err := packages.GetPackageByName(ctx, ctx.ContextUser.ID, packages.Type(ctx.PathParamRaw("type")), ctx.PathParamRaw("name")) 315 + if err != nil { 316 + if errors.Is(err, util.ErrNotExist) { 317 + ctx.Error(http.StatusNotFound, "GetPackageByName", err) 318 + } else { 319 + ctx.Error(http.StatusInternalServerError, "GetPackageByName", err) 320 + } 321 + return 322 + } 323 + 324 + err = packages_service.UnlinkFromRepository(ctx, pkg, ctx.Doer) 325 + if err != nil { 326 + switch { 327 + case errors.Is(err, util.ErrPermissionDenied): 328 + ctx.Error(http.StatusForbidden, "UnlinkFromRepository", err) 329 + case errors.Is(err, util.ErrInvalidArgument): 330 + ctx.Error(http.StatusBadRequest, "UnlinkFromRepository", err) 331 + default: 332 + ctx.Error(http.StatusInternalServerError, "UnlinkFromRepository", err) 333 + } 334 + return 335 + } 336 + ctx.Status(http.StatusNoContent) 337 + }
+1 -1
routers/web/repo/actions/actions.go
··· 240 240 } 241 241 ctx.Data["Actors"] = repo.MakeSelfOnTop(ctx.Doer, actors) 242 242 243 - ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx) 243 + ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale) 244 244 245 245 pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) 246 246 pager.SetDefaultParams(ctx)
+107
services/actions/task.go
··· 1 + // Copyright 2022 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package actions 5 + 6 + import ( 7 + "context" 8 + "fmt" 9 + 10 + actions_model "code.gitea.io/gitea/models/actions" 11 + "code.gitea.io/gitea/models/db" 12 + secret_model "code.gitea.io/gitea/models/secret" 13 + 14 + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" 15 + "google.golang.org/protobuf/types/known/structpb" 16 + ) 17 + 18 + func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) { 19 + var ( 20 + task *runnerv1.Task 21 + job *actions_model.ActionRunJob 22 + ) 23 + 24 + if err := db.WithTx(ctx, func(ctx context.Context) error { 25 + t, ok, err := actions_model.CreateTaskForRunner(ctx, runner) 26 + if err != nil { 27 + return fmt.Errorf("CreateTaskForRunner: %w", err) 28 + } 29 + if !ok { 30 + return nil 31 + } 32 + 33 + if err := t.LoadAttributes(ctx); err != nil { 34 + return fmt.Errorf("task LoadAttributes: %w", err) 35 + } 36 + job = t.Job 37 + 38 + secrets, err := secret_model.GetSecretsOfTask(ctx, t) 39 + if err != nil { 40 + return fmt.Errorf("GetSecretsOfTask: %w", err) 41 + } 42 + 43 + vars, err := actions_model.GetVariablesOfRun(ctx, t.Job.Run) 44 + if err != nil { 45 + return fmt.Errorf("GetVariablesOfRun: %w", err) 46 + } 47 + 48 + needs, err := findTaskNeeds(ctx, job) 49 + if err != nil { 50 + return fmt.Errorf("findTaskNeeds: %w", err) 51 + } 52 + 53 + taskContext, err := generateTaskContext(t) 54 + if err != nil { 55 + return fmt.Errorf("generateTaskContext: %w", err) 56 + } 57 + 58 + task = &runnerv1.Task{ 59 + Id: t.ID, 60 + WorkflowPayload: t.Job.WorkflowPayload, 61 + Context: taskContext, 62 + Secrets: secrets, 63 + Vars: vars, 64 + Needs: needs, 65 + } 66 + 67 + return nil 68 + }); err != nil { 69 + return nil, false, err 70 + } 71 + 72 + if task == nil { 73 + return nil, false, nil 74 + } 75 + 76 + CreateCommitStatus(ctx, job) 77 + 78 + return task, true, nil 79 + } 80 + 81 + func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) { 82 + giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID) 83 + if err != nil { 84 + return nil, err 85 + } 86 + 87 + gitCtx := GenerateGiteaContext(t.Job.Run, t.Job) 88 + gitCtx["token"] = t.Token 89 + gitCtx["gitea_runtime_token"] = giteaRuntimeToken 90 + 91 + return structpb.NewStruct(gitCtx) 92 + } 93 + 94 + func findTaskNeeds(ctx context.Context, taskJob *actions_model.ActionRunJob) (map[string]*runnerv1.TaskNeed, error) { 95 + taskNeeds, err := FindTaskNeeds(ctx, taskJob) 96 + if err != nil { 97 + return nil, err 98 + } 99 + ret := make(map[string]*runnerv1.TaskNeed, len(taskNeeds)) 100 + for jobID, taskNeed := range taskNeeds { 101 + ret[jobID] = &runnerv1.TaskNeed{ 102 + Outputs: taskNeed.Outputs, 103 + Result: runnerv1.Result(taskNeed.Result), 104 + } 105 + } 106 + return ret, nil 107 + }
+1 -1
services/cron/tasks_basic.go
··· 54 54 RunAtStart: false, 55 55 Schedule: "@midnight", 56 56 }, 57 - Timeout: 60 * time.Second, 57 + Timeout: time.Duration(setting.Git.Timeout.Default) * time.Second, 58 58 Args: []string{}, 59 59 }, func(ctx context.Context, _ *user_model.User, config Config) error { 60 60 rhcConfig := config.(*RepoHealthCheckConfig)
+11 -2
services/mirror/mirror_pull.go
··· 135 135 case strings.HasPrefix(lines[i], " - "): // Delete reference 136 136 isTag := !strings.HasPrefix(refName, remoteName+"/") 137 137 var refFullName git.RefName 138 - if isTag { 138 + if strings.HasPrefix(refName, "refs/") { 139 + refFullName = git.RefName(refName) 140 + } else if isTag { 139 141 refFullName = git.RefNameFromTag(refName) 140 142 } else { 141 143 refFullName = git.RefNameFromBranch(strings.TrimPrefix(refName, remoteName+"/")) ··· 158 160 log.Error("Expect two SHAs but not what found: %q", lines[i]) 159 161 continue 160 162 } 163 + var refFullName git.RefName 164 + if strings.HasPrefix(refName, "refs/") { 165 + refFullName = git.RefName(refName) 166 + } else { 167 + refFullName = git.RefNameFromBranch(strings.TrimPrefix(refName, remoteName+"/")) 168 + } 169 + 161 170 results = append(results, &mirrorSyncResult{ 162 - refName: git.RefNameFromBranch(strings.TrimPrefix(refName, remoteName+"/")), 171 + refName: refFullName, 163 172 oldCommitID: shas[0], 164 173 newCommitID: shas[1], 165 174 })
+16 -6
services/mirror/mirror_test.go
··· 17 17 - [deleted] (none) -> tag1 18 18 + f895a1e...957a993 test2 -> origin/test2 (forced update) 19 19 957a993..a87ba5f test3 -> origin/test3 20 - * [new ref] refs/pull/27/merge -> refs/pull/27/merge 21 - * [new ref] refs/pull/516/head -> refs/pull/516/head 22 - ` 20 + * [new ref] refs/pull/26595/head -> refs/pull/26595/head 21 + * [new ref] refs/pull/26595/merge -> refs/pull/26595/merge 22 + e0639e38fb..6db2410489 refs/pull/25873/head -> refs/pull/25873/head 23 + + 1c97ebc746...976d27d52f refs/pull/25873/merge -> refs/pull/25873/merge (forced update) 24 + ` 23 25 results := parseRemoteUpdateOutput(output, "origin") 24 - assert.Len(t, results, 8) 26 + assert.Len(t, results, 10) 25 27 assert.EqualValues(t, "refs/tags/v0.1.8", results[0].refName.String()) 26 28 assert.EqualValues(t, gitShortEmptySha, results[0].oldCommitID) 27 29 assert.EqualValues(t, "", results[0].newCommitID) ··· 46 48 assert.EqualValues(t, "957a993", results[5].oldCommitID) 47 49 assert.EqualValues(t, "a87ba5f", results[5].newCommitID) 48 50 49 - assert.EqualValues(t, "refs/pull/27/merge", results[6].refName.String()) 51 + assert.EqualValues(t, "refs/pull/26595/head", results[6].refName.String()) 50 52 assert.EqualValues(t, gitShortEmptySha, results[6].oldCommitID) 51 53 assert.EqualValues(t, "", results[6].newCommitID) 52 54 53 - assert.EqualValues(t, "refs/pull/516/head", results[7].refName.String()) 55 + assert.EqualValues(t, "refs/pull/26595/merge", results[7].refName.String()) 54 56 assert.EqualValues(t, gitShortEmptySha, results[7].oldCommitID) 55 57 assert.EqualValues(t, "", results[7].newCommitID) 58 + 59 + assert.EqualValues(t, "refs/pull/25873/head", results[8].refName.String()) 60 + assert.EqualValues(t, "e0639e38fb", results[8].oldCommitID) 61 + assert.EqualValues(t, "6db2410489", results[8].newCommitID) 62 + 63 + assert.EqualValues(t, "refs/pull/25873/merge", results[9].refName.String()) 64 + assert.EqualValues(t, "1c97ebc746", results[9].oldCommitID) 65 + assert.EqualValues(t, "976d27d52f", results[9].newCommitID) 56 66 }
+78
services/packages/package_update.go
··· 1 + // Copyright 2025 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package packages 5 + 6 + import ( 7 + "context" 8 + "fmt" 9 + 10 + org_model "code.gitea.io/gitea/models/organization" 11 + packages_model "code.gitea.io/gitea/models/packages" 12 + access_model "code.gitea.io/gitea/models/perm/access" 13 + repo_model "code.gitea.io/gitea/models/repo" 14 + "code.gitea.io/gitea/models/unit" 15 + user_model "code.gitea.io/gitea/models/user" 16 + "code.gitea.io/gitea/modules/util" 17 + ) 18 + 19 + func LinkToRepository(ctx context.Context, pkg *packages_model.Package, repo *repo_model.Repository, doer *user_model.User) error { 20 + if pkg.OwnerID != repo.OwnerID { 21 + return util.ErrPermissionDenied 22 + } 23 + if pkg.RepoID > 0 { 24 + return util.ErrInvalidArgument 25 + } 26 + 27 + perms, err := access_model.GetUserRepoPermission(ctx, repo, doer) 28 + if err != nil { 29 + return fmt.Errorf("error getting permissions for user %d on repository %d: %w", doer.ID, repo.ID, err) 30 + } 31 + if !perms.CanWrite(unit.TypePackages) { 32 + return util.ErrPermissionDenied 33 + } 34 + 35 + if err := packages_model.SetRepositoryLink(ctx, pkg.ID, repo.ID); err != nil { 36 + return fmt.Errorf("error while linking package '%v' to repo '%v' : %w", pkg.Name, repo.FullName(), err) 37 + } 38 + return nil 39 + } 40 + 41 + func UnlinkFromRepository(ctx context.Context, pkg *packages_model.Package, doer *user_model.User) error { 42 + if pkg.RepoID == 0 { 43 + return util.ErrInvalidArgument 44 + } 45 + 46 + repo, err := repo_model.GetRepositoryByID(ctx, pkg.RepoID) 47 + if err != nil { 48 + return fmt.Errorf("error getting repository %d: %w", pkg.RepoID, err) 49 + } 50 + 51 + perms, err := access_model.GetUserRepoPermission(ctx, repo, doer) 52 + if err != nil { 53 + return fmt.Errorf("error getting permissions for user %d on repository %d: %w", doer.ID, repo.ID, err) 54 + } 55 + if !perms.CanWrite(unit.TypePackages) { 56 + return util.ErrPermissionDenied 57 + } 58 + 59 + user, err := user_model.GetUserByID(ctx, pkg.OwnerID) 60 + if err != nil { 61 + return err 62 + } 63 + if !doer.IsAdmin { 64 + if !user.IsOrganization() { 65 + if doer.ID != pkg.OwnerID { 66 + return fmt.Errorf("no permission to unlink package '%v' from its repository, or packages are disabled", pkg.Name) 67 + } 68 + } else { 69 + isOrgAdmin, err := org_model.OrgFromUser(user).IsOrgAdmin(ctx, doer.ID) 70 + if err != nil { 71 + return err 72 + } else if !isOrgAdmin { 73 + return fmt.Errorf("no permission to unlink package '%v' from its repository, or packages are disabled", pkg.Name) 74 + } 75 + } 76 + } 77 + return packages_model.UnlinkRepository(ctx, pkg.ID) 78 + }
+11
services/repository/delete.go
··· 16 16 git_model "code.gitea.io/gitea/models/git" 17 17 issues_model "code.gitea.io/gitea/models/issues" 18 18 "code.gitea.io/gitea/models/organization" 19 + packages_model "code.gitea.io/gitea/models/packages" 19 20 access_model "code.gitea.io/gitea/models/perm/access" 20 21 project_model "code.gitea.io/gitea/models/project" 21 22 repo_model "code.gitea.io/gitea/models/repo" ··· 28 29 "code.gitea.io/gitea/modules/log" 29 30 "code.gitea.io/gitea/modules/setting" 30 31 "code.gitea.io/gitea/modules/storage" 32 + federation_service "code.gitea.io/gitea/services/federation" 31 33 32 34 "xorm.io/builder" 33 35 ) ··· 286 288 } 287 289 288 290 if _, err := sess.Where("repo_id=?", repo.ID).Delete(new(repo_model.Attachment)); err != nil { 291 + return err 292 + } 293 + 294 + if err := federation_service.DeleteFollowingRepos(ctx, repo.ID); err != nil { 295 + return err 296 + } 297 + 298 + // unlink packages linked to this repository 299 + if err = packages_model.UnlinkRepositoryFromAllPackages(ctx, repoID); err != nil { 289 300 return err 290 301 } 291 302
+1 -11
services/repository/repository.go
··· 12 12 "code.gitea.io/gitea/models/git" 13 13 issues_model "code.gitea.io/gitea/models/issues" 14 14 "code.gitea.io/gitea/models/organization" 15 - packages_model "code.gitea.io/gitea/models/packages" 16 15 repo_model "code.gitea.io/gitea/models/repo" 17 16 system_model "code.gitea.io/gitea/models/system" 18 17 "code.gitea.io/gitea/models/unit" ··· 22 21 repo_module "code.gitea.io/gitea/modules/repository" 23 22 "code.gitea.io/gitea/modules/setting" 24 23 "code.gitea.io/gitea/modules/structs" 25 - federation_service "code.gitea.io/gitea/services/federation" 26 24 notify_service "code.gitea.io/gitea/services/notify" 27 25 pull_service "code.gitea.io/gitea/services/pull" 28 26 ) ··· 64 62 notify_service.DeleteRepository(ctx, doer, repo) 65 63 } 66 64 67 - if err := DeleteRepositoryDirectly(ctx, doer, repo.ID); err != nil { 68 - return err 69 - } 70 - 71 - if err := federation_service.DeleteFollowingRepos(ctx, repo.ID); err != nil { 72 - return err 73 - } 74 - 75 - return packages_model.UnlinkRepositoryFromAllPackages(ctx, repo.ID) 65 + return DeleteRepositoryDirectly(ctx, doer, repo.ID) 76 66 } 77 67 78 68 // PushCreateRepo creates a repository when a new repository is pushed to an appropriate namespace
+4 -2
templates/base/head_navbar.tmpl
··· 194 194 {{else}} 195 195 {{if .ShowRegistrationButton}} 196 196 <a class="item{{if .PageIsSignUp}} active{{end}}" href="{{AppSubUrl}}/user/sign_up"> 197 - {{svg "octicon-person"}} {{ctx.Locale.Tr "register"}} 197 + {{svg "octicon-person"}} 198 + <span class="tw-ml-1">{{ctx.Locale.Tr "register"}}</span> 198 199 </a> 199 200 {{end}} 200 201 <a class="item{{if .PageIsSignIn}} active{{end}}" rel="nofollow" href="{{AppSubUrl}}/user/login{{if not .PageIsSignIn}}?redirect_to={{.CurrentURL}}{{end}}"> 201 - {{svg "octicon-sign-in"}} {{ctx.Locale.Tr "sign_in"}} 202 + {{svg "octicon-sign-in"}} 203 + <span class="tw-ml-1">{{ctx.Locale.Tr "sign_in"}}</span> 202 204 </a> 203 205 {{end}} 204 206 </div><!-- end full right menu -->
+87
templates/swagger/v1_json.tmpl
··· 4159 4159 } 4160 4160 } 4161 4161 }, 4162 + "/packages/{owner}/{type}/{name}/-/link/{repo_name}": { 4163 + "post": { 4164 + "tags": [ 4165 + "package" 4166 + ], 4167 + "summary": "Link a package to a repository", 4168 + "operationId": "linkPackage", 4169 + "parameters": [ 4170 + { 4171 + "type": "string", 4172 + "description": "owner of the package", 4173 + "name": "owner", 4174 + "in": "path", 4175 + "required": true 4176 + }, 4177 + { 4178 + "type": "string", 4179 + "description": "type of the package", 4180 + "name": "type", 4181 + "in": "path", 4182 + "required": true 4183 + }, 4184 + { 4185 + "type": "string", 4186 + "description": "name of the package", 4187 + "name": "name", 4188 + "in": "path", 4189 + "required": true 4190 + }, 4191 + { 4192 + "type": "string", 4193 + "description": "name of the repository to link.", 4194 + "name": "repo_name", 4195 + "in": "path", 4196 + "required": true 4197 + } 4198 + ], 4199 + "responses": { 4200 + "201": { 4201 + "$ref": "#/responses/empty" 4202 + }, 4203 + "404": { 4204 + "$ref": "#/responses/notFound" 4205 + } 4206 + } 4207 + } 4208 + }, 4209 + "/packages/{owner}/{type}/{name}/-/unlink": { 4210 + "post": { 4211 + "tags": [ 4212 + "package" 4213 + ], 4214 + "summary": "Unlink a package from a repository", 4215 + "operationId": "unlinkPackage", 4216 + "parameters": [ 4217 + { 4218 + "type": "string", 4219 + "description": "owner of the package", 4220 + "name": "owner", 4221 + "in": "path", 4222 + "required": true 4223 + }, 4224 + { 4225 + "type": "string", 4226 + "description": "type of the package", 4227 + "name": "type", 4228 + "in": "path", 4229 + "required": true 4230 + }, 4231 + { 4232 + "type": "string", 4233 + "description": "name of the package", 4234 + "name": "name", 4235 + "in": "path", 4236 + "required": true 4237 + } 4238 + ], 4239 + "responses": { 4240 + "201": { 4241 + "$ref": "#/responses/empty" 4242 + }, 4243 + "404": { 4244 + "$ref": "#/responses/notFound" 4245 + } 4246 + } 4247 + } 4248 + }, 4162 4249 "/packages/{owner}/{type}/{name}/{version}": { 4163 4250 "get": { 4164 4251 "produces": [
+10 -1
templates/user/settings/keys_ssh.tmpl
··· 78 78 <input readonly="" value="{{$.TokenToSign}}"> 79 79 <div class="help"> 80 80 <p>{{ctx.Locale.Tr "settings.ssh_token_help"}}</p> 81 - <p><code>{{printf "echo -n '%s' | ssh-keygen -Y sign -n gitea -f /path_to_PrivateKey_or_RelatedPublicKey" $.TokenToSign}}</code></p> 81 + <p><code>echo -n '{{$.TokenToSign}}' | ssh-keygen -Y sign -n gitea -f /path_to_PrivateKey_or_RelatedPublicKey</code></p> 82 + <details> 83 + <summary>Windows PowerShell</summary> 84 + <p><code>cmd /c "&lt;NUL set /p=`"{{$.TokenToSign}}`"| ssh-keygen -Y sign -n gitea -f /path_to_PrivateKey_or_RelatedPublicKey"</code></p> 85 + </details> 86 + <br> 87 + <details> 88 + <summary>Windows CMD</summary> 89 + <p><code>set /p={{$.TokenToSign}}| ssh-keygen -Y sign -n gitea -f /path_to_PrivateKey_or_RelatedPublicKey</code></p> 90 + </details> 82 91 </div> 83 92 <br> 84 93 </div>
+30 -8
tests/integration/api_packages_test.go
··· 16 16 "code.gitea.io/gitea/models/db" 17 17 packages_model "code.gitea.io/gitea/models/packages" 18 18 container_model "code.gitea.io/gitea/models/packages/container" 19 + unit_model "code.gitea.io/gitea/models/unit" 19 20 "code.gitea.io/gitea/models/unittest" 20 21 user_model "code.gitea.io/gitea/models/user" 21 22 "code.gitea.io/gitea/modules/setting" ··· 35 36 user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) 36 37 session := loginUser(t, user.Name) 37 38 tokenReadPackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage) 38 - tokenDeletePackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWritePackage) 39 + tokenWritePackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWritePackage) 39 40 40 41 packageName := "test-package" 41 42 packageVersion := "1.0.3" ··· 99 100 DecodeJSON(t, resp, &ap1) 100 101 assert.Nil(t, ap1.Repository) 101 102 103 + // create a repository 104 + repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{unit_model.TypeCode}, nil, nil) 105 + defer f() 106 + 102 107 // link to public repository 103 - require.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 1)) 108 + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/-/link/%s", user.Name, packageName, repo.Name)).AddTokenAuth(tokenWritePackage) 109 + MakeRequest(t, req, http.StatusCreated) 104 110 105 111 req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). 106 112 AddTokenAuth(tokenReadPackage) ··· 109 115 var ap2 *api.Package 110 116 DecodeJSON(t, resp, &ap2) 111 117 assert.NotNil(t, ap2.Repository) 112 - assert.EqualValues(t, 1, ap2.Repository.ID) 118 + assert.EqualValues(t, repo.ID, ap2.Repository.ID) 113 119 114 - // link to private repository 115 - require.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 2)) 120 + // link to repository without write access, should fail 121 + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/-/link/%s", user.Name, packageName, "repo3")).AddTokenAuth(tokenWritePackage) 122 + MakeRequest(t, req, http.StatusNotFound) 123 + 124 + // remove link 125 + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/-/unlink", user.Name, packageName)).AddTokenAuth(tokenWritePackage) 126 + MakeRequest(t, req, http.StatusNoContent) 116 127 117 128 req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). 118 129 AddTokenAuth(tokenReadPackage) ··· 122 133 DecodeJSON(t, resp, &ap3) 123 134 assert.Nil(t, ap3.Repository) 124 135 125 - require.NoError(t, packages_model.UnlinkRepositoryFromAllPackages(db.DefaultContext, 2)) 136 + // force link to a repository the currently logged-in user doesn't have access to 137 + privateRepoID := int64(6) 138 + require.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, privateRepoID)) 139 + 140 + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)).AddTokenAuth(tokenReadPackage) 141 + resp = MakeRequest(t, req, http.StatusOK) 142 + 143 + var ap4 *api.Package 144 + DecodeJSON(t, resp, &ap4) 145 + assert.Nil(t, ap4.Repository) 146 + 147 + require.NoError(t, packages_model.UnlinkRepositoryFromAllPackages(db.DefaultContext, privateRepoID)) 126 148 }) 127 149 }) 128 150 ··· 153 175 defer tests.PrintCurrentTest(t)() 154 176 155 177 req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/dummy/%s/%s", user.Name, packageName, packageVersion)). 156 - AddTokenAuth(tokenDeletePackage) 178 + AddTokenAuth(tokenWritePackage) 157 179 MakeRequest(t, req, http.StatusNotFound) 158 180 159 181 req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). 160 - AddTokenAuth(tokenDeletePackage) 182 + AddTokenAuth(tokenWritePackage) 161 183 MakeRequest(t, req, http.StatusNoContent) 162 184 }) 163 185 }
+6 -6
web_src/js/components/ActionRunStatus.vue
··· 29 29 </script> 30 30 <template> 31 31 <span class="tw-flex tw-items-center" :data-tooltip-content="localeStatus ?? status" v-if="status"> 32 - <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/> 33 - <SvgIcon name="octicon-skip" class="text grey" :size="size" :class-name="className" v-else-if="status === 'skipped'"/> 34 - <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'cancelled'"/> 35 - <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'waiting'"/> 36 - <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'blocked'"/> 37 - <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class-name="'job-status-rotate ' + className" v-else-if="status === 'running'"/> 32 + <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class="className" v-if="status === 'success'"/> 33 + <SvgIcon name="octicon-skip" class="text grey" :size="size" :class="className" v-else-if="status === 'skipped'"/> 34 + <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class="className" v-else-if="status === 'cancelled'"/> 35 + <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class="className" v-else-if="status === 'waiting'"/> 36 + <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/> 37 + <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'job-status-rotate ' + className" v-else-if="status === 'running'"/> 38 38 <SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown --> 39 39 </span> 40 40 </template>
+10 -10
web_src/js/components/DashboardRepoList.vue
··· 359 359 otherwise if the "input" handles click event for intermediate status, it breaks the internal state--> 360 360 <input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxArchivedFilterProps"> 361 361 <label> 362 - <svg-icon name="octicon-archive" :size="16" class-name="tw-mr-1"/> 362 + <svg-icon name="octicon-archive" :size="16" class="tw-mr-1"/> 363 363 {{ textShowArchived }} 364 364 </label> 365 365 </div> ··· 368 368 <div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle"> 369 369 <input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxPrivateFilterProps"> 370 370 <label> 371 - <svg-icon name="octicon-lock" :size="16" class-name="tw-mr-1"/> 371 + <svg-icon name="octicon-lock" :size="16" class="tw-mr-1"/> 372 372 {{ textShowPrivate }} 373 373 </label> 374 374 </div> ··· 405 405 <ul class="repo-owner-name-list"> 406 406 <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id"> 407 407 <a class="repo-list-link muted" :href="repo.link"> 408 - <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/> 408 + <svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/> 409 409 <div class="text truncate">{{ repo.full_name }}</div> 410 410 <div v-if="repo.archived"> 411 411 <svg-icon name="octicon-archive" :size="16"/> ··· 413 413 </a> 414 414 <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status" :href="repo.latest_commit_status.TargetURL" :data-tooltip-content="repo.locale_latest_commit_status"> 415 415 <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl --> 416 - <svg-icon :name="statusIcon(repo.latest_commit_status.State)" :class-name="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status.State)" :size="16"/> 416 + <svg-icon :name="statusIcon(repo.latest_commit_status.State)" :class="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status.State)" :size="16"/> 417 417 </a> 418 418 </li> 419 419 </ul> ··· 424 424 class="item navigation tw-py-1" :class="{'disabled': page === 1}" 425 425 @click="changePage(1)" :title="textFirstPage" 426 426 > 427 - <svg-icon name="gitea-double-chevron-left" :size="16" class-name="tw-mr-1"/> 427 + <svg-icon name="gitea-double-chevron-left" :size="16" class="tw-mr-1"/> 428 428 </a> 429 429 <a 430 430 class="item navigation tw-py-1" :class="{'disabled': page === 1}" 431 431 @click="changePage(page - 1)" :title="textPreviousPage" 432 432 > 433 - <svg-icon name="octicon-chevron-left" :size="16" clsas-name="tw-mr-1"/> 433 + <svg-icon name="octicon-chevron-left" :size="16" class="tw-mr-1"/> 434 434 </a> 435 435 <a class="active item tw-py-1">{{ page }}</a> 436 436 <a 437 437 class="item navigation" :class="{'disabled': page === finalPage}" 438 438 @click="changePage(page + 1)" :title="textNextPage" 439 439 > 440 - <svg-icon name="octicon-chevron-right" :size="16" class-name="tw-ml-1"/> 440 + <svg-icon name="octicon-chevron-right" :size="16" class="tw-ml-1"/> 441 441 </a> 442 442 <a 443 443 class="item navigation tw-py-1" :class="{'disabled': page === finalPage}" 444 444 @click="changePage(finalPage)" :title="textLastPage" 445 445 > 446 - <svg-icon name="gitea-double-chevron-right" :size="16" class-name="tw-ml-1"/> 446 + <svg-icon name="gitea-double-chevron-right" :size="16" class="tw-ml-1"/> 447 447 </a> 448 448 </div> 449 449 </div> ··· 454 454 <ul class="repo-owner-name-list"> 455 455 <li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name"> 456 456 <a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)"> 457 - <svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/> 457 + <svg-icon name="octicon-organization" :size="16" class="repo-list-icon"/> 458 458 <div class="text truncate">{{ org.name }}</div> 459 459 <div><!-- div to prevent underline of label on hover --> 460 460 <span class="ui tiny basic label" v-if="org.org_visibility !== 'public'"> ··· 464 464 </a> 465 465 <div class="text light grey tw-flex tw-items-center tw-ml-2"> 466 466 {{ org.num_repos }} 467 - <svg-icon name="octicon-repo" :size="16" class-name="tw-ml-1 tw-mt-0.5"/> 467 + <svg-icon name="octicon-repo" :size="16" class="tw-ml-1 tw-mt-0.5"/> 468 468 </div> 469 469 </li> 470 470 </ul>
+3 -3
web_src/js/components/RepoBranchTagSelector.vue
··· 256 256 <strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ refNameText }}</strong> 257 257 </template> 258 258 </span> 259 - <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/> 259 + <svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/> 260 260 </button> 261 261 <div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak> 262 262 <div class="ui icon search input"> ··· 265 265 </div> 266 266 <div v-if="showBranchesInDropdown" class="branch-tag-tab"> 267 267 <a class="branch-tag-item muted" :class="{active: mode === 'branches'}" href="#" @click="handleTabSwitch('branches')"> 268 - <svg-icon name="octicon-git-branch" :size="16" class-name="tw-mr-1"/>{{ textBranches }} 268 + <svg-icon name="octicon-git-branch" :size="16" class="tw-mr-1"/>{{ textBranches }} 269 269 </a> 270 270 <a v-if="!noTag" class="branch-tag-item muted" :class="{active: mode === 'tags'}" href="#" @click="handleTabSwitch('tags')"> 271 - <svg-icon name="octicon-tag" :size="16" class-name="tw-mr-1"/>{{ textTags }} 271 + <svg-icon name="octicon-tag" :size="16" class="tw-mr-1"/>{{ textTags }} 272 272 </a> 273 273 </div> 274 274 <div class="branch-tag-divider"/>
+1 -10
web_src/js/svg.js
··· 192 192 props: { 193 193 name: {type: String, required: true}, 194 194 size: {type: Number, default: 16}, 195 - className: {type: String, default: ''}, 196 195 symbolId: {type: String}, 197 196 }, 198 197 render() { ··· 207 206 attrs[`^width`] = this.size; 208 207 attrs[`^height`] = this.size; 209 208 210 - // make the <SvgIcon class="foo" class-name="bar"> classes work together 211 - const classes = []; 212 - for (const cls of svgOuter.classList) { 213 - classes.push(cls); 214 - } 215 - // TODO: drop the `className/class-name` prop in the future, only use "class" prop 216 - if (this.className) { 217 - classes.push(...this.className.split(/\s+/).filter(Boolean)); 218 - } 209 + const classes = Array.from(svgOuter.classList); 219 210 if (this.symbolId) { 220 211 classes.push('tw-hidden', 'svg-symbol-container'); 221 212 svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`;
+1 -2
web_src/js/svg.test.js
··· 16 16 17 17 test('SvgIcon', () => { 18 18 const root = document.createElement('div'); 19 - createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base', className: 'extra'})}).mount(root); 19 + createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base'})}).mount(root); 20 20 const node = root.firstChild; 21 21 expect(node.nodeName).toEqual('svg'); 22 22 expect(node.getAttribute('width')).toEqual('24'); 23 23 expect(node.getAttribute('height')).toEqual('24'); 24 24 expect(node.classList.contains('octicon-link')).toBeTruthy(); 25 25 expect(node.classList.contains('base')).toBeTruthy(); 26 - expect(node.classList.contains('extra')).toBeTruthy(); 27 26 });