From 0387195abb82080b4c488966960f25a3e8c6fe66 Mon Sep 17 00:00:00 2001 From: Chai-Shi Date: Tue, 31 Dec 2024 12:22:09 +0800 Subject: [PATCH 01/55] [Feature] Private README.md for organization (#32872) Implemented #29503 --------- Co-authored-by: Ben Chang Co-authored-by: wxiaoguang --- modules/gitrepo/gitrepo.go | 17 ++-- modules/reqctx/datastore.go | 26 ++++- modules/templates/helper.go | 62 ++++++++---- modules/templates/helper_test.go | 55 +++++++++++ modules/util/path.go | 1 + options/locale/locale_en-US.ini | 8 +- routers/api/v1/repo/branch.go | 4 +- routers/api/v1/repo/compare.go | 2 +- routers/api/v1/repo/download.go | 2 +- routers/api/v1/repo/file.go | 2 +- routers/api/v1/repo/repo.go | 2 +- routers/private/internal_repo.go | 2 +- routers/web/org/home.go | 74 +++++++++------ routers/web/org/members.go | 4 +- routers/web/org/teams.go | 4 +- routers/web/shared/user/header.go | 86 +++++++++++------ routers/web/user/profile.go | 7 +- services/context/api.go | 2 +- services/context/base_form.go | 9 +- services/context/repo.go | 4 +- templates/org/home.tmpl | 27 +++++- templates/org/menu.tmpl | 4 +- templates/repo/create.tmpl | 9 +- templates/user/overview/header.tmpl | 2 +- tests/integration/org_profile_test.go | 132 ++++++++++++++++++++++++++ web_src/css/form.css | 44 ++++----- web_src/js/features/repo-new.ts | 42 +++++--- 27 files changed, 484 insertions(+), 149 deletions(-) create mode 100644 tests/integration/org_profile_test.go diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go index 831b9d7bb7858..540b7244892a7 100644 --- a/modules/gitrepo/gitrepo.go +++ b/modules/gitrepo/gitrepo.go @@ -43,19 +43,20 @@ type contextKey struct { } // RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it +// The caller must call "defer gitRepo.Close()" func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) { - ds := reqctx.GetRequestDataStore(ctx) - if ds != nil { - gitRepo, err := RepositoryFromRequestContextOrOpen(ctx, ds, repo) + reqCtx := reqctx.FromContext(ctx) + if reqCtx != nil { + gitRepo, err := RepositoryFromRequestContextOrOpen(reqCtx, repo) return gitRepo, util.NopCloser{}, err } gitRepo, err := OpenRepository(ctx, repo) return gitRepo, gitRepo, err } -// RepositoryFromRequestContextOrOpen opens the repository at the given relative path in the provided request context -// The repo will be automatically closed when the request context is done -func RepositoryFromRequestContextOrOpen(ctx context.Context, ds reqctx.RequestDataStore, repo Repository) (*git.Repository, error) { +// RepositoryFromRequestContextOrOpen opens the repository at the given relative path in the provided request context. +// Caller shouldn't close the git repo manually, the git repo will be automatically closed when the request context is done. +func RepositoryFromRequestContextOrOpen(ctx reqctx.RequestContext, repo Repository) (*git.Repository, error) { ck := contextKey{repoPath: repoPath(repo)} if gitRepo, ok := ctx.Value(ck).(*git.Repository); ok { return gitRepo, nil @@ -64,7 +65,7 @@ func RepositoryFromRequestContextOrOpen(ctx context.Context, ds reqctx.RequestDa if err != nil { return nil, err } - ds.AddCloser(gitRepo) - ds.SetContextValue(ck, gitRepo) + ctx.AddCloser(gitRepo) + ctx.SetContextValue(ck, gitRepo) return gitRepo, nil } diff --git a/modules/reqctx/datastore.go b/modules/reqctx/datastore.go index 66361a45874cf..94232450f33ca 100644 --- a/modules/reqctx/datastore.go +++ b/modules/reqctx/datastore.go @@ -88,6 +88,21 @@ func (r *requestDataStore) cleanUp() { } } +type RequestContext interface { + context.Context + RequestDataStore +} + +func FromContext(ctx context.Context) RequestContext { + // here we must use the current ctx and the underlying store + // the current ctx guarantees that the ctx deadline/cancellation/values are respected + // the underlying store guarantees that the request-specific data is available + if store := GetRequestDataStore(ctx); store != nil { + return &requestContext{Context: ctx, RequestDataStore: store} + } + return nil +} + func GetRequestDataStore(ctx context.Context) RequestDataStore { if req, ok := ctx.Value(RequestDataStoreKey).(*requestDataStore); ok { return req @@ -97,11 +112,11 @@ func GetRequestDataStore(ctx context.Context) RequestDataStore { type requestContext struct { context.Context - dataStore *requestDataStore + RequestDataStore } func (c *requestContext) Value(key any) any { - if v := c.dataStore.GetContextValue(key); v != nil { + if v := c.GetContextValue(key); v != nil { return v } return c.Context.Value(key) @@ -109,9 +124,10 @@ func (c *requestContext) Value(key any) any { func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Context, finished func()) { ctx, _, processFinished := process.GetManager().AddTypedContext(parentCtx, profDesc, process.RequestProcessType, true) - reqCtx := &requestContext{Context: ctx, dataStore: &requestDataStore{values: make(map[any]any)}} + store := &requestDataStore{values: make(map[any]any)} + reqCtx := &requestContext{Context: ctx, RequestDataStore: store} return reqCtx, func() { - reqCtx.dataStore.cleanUp() + store.cleanUp() processFinished() } } @@ -119,5 +135,5 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co // NewRequestContextForTest creates a new RequestContext for testing purposes // It doesn't add the context to the process manager, nor do cleanup func NewRequestContextForTest(parentCtx context.Context) context.Context { - return &requestContext{Context: parentCtx, dataStore: &requestDataStore{values: make(map[any]any)}} + return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}} } diff --git a/modules/templates/helper.go b/modules/templates/helper.go index b673450f5a9d8..7529cadca4d51 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -264,22 +264,42 @@ func userThemeName(user *user_model.User) string { return setting.UI.DefaultTheme } +func isQueryParamEmpty(v any) bool { + return v == nil || v == false || v == 0 || v == int64(0) || v == "" +} + // QueryBuild builds a query string from a list of key-value pairs. -// It omits the nil and empty strings, but it doesn't omit other zero values, -// because the zero value of number types may have a meaning. +// It omits the nil, false, zero int/int64 and empty string values, +// because they are default empty values for "ctx.FormXxx" calls. +// If 0 or false need to be included, use string values: "0" and "false". +// Build rules: +// * Even parameters: always build as query string: a=b&c=d +// * Odd parameters: +// * * {"/anything", param-pairs...} => "/?param-paris" +// * * {"anything?old-params", new-param-pairs...} => "anything?old-params&new-param-paris" +// * * Otherwise: {"old¶ms", new-param-pairs...} => "old¶ms&new-param-paris" +// * * Other behaviors are undefined yet. func QueryBuild(a ...any) template.URL { - var s string + var reqPath, s string + hasTrailingSep := false if len(a)%2 == 1 { if v, ok := a[0].(string); ok { - if v == "" || (v[0] != '?' && v[0] != '&') { - panic("QueryBuild: invalid argument") - } s = v } else if v, ok := a[0].(template.URL); ok { s = string(v) } else { panic("QueryBuild: invalid argument") } + hasTrailingSep = s != "&" && strings.HasSuffix(s, "&") + if strings.HasPrefix(s, "/") || strings.Contains(s, "?") { + if s1, s2, ok := strings.Cut(s, "?"); ok { + reqPath = s1 + "?" + s = s2 + } else { + reqPath += s + "?" + s = "" + } + } } for i := len(a) % 2; i < len(a); i += 2 { k, ok := a[i].(string) @@ -290,19 +310,16 @@ func QueryBuild(a ...any) template.URL { if va, ok := a[i+1].(string); ok { v = va } else if a[i+1] != nil { - v = fmt.Sprint(a[i+1]) + if !isQueryParamEmpty(a[i+1]) { + v = fmt.Sprint(a[i+1]) + } } // pos1 to pos2 is the "k=v&" part, "&" is optional pos1 := strings.Index(s, "&"+k+"=") if pos1 != -1 { pos1++ - } else { - pos1 = strings.Index(s, "?"+k+"=") - if pos1 != -1 { - pos1++ - } else if strings.HasPrefix(s, k+"=") { - pos1 = 0 - } + } else if strings.HasPrefix(s, k+"=") { + pos1 = 0 } pos2 := len(s) if pos1 == -1 { @@ -315,7 +332,7 @@ func QueryBuild(a ...any) template.URL { } if v != "" { sep := "" - hasPrefixSep := pos1 == 0 || (pos1 <= len(s) && (s[pos1-1] == '?' || s[pos1-1] == '&')) + hasPrefixSep := pos1 == 0 || (pos1 <= len(s) && s[pos1-1] == '&') if !hasPrefixSep { sep = "&" } @@ -324,9 +341,22 @@ func QueryBuild(a ...any) template.URL { s = s[:pos1] + s[pos2:] } } - if s != "" && s != "&" && s[len(s)-1] == '&' { + if s != "" && s[len(s)-1] == '&' && !hasTrailingSep { s = s[:len(s)-1] } + if reqPath != "" { + if s == "" { + s = reqPath + if s != "?" { + s = s[:len(s)-1] + } + } else { + if s[0] == '&' { + s = s[1:] + } + s = reqPath + s + } + } return template.URL(s) } diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go index a530d484bc4ee..e35e8a28f86ed 100644 --- a/modules/templates/helper_test.go +++ b/modules/templates/helper_test.go @@ -118,3 +118,58 @@ func TestTemplateEscape(t *testing.T) { assert.Equal(t, `<>`, actual) }) } + +func TestQueryBuild(t *testing.T) { + t.Run("construct", func(t *testing.T) { + assert.Equal(t, "", string(QueryBuild())) + assert.Equal(t, "", string(QueryBuild("a", nil, "b", false, "c", 0, "d", ""))) + assert.Equal(t, "a=1&b=true", string(QueryBuild("a", 1, "b", "true"))) + + // path with query parameters + assert.Equal(t, "/?k=1", string(QueryBuild("/", "k", 1))) + assert.Equal(t, "/", string(QueryBuild("/?k=a", "k", 0))) + + // no path but question mark with query parameters + assert.Equal(t, "?k=1", string(QueryBuild("?", "k", 1))) + assert.Equal(t, "?", string(QueryBuild("?", "k", 0))) + assert.Equal(t, "path?k=1", string(QueryBuild("path?", "k", 1))) + assert.Equal(t, "path", string(QueryBuild("path?", "k", 0))) + + // only query parameters + assert.Equal(t, "&k=1", string(QueryBuild("&", "k", 1))) + assert.Equal(t, "", string(QueryBuild("&", "k", 0))) + assert.Equal(t, "", string(QueryBuild("&k=a", "k", 0))) + assert.Equal(t, "", string(QueryBuild("k=a&", "k", 0))) + assert.Equal(t, "a=1&b=2", string(QueryBuild("a=1", "b", 2))) + assert.Equal(t, "&a=1&b=2", string(QueryBuild("&a=1", "b", 2))) + assert.Equal(t, "a=1&b=2&", string(QueryBuild("a=1&", "b", 2))) + }) + + t.Run("replace", func(t *testing.T) { + assert.Equal(t, "a=1&c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "a", 1))) + assert.Equal(t, "a=b&c=1&e=f", string(QueryBuild("a=b&c=d&e=f", "c", 1))) + assert.Equal(t, "a=b&c=d&e=1", string(QueryBuild("a=b&c=d&e=f", "e", 1))) + assert.Equal(t, "a=b&c=d&e=f&k=1", string(QueryBuild("a=b&c=d&e=f", "k", 1))) + }) + + t.Run("replace-&", func(t *testing.T) { + assert.Equal(t, "&a=1&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "a", 1))) + assert.Equal(t, "&a=b&c=1&e=f", string(QueryBuild("&a=b&c=d&e=f", "c", 1))) + assert.Equal(t, "&a=b&c=d&e=1", string(QueryBuild("&a=b&c=d&e=f", "e", 1))) + assert.Equal(t, "&a=b&c=d&e=f&k=1", string(QueryBuild("&a=b&c=d&e=f", "k", 1))) + }) + + t.Run("delete", func(t *testing.T) { + assert.Equal(t, "c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "a", ""))) + assert.Equal(t, "a=b&e=f", string(QueryBuild("a=b&c=d&e=f", "c", ""))) + assert.Equal(t, "a=b&c=d", string(QueryBuild("a=b&c=d&e=f", "e", ""))) + assert.Equal(t, "a=b&c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "k", ""))) + }) + + t.Run("delete-&", func(t *testing.T) { + assert.Equal(t, "&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "a", ""))) + assert.Equal(t, "&a=b&e=f", string(QueryBuild("&a=b&c=d&e=f", "c", ""))) + assert.Equal(t, "&a=b&c=d", string(QueryBuild("&a=b&c=d&e=f", "e", ""))) + assert.Equal(t, "&a=b&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "k", ""))) + }) +} diff --git a/modules/util/path.go b/modules/util/path.go index 1272f5af2ee84..d4594947c9e9a 100644 --- a/modules/util/path.go +++ b/modules/util/path.go @@ -203,6 +203,7 @@ func statDir(dirPath, recPath string, includeDir, isDirOnly, followSymlinks bool // // Slice does not include given path itself. // If subdirectories is enabled, they will have suffix '/'. +// FIXME: it doesn't like dot-files, for example: "owner/.profile.git" func StatDir(rootPath string, includeDir ...bool) ([]string, error) { if isDir, err := IsDir(rootPath); err != nil { return nil, err diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 16d894aa266d5..ef66e9ce45ec4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1015,7 +1015,9 @@ new_repo_helper = A repository contains all project files, including revision hi owner = Owner owner_helper = Some organizations may not show up in the dropdown due to a maximum repository count limit. repo_name = Repository Name -repo_name_helper = Good repository names use short, memorable and unique keywords. +repo_name_profile_public_hint= .profile is a special repository that you can use to add README.md to your public organization profile, visible to anyone. Make sure it’s public and initialize it with a README in the profile directory to get started. +repo_name_profile_private_hint = .profile-private is a special repository that you can use to add a README.md to your organization member profile, visible only to organization members. Make sure it’s private and initialize it with a README in the profile directory to get started. +repo_name_helper = Good repository names use short, memorable and unique keywords. A repository named '.profile' or '.profile-private' could be used to add a README.md for the user/organization profile. repo_size = Repository Size template = Template template_select = Select a template. @@ -2862,6 +2864,10 @@ teams.invite.title = You have been invited to join team %s in o teams.invite.by = Invited by %s teams.invite.description = Please click the button below to join the team. +view_as_role = View as: %s +view_as_public_hint = You are viewing the README a public user. +view_as_member_hint = You are viewing the README a member of this organization. + [admin] maintenance = Maintenance dashboard = Dashboard diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index 67dfda39a8138..2fcdd0205875a 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -729,7 +729,7 @@ func CreateBranchProtection(ctx *context.APIContext) { } else { if !isPlainRule { if ctx.Repo.GitRepo == nil { - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return @@ -1057,7 +1057,7 @@ func EditBranchProtection(ctx *context.APIContext) { } else { if !isPlainRule { if ctx.Repo.GitRepo == nil { - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return diff --git a/routers/api/v1/repo/compare.go b/routers/api/v1/repo/compare.go index 87b890cb62b33..a1813a8a76540 100644 --- a/routers/api/v1/repo/compare.go +++ b/routers/api/v1/repo/compare.go @@ -45,7 +45,7 @@ func CompareDiff(ctx *context.APIContext) { if ctx.Repo.GitRepo == nil { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return diff --git a/routers/api/v1/repo/download.go b/routers/api/v1/repo/download.go index eb967772edfa7..a8a23c4a8d425 100644 --- a/routers/api/v1/repo/download.go +++ b/routers/api/v1/repo/download.go @@ -29,7 +29,7 @@ func DownloadArchive(ctx *context.APIContext) { if ctx.Repo.GitRepo == nil { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 8a4f78a3d770f..1ad55d225b826 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -282,7 +282,7 @@ func GetArchive(ctx *context.APIContext) { if ctx.Repo.GitRepo == nil { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index a192e241b7be0..ce09e7fc0f9bd 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -726,7 +726,7 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err if ctx.Repo.GitRepo == nil && !repo.IsEmpty { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, repo) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo) if err != nil { ctx.Error(http.StatusInternalServerError, "Unable to OpenRepository", err) return err diff --git a/routers/private/internal_repo.go b/routers/private/internal_repo.go index 8a53e1ed23723..e111d6689e0ab 100644 --- a/routers/private/internal_repo.go +++ b/routers/private/internal_repo.go @@ -27,7 +27,7 @@ func RepoAssignment(ctx *gitea_context.PrivateContext) { return } - gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, repo) + gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo) if err != nil { log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) ctx.JSON(http.StatusInternalServerError, private.Response{ diff --git a/routers/web/org/home.go b/routers/web/org/home.go index deeb18ae7c2a1..277adb60ca0e7 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" @@ -21,9 +22,7 @@ import ( "code.gitea.io/gitea/services/context" ) -const ( - tplOrgHome templates.TplName = "org/home" -) +const tplOrgHome templates.TplName = "org/home" // Home show organization home page func Home(ctx *context.Context) { @@ -110,15 +109,19 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 - if !prepareOrgProfileReadme(ctx, viewRepositories) { - ctx.Data["PageIsViewRepositories"] = true + prepareResult, err := shared_user.PrepareOrgHeader(ctx) + if err != nil { + ctx.ServerError("PrepareOrgHeader", err) + return } - var ( - repos []*repo_model.Repository - count int64 - ) - repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + // if no profile readme, it still means "view repositories" + isViewOverview := !viewRepositories && prepareOrgProfileReadme(ctx, prepareResult) + ctx.Data["PageIsViewRepositories"] = !isViewOverview + ctx.Data["PageIsViewOverview"] = isViewOverview + ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: setting.UI.User.RepoPagingNum, Page: page, @@ -151,28 +154,45 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.HTML(http.StatusOK, tplOrgHome) } -func prepareOrgProfileReadme(ctx *context.Context, viewRepositories bool) bool { - profileDbRepo, profileGitRepo, profileReadme, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) - defer profileClose() - ctx.Data["HasProfileReadme"] = profileReadme != nil - - if profileGitRepo == nil || profileReadme == nil || viewRepositories { - return false - } +func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.PrepareOrgHeaderResult) bool { + viewAs := ctx.FormString("view_as", util.Iif(ctx.Org.IsMember, "member", "public")) + viewAsMember := viewAs == "member" - if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { - log.Error("failed to GetBlobContent: %v", err) + var profileRepo *repo_model.Repository + var readmeBlob *git.Blob + if viewAsMember { + if prepareResult.ProfilePrivateReadmeBlob != nil { + profileRepo, readmeBlob = prepareResult.ProfilePrivateRepo, prepareResult.ProfilePrivateReadmeBlob + } else { + profileRepo, readmeBlob = prepareResult.ProfilePublicRepo, prepareResult.ProfilePublicReadmeBlob + viewAsMember = false + } } else { - rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{ - CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), - }) - if profileContent, err := markdown.RenderString(rctx, bytes); err != nil { - log.Error("failed to RenderString: %v", err) + if prepareResult.ProfilePublicReadmeBlob != nil { + profileRepo, readmeBlob = prepareResult.ProfilePublicRepo, prepareResult.ProfilePublicReadmeBlob } else { - ctx.Data["ProfileReadme"] = profileContent + profileRepo, readmeBlob = prepareResult.ProfilePrivateRepo, prepareResult.ProfilePrivateReadmeBlob + viewAsMember = true } } + if readmeBlob == nil { + return false + } + + readmeBytes, err := readmeBlob.GetBlobContent(setting.UI.MaxDisplayFileSize) + if err != nil { + log.Error("failed to GetBlobContent for profile %q (view as %q) readme: %v", profileRepo.FullName(), viewAs, err) + return false + } - ctx.Data["PageIsViewOverview"] = true + rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{ + CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)), + }) + ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes) + if err != nil { + log.Error("failed to GetBlobContent for profile %q (view as %q) readme: %v", profileRepo.FullName(), viewAs, err) + return false + } + ctx.Data["IsViewingOrgAsMember"] = viewAsMember return true } diff --git a/routers/web/org/members.go b/routers/web/org/members.go index 5a134caecb0a2..1665a123025ed 100644 --- a/routers/web/org/members.go +++ b/routers/web/org/members.go @@ -54,9 +54,9 @@ func Members(ctx *context.Context) { return } - err = shared_user.RenderOrgHeader(ctx) + _, err = shared_user.PrepareOrgHeader(ctx) if err != nil { - ctx.ServerError("RenderOrgHeader", err) + ctx.ServerError("PrepareOrgHeader", err) return } diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 0137f2cc961f2..26031029d662f 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -58,9 +58,9 @@ func Teams(ctx *context.Context) { } ctx.Data["Teams"] = ctx.Org.Teams - err := shared_user.RenderOrgHeader(ctx) + _, err := shared_user.PrepareOrgHeader(ctx) if err != nil { - ctx.ServerError("RenderOrgHeader", err) + ctx.ServerError("PrepareOrgHeader", err) return } diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index d388d2b5d9a6c..62b146c7f3200 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" ) @@ -102,37 +103,46 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { } } -func FindUserProfileReadme(ctx *context.Context, doer *user_model.User) (profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadmeBlob *git.Blob, profileClose func()) { - profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ".profile") - if err == nil { - perm, err := access_model.GetUserRepoPermission(ctx, profileDbRepo, doer) - if err == nil && !profileDbRepo.IsEmpty && perm.CanRead(unit.TypeCode) { - if profileGitRepo, err = gitrepo.OpenRepository(ctx, profileDbRepo); err != nil { - log.Error("FindUserProfileReadme failed to OpenRepository: %v", err) - } else { - if commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch); err != nil { - log.Error("FindUserProfileReadme failed to GetBranchCommit: %v", err) - } else { - profileReadmeBlob, _ = commit.GetBlobByPath("README.md") - } - } +func FindOwnerProfileReadme(ctx *context.Context, doer *user_model.User, optProfileRepoName ...string) (profileDbRepo *repo_model.Repository, profileReadmeBlob *git.Blob) { + profileRepoName := util.OptionalArg(optProfileRepoName, RepoNameProfile) + profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, profileRepoName) + if err != nil { + if !repo_model.IsErrRepoNotExist(err) { + log.Error("FindOwnerProfileReadme failed to GetRepositoryByName: %v", err) } - } else if !repo_model.IsErrRepoNotExist(err) { - log.Error("FindUserProfileReadme failed to GetRepositoryByName: %v", err) + return nil, nil } - return profileDbRepo, profileGitRepo, profileReadmeBlob, func() { - if profileGitRepo != nil { - _ = profileGitRepo.Close() - } + + perm, err := access_model.GetUserRepoPermission(ctx, profileDbRepo, doer) + if err != nil { + log.Error("FindOwnerProfileReadme failed to GetRepositoryByName: %v", err) + return nil, nil + } + if profileDbRepo.IsEmpty || !perm.CanRead(unit.TypeCode) { + return nil, nil } + + profileGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, profileDbRepo) + if err != nil { + log.Error("FindOwnerProfileReadme failed to OpenRepository: %v", err) + return nil, nil + } + + commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch) + if err != nil { + log.Error("FindOwnerProfileReadme failed to GetBranchCommit: %v", err) + return nil, nil + } + + profileReadmeBlob, _ = commit.GetBlobByPath("README.md") // no need to handle this error + return profileDbRepo, profileReadmeBlob } func RenderUserHeader(ctx *context.Context) { prepareContextForCommonProfile(ctx) - _, _, profileReadmeBlob, profileClose := FindUserProfileReadme(ctx, ctx.Doer) - defer profileClose() - ctx.Data["HasProfileReadme"] = profileReadmeBlob != nil + _, profileReadmeBlob := FindOwnerProfileReadme(ctx, ctx.Doer) + ctx.Data["HasUserProfileReadme"] = profileReadmeBlob != nil } func LoadHeaderCount(ctx *context.Context) error { @@ -169,14 +179,28 @@ func LoadHeaderCount(ctx *context.Context) error { return nil } -func RenderOrgHeader(ctx *context.Context) error { - if err := LoadHeaderCount(ctx); err != nil { - return err - } +const ( + RepoNameProfilePrivate = ".profile-private" + RepoNameProfile = ".profile" +) - _, _, profileReadmeBlob, profileClose := FindUserProfileReadme(ctx, ctx.Doer) - defer profileClose() - ctx.Data["HasProfileReadme"] = profileReadmeBlob != nil +type PrepareOrgHeaderResult struct { + ProfilePublicRepo *repo_model.Repository + ProfilePublicReadmeBlob *git.Blob + ProfilePrivateRepo *repo_model.Repository + ProfilePrivateReadmeBlob *git.Blob + HasOrgProfileReadme bool +} - return nil +func PrepareOrgHeader(ctx *context.Context) (result *PrepareOrgHeaderResult, err error) { + if err = LoadHeaderCount(ctx); err != nil { + return nil, err + } + + result = &PrepareOrgHeaderResult{} + result.ProfilePublicRepo, result.ProfilePublicReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer) + result.ProfilePrivateRepo, result.ProfilePrivateReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer, RepoNameProfilePrivate) + result.HasOrgProfileReadme = result.ProfilePublicReadmeBlob != nil || result.ProfilePrivateReadmeBlob != nil + ctx.Data["HasOrgProfileReadme"] = result.HasOrgProfileReadme // many pages need it to show the "overview" tab + return result, nil } diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 9c014bffdb79b..006ffdcf7e095 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -74,8 +74,7 @@ func userProfile(ctx *context.Context) { ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) } - profileDbRepo, _ /*profileGitRepo*/, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) - defer profileClose() + profileDbRepo, profileReadmeBlob := shared_user.FindOwnerProfileReadme(ctx, ctx.Doer) showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) prepareUserProfileTabData(ctx, showPrivate, profileDbRepo, profileReadmeBlob) @@ -96,7 +95,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb } } ctx.Data["TabName"] = tab - ctx.Data["HasProfileReadme"] = profileReadme != nil + ctx.Data["HasUserProfileReadme"] = profileReadme != nil page := ctx.FormInt("page") if page <= 0 { @@ -254,7 +253,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb if profileContent, err := markdown.RenderString(rctx, bytes); err != nil { log.Error("failed to RenderString: %v", err) } else { - ctx.Data["ProfileReadme"] = profileContent + ctx.Data["ProfileReadmeContent"] = profileContent } } case "organizations": diff --git a/services/context/api.go b/services/context/api.go index 7b604c5ea15de..bda705cb48304 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -274,7 +274,7 @@ func ReferencesGitRepo(allowEmpty ...bool) func(ctx *APIContext) { // For API calls. if ctx.Repo.GitRepo == nil { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Open Repository %v failed", ctx.Repo.Repository.FullName()), err) return diff --git a/services/context/base_form.go b/services/context/base_form.go index ddf9734f5777d..5b8cae9e998b8 100644 --- a/services/context/base_form.go +++ b/services/context/base_form.go @@ -8,11 +8,16 @@ import ( "strings" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/util" ) // FormString returns the first value matching the provided key in the form as a string -func (b *Base) FormString(key string) string { - return b.Req.FormValue(key) +func (b *Base) FormString(key string, def ...string) string { + s := b.Req.FormValue(key) + if s == "" { + s = util.OptionalArg(def) + } + return s } // FormStrings returns a string slice for the provided key from the form diff --git a/services/context/repo.go b/services/context/repo.go index b537a050362c5..2a473f4a5419e 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -622,7 +622,7 @@ func RepoAssignment(ctx *Context) { ctx.Repo.GitRepo = nil } - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, repo) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo) if err != nil { if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "no such file or directory") { log.Error("Repository %-v has a broken repository on the file system: %s Error: %v", ctx.Repo.Repository, ctx.Repo.Repository.RepoPath(), err) @@ -881,7 +881,7 @@ func RepoRefByType(detectRefType RepoRefType, opts ...RepoRefByTypeOptions) func ) if ctx.Repo.GitRepo == nil { - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.ServerError(fmt.Sprintf("Open Repository %v failed", ctx.Repo.Repository.FullName()), err) return diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 4851b6997967b..db750692bff18 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -5,8 +5,8 @@
- {{if .ProfileReadme}} -
{{.ProfileReadme}}
+ {{if .ProfileReadmeContent}} +
{{.ProfileReadmeContent}}
{{end}} {{template "shared/repo_search" .}} {{template "explore/repo_list" .}} @@ -24,6 +24,29 @@
{{end}} + + {{if and .ShowMemberAndTeamTab .ShowOrgProfileReadmeSelector}} +
+ +
+ {{if .IsViewingOrgAsMember}}{{ctx.Locale.Tr "org.view_as_member_hint"}}{{else}}{{ctx.Locale.Tr "org.view_as_public_hint"}}{{end}} +
+
+ {{end}} + {{if .NumMembers}}

{{ctx.Locale.Tr "org.members"}} diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 29238f8d6bb9a..4a8aee68a7d37 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -1,12 +1,12 @@
- {{if .HasProfileReadme}} + {{if .HasOrgProfileReadme}} {{svg "octicon-info"}} {{ctx.Locale.Tr "user.overview"}} {{end}} - + {{svg "octicon-repo"}} {{ctx.Locale.Tr "user.repositories"}} {{if .RepoCount}}
{{.RepoCount}}
diff --git a/templates/repo/create.tmpl b/templates/repo/create.tmpl index 2e1de244ea19c..78eb2f704a210 100644 --- a/templates/repo/create.tmpl +++ b/templates/repo/create.tmpl @@ -1,8 +1,8 @@ {{template "base/head" .}} -
+
-
+ {{.CsrfTokenHtml}}

{{ctx.Locale.Tr "new_repo"}} @@ -44,8 +44,11 @@
- {{ctx.Locale.Tr "repo.repo_name_helper"}} + {{ctx.Locale.Tr "repo.repo_name_helper"}} + {{ctx.Locale.Tr "repo.repo_name_profile_public_hint"}} + {{ctx.Locale.Tr "repo.repo_name_profile_private_hint"}}
+
diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl index 275c4e295e447..f4664c704dcc8 100644 --- a/templates/user/overview/header.tmpl +++ b/templates/user/overview/header.tmpl @@ -1,6 +1,6 @@
- {{if and .HasProfileReadme .ContextUser.IsIndividual}} + {{if and .HasUserProfileReadme .ContextUser.IsIndividual}} {{svg "octicon-info"}} {{ctx.Locale.Tr "user.overview"}} diff --git a/tests/integration/org_profile_test.go b/tests/integration/org_profile_test.go new file mode 100644 index 0000000000000..73cafd85c2b32 --- /dev/null +++ b/tests/integration/org_profile_test.go @@ -0,0 +1,132 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/shared/user" + + "github.com/stretchr/testify/assert" +) + +func getCreateProfileReadmeFileOptions(content string) api.CreateFileOptions { + contentEncoded := base64.StdEncoding.EncodeToString([]byte(content)) + return api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "main", + NewBranchName: "main", + Message: "create the profile README.md", + Dates: api.CommitDateOptions{ + Author: time.Unix(946684810, 0), + Committer: time.Unix(978307190, 0), + }, + }, + ContentBase64: contentEncoded, + } +} + +func createTestProfile(t *testing.T, orgName, profileRepoName, readmeContent string) { + isPrivate := profileRepoName == user.RepoNameProfilePrivate + + ctx := NewAPITestContext(t, "user1", profileRepoName, auth_model.AccessTokenScopeAll) + session := loginUser(t, "user1") + tokenAdmin := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) + + // create repo + doAPICreateOrganizationRepository(ctx, orgName, &api.CreateRepoOption{Name: profileRepoName, Private: isPrivate})(t) + + // create readme + createFileOptions := getCreateProfileReadmeFileOptions(readmeContent) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", orgName, profileRepoName, "README.md"), &createFileOptions). + AddTokenAuth(tokenAdmin) + MakeRequest(t, req, http.StatusCreated) +} + +func TestOrgProfile(t *testing.T) { + onGiteaRun(t, testOrgProfile) +} + +func testOrgProfile(t *testing.T, u *url.URL) { + const contentPublicReadme = "Public Readme Content" + const contentPrivateReadme = "Private Readme Content" + // HTML: "#org-home-view-as-dropdown" (indicate whether the view as dropdown menu is present) + + // PART 1: Test Both Private and Public + createTestProfile(t, "org3", user.RepoNameProfile, contentPublicReadme) + createTestProfile(t, "org3", user.RepoNameProfilePrivate, contentPrivateReadme) + + // Anonymous User + req := NewRequest(t, "GET", "org3") + resp := MakeRequest(t, req, http.StatusOK) + bodyString := util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + // Logged in but not member + session := loginUser(t, "user24") + req = NewRequest(t, "GET", "org3") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + // Site Admin + session = loginUser(t, "user1") + req = NewRequest(t, "GET", "/org3") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPrivateReadme) // as an org member, default to show the private profile + assert.Contains(t, bodyString, `id="org-home-view-as-dropdown"`) + + req = NewRequest(t, "GET", "/org3?view_as=member") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPrivateReadme) + assert.Contains(t, bodyString, `id="org-home-view-as-dropdown"`) + + req = NewRequest(t, "GET", "/org3?view_as=public") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.Contains(t, bodyString, `id="org-home-view-as-dropdown"`) + + // PART 2: Each org has either one of private pr public profile + createTestProfile(t, "org41", user.RepoNameProfile, contentPublicReadme) + createTestProfile(t, "org42", user.RepoNameProfilePrivate, contentPrivateReadme) + + // Anonymous User + req = NewRequest(t, "GET", "/org41") + resp = MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + req = NewRequest(t, "GET", "/org42") + resp = MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.NotContains(t, bodyString, contentPrivateReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + // Site Admin + req = NewRequest(t, "GET", "/org41") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + req = NewRequest(t, "GET", "/org42") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPrivateReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) +} diff --git a/web_src/css/form.css b/web_src/css/form.css index 5dd5e05bec7f0..a92ba354b41a4 100644 --- a/web_src/css/form.css +++ b/web_src/css/form.css @@ -325,50 +325,50 @@ textarea:focus, margin: 0; } -.repository.new.repo form, +.repository.new-repo form, .repository.new.migrate form, .repository.new.fork form { margin: auto; } -.repository.new.repo form .ui.message, +.repository.new-repo form .ui.message, .repository.new.migrate form .ui.message, .repository.new.fork form .ui.message { text-align: center; } @media (min-width: 768px) { - .repository.new.repo form, + .repository.new-repo form, .repository.new.migrate form, .repository.new.fork form { width: 800px !important; } - .repository.new.repo form .header, + .repository.new-repo form .header, .repository.new.migrate form .header, .repository.new.fork form .header { padding-left: 280px !important; } - .repository.new.repo form .inline.field > label, + .repository.new-repo form .inline.field > label, .repository.new.migrate form .inline.field > label, .repository.new.fork form .inline.field > label { text-align: right; width: 250px !important; word-wrap: break-word; } - .repository.new.repo form .help, + .repository.new-repo form .help, .repository.new.migrate form .help, .repository.new.fork form .help { margin-left: 265px !important; } - .repository.new.repo form .optional .title, + .repository.new-repo form .optional .title, .repository.new.migrate form .optional .title, .repository.new.fork form .optional .title { margin-left: 250px !important; } - .repository.new.repo form .inline.field > input, + .repository.new-repo form .inline.field > input, .repository.new.migrate form .inline.field > input, .repository.new.fork form .inline.field > input, - .repository.new.repo form .inline.field > textarea, + .repository.new-repo form .inline.field > textarea, .repository.new.migrate form .inline.field > textarea, .repository.new.fork form .inline.field > textarea { width: 50%; @@ -376,32 +376,32 @@ textarea:focus, } @media (max-width: 767.98px) { - .repository.new.repo form .optional .title, + .repository.new-repo form .optional .title, .repository.new.migrate form .optional .title, .repository.new.fork form .optional .title { margin-left: 15px; } - .repository.new.repo form .inline.field > label, + .repository.new-repo form .inline.field > label, .repository.new.migrate form .inline.field > label, .repository.new.fork form .inline.field > label { display: block; } } -.repository.new.repo form .dropdown .text, +.repository.new-repo form .dropdown .text, .repository.new.migrate form .dropdown .text, .repository.new.fork form .dropdown .text { margin-right: 0 !important; } -.repository.new.repo form .header, +.repository.new-repo form .header, .repository.new.migrate form .header, .repository.new.fork form .header { padding-left: 0 !important; text-align: center; } -.repository.new.repo form .selection.dropdown, +.repository.new-repo form .selection.dropdown, .repository.new.migrate form .selection.dropdown, .repository.new.fork form .selection.dropdown, .repository.new.fork form .field a { @@ -410,22 +410,22 @@ textarea:focus, } @media (max-width: 767.98px) { - .repository.new.repo form label, + .repository.new-repo form label, .repository.new.migrate form label, .repository.new.fork form label, - .repository.new.repo form .inline.field > input, + .repository.new-repo form .inline.field > input, .repository.new.migrate form .inline.field > input, .repository.new.fork form .inline.field > input, .repository.new.fork form .field a, - .repository.new.repo form .selection.dropdown, + .repository.new-repo form .selection.dropdown, .repository.new.migrate form .selection.dropdown, .repository.new.fork form .selection.dropdown { width: 100% !important; } - .repository.new.repo form .field button, + .repository.new-repo form .field button, .repository.new.migrate form .field button, .repository.new.fork form .field button, - .repository.new.repo form .field a, + .repository.new-repo form .field a, .repository.new.migrate form .field a { margin-bottom: 1em; width: 100%; @@ -433,17 +433,17 @@ textarea:focus, } @media (min-width: 768px) { - .repository.new.repo .ui.form #auto-init { + .repository.new-repo .ui.form #auto-init { margin-left: 265px !important; } } -.repository.new.repo .ui.form .selection.dropdown:not(.owner) { +.repository.new-repo .ui.form .selection.dropdown:not(.owner) { width: 50% !important; } @media (max-width: 767.98px) { - .repository.new.repo .ui.form .selection.dropdown:not(.owner) { + .repository.new-repo .ui.form .selection.dropdown:not(.owner) { width: 100% !important; } } diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts index 436288325ae50..ec44a14ce02ab 100644 --- a/web_src/js/features/repo-new.ts +++ b/web_src/js/features/repo-new.ts @@ -1,14 +1,34 @@ -import $ from 'jquery'; +import {hideElem, showElem} from '../utils/dom.ts'; export function initRepoNew() { - // Repo Creation - if ($('.repository.new.repo').length > 0) { - $('input[name="gitignores"], input[name="license"]').on('change', () => { - const gitignores = $('input[name="gitignores"]').val(); - const license = $('input[name="license"]').val(); - if (gitignores || license) { - document.querySelector('input[name="auto_init"]').checked = true; - } - }); - } + const pageContent = document.querySelector('.page-content.repository.new-repo'); + if (!pageContent) return; + + const form = document.querySelector('.new-repo-form'); + const inputGitIgnores = form.querySelector('input[name="gitignores"]'); + const inputLicense = form.querySelector('input[name="license"]'); + const inputAutoInit = form.querySelector('input[name="auto_init"]'); + const updateUiAutoInit = () => { + inputAutoInit.checked = Boolean(inputGitIgnores.value || inputLicense.value); + }; + form.addEventListener('change', updateUiAutoInit); + updateUiAutoInit(); + + const inputRepoName = form.querySelector('input[name="repo_name"]'); + const inputPrivate = form.querySelector('input[name="private"]'); + const updateUiRepoName = () => { + const helps = form.querySelectorAll(`.help[data-help-for-repo-name]`); + hideElem(helps); + let help = form.querySelector(`.help[data-help-for-repo-name="${CSS.escape(inputRepoName.value)}"]`); + if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`); + showElem(help); + const repoNamePreferPrivate = {'.profile': false, '.profile-private': true}; + const preferPrivate = repoNamePreferPrivate[inputRepoName.value]; + // inputPrivate might be disabled because site admin "force private" + if (preferPrivate !== undefined && !inputPrivate.closest('.disabled, [disabled]')) { + inputPrivate.checked = preferPrivate; + } + }; + inputRepoName.addEventListener('input', updateUiRepoName); + updateUiRepoName(); } From 54bd2205203c26fb55eacc3a987c5dff7f96aa61 Mon Sep 17 00:00:00 2001 From: Kerwin Bryant Date: Tue, 31 Dec 2024 12:49:26 +0800 Subject: [PATCH 02/55] Optimize the installation page (#32994) Co-authored-by: wxiaoguang --- options/locale/locale_en-US.ini | 1 + templates/post-install.tmpl | 25 ++++++------------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ef66e9ce45ec4..4050ed1a15dba 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -244,6 +244,7 @@ license_desc = Go get [install] install = Installation +installing_desc = Installing now, please wait... title = Initial Configuration docker_helper = If you run Gitea inside Docker, please read the documentation before changing any settings. require_db_desc = Gitea requires MySQL, PostgreSQL, MSSQL, SQLite3 or TiDB (MySQL protocol). diff --git a/templates/post-install.tmpl b/templates/post-install.tmpl index fb234008fb4ee..fa10827295466 100644 --- a/templates/post-install.tmpl +++ b/templates/post-install.tmpl @@ -1,23 +1,10 @@ {{template "base/head" .}} -
-
-
-
-
-
-
-
- {{ctx.Locale.Tr -
-
-
- -
-
+
+
From e5c576e92b0fb10077fc3c0c21a101e2e47881f9 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 31 Dec 2024 13:30:52 +0800 Subject: [PATCH 03/55] Refactor maven package registry (#33049) Close #33036 --- models/packages/package.go | 12 +++ modules/web/router_test.go | 5 + routers/api/packages/api.go | 2 - routers/api/packages/maven/maven.go | 76 +++++++++---- tests/integration/api_packages_maven_test.go | 108 ++++++++++++------- 5 files changed, 143 insertions(+), 60 deletions(-) diff --git a/models/packages/package.go b/models/packages/package.go index c12f345f0e272..31e1277a6e37b 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -248,6 +248,18 @@ func GetPackageByID(ctx context.Context, packageID int64) (*Package, error) { return p, nil } +// UpdatePackageNameByID updates the package's name, it is only for internal usage, for example: rename some legacy packages +func UpdatePackageNameByID(ctx context.Context, ownerID int64, packageType Type, packageID int64, name string) error { + var cond builder.Cond = builder.Eq{ + "package.id": packageID, + "package.owner_id": ownerID, + "package.type": packageType, + "package.is_internal": false, + } + _, err := db.GetEngine(ctx).Where(cond).Update(&Package{Name: name, LowerName: strings.ToLower(name)}) + return err +} + // GetPackageByName gets a package by name func GetPackageByName(ctx context.Context, ownerID int64, packageType Type, name string) (*Package, error) { var cond builder.Cond = builder.Eq{ diff --git a/modules/web/router_test.go b/modules/web/router_test.go index bdcf623b951b6..582980a27ae87 100644 --- a/modules/web/router_test.go +++ b/modules/web/router_test.go @@ -183,6 +183,11 @@ func TestRouter(t *testing.T) { pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"}, handlerMark: "match-path", }) + testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1%2fd2/fn", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1%2fd2/fn", "dir": "d1%2fd2", "file": "fn"}, + handlerMark: "match-path", + }) testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{ method: "GET", pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"}, diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 8c06836ff80c2..5b035fbb7114f 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -430,8 +430,6 @@ func CommonRoutes() *web.Router { r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), helm.UploadPackage) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/maven", func() { - // FIXME: this path design is not right. - // It should be `/.../{groupId}/{artifactId}/{version}`, but not `/.../{groupId}-{artifactId}/{version}` r.Put("/*", reqPackageAccess(perm.AccessModeWrite), maven.UploadPackageFile) r.Get("/*", maven.DownloadPackageFile) r.Head("/*", maven.ProvidePackageFileHeader) diff --git a/routers/api/packages/maven/maven.go b/routers/api/packages/maven/maven.go index 9474b17bc79d1..4d04d4d1e9f32 100644 --- a/routers/api/packages/maven/maven.go +++ b/routers/api/packages/maven/maven.go @@ -13,7 +13,7 @@ import ( "errors" "io" "net/http" - "path/filepath" + "path" "regexp" "sort" "strconv" @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" maven_module "code.gitea.io/gitea/modules/packages/maven" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" @@ -44,7 +45,7 @@ const ( var ( errInvalidParameters = errors.New("request parameters are invalid") - illegalCharacters = regexp.MustCompile(`[\\/:"<>|?\*]`) + illegalCharacters = regexp.MustCompile(`[\\/:"<>|?*]`) ) func apiError(ctx *context.Context, status int, obj any) { @@ -85,8 +86,10 @@ func handlePackageFile(ctx *context.Context, serveContent bool) { func serveMavenMetadata(ctx *context.Context, params parameters) { // /com/foo/project/maven-metadata.xml[.md5/.sha1/.sha256/.sha512] - packageName := params.GroupID + "-" + params.ArtifactID - pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, packageName) + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageName()) + if errors.Is(err, util.ErrNotExist) { + pvs, err = packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy()) + } if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -116,10 +119,10 @@ func serveMavenMetadata(ctx *context.Context, params parameters) { latest := pds[len(pds)-1] // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat - lastModifed := latest.Version.CreatedUnix.AsTime().UTC().Format(http.TimeFormat) - ctx.Resp.Header().Set("Last-Modified", lastModifed) + lastModified := latest.Version.CreatedUnix.AsTime().UTC().Format(http.TimeFormat) + ctx.Resp.Header().Set("Last-Modified", lastModified) - ext := strings.ToLower(filepath.Ext(params.Filename)) + ext := strings.ToLower(path.Ext(params.Filename)) if isChecksumExtension(ext) { var hash []byte switch ext { @@ -147,11 +150,12 @@ func serveMavenMetadata(ctx *context.Context, params parameters) { } func servePackageFile(ctx *context.Context, params parameters, serveContent bool) { - packageName := params.GroupID + "-" + params.ArtifactID - - pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, packageName, params.Version) + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageName(), params.Version) + if errors.Is(err, util.ErrNotExist) { + pv, err = packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy(), params.Version) + } if err != nil { - if err == packages_model.ErrPackageNotExist { + if errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusNotFound, err) } else { apiError(ctx, http.StatusInternalServerError, err) @@ -161,14 +165,14 @@ func servePackageFile(ctx *context.Context, params parameters, serveContent bool filename := params.Filename - ext := strings.ToLower(filepath.Ext(filename)) + ext := strings.ToLower(path.Ext(filename)) if isChecksumExtension(ext) { filename = filename[:len(filename)-len(ext)] } pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, filename, packages_model.EmptyFileKey) if err != nil { - if err == packages_model.ErrPackageFileNotExist { + if errors.Is(err, packages_model.ErrPackageFileNotExist) { apiError(ctx, http.StatusNotFound, err) } else { apiError(ctx, http.StatusInternalServerError, err) @@ -238,15 +242,17 @@ func UploadPackageFile(ctx *context.Context) { return } - log.Trace("Parameters: %+v", params) - // Ignore the package index //maven-metadata.xml if params.IsMeta && params.Version == "" { ctx.Status(http.StatusOK) return } - packageName := params.GroupID + "-" + params.ArtifactID + packageName := params.toInternalPackageName() + if ctx.FormBool("use_legacy_package_name") { + // for testing purpose only + packageName = params.toInternalPackageNameLegacy() + } // for the same package, only one upload at a time releaser, err := globallock.Lock(ctx, mavenPkgNameKey(packageName)) @@ -274,13 +280,26 @@ func UploadPackageFile(ctx *context.Context) { Creator: ctx.Doer, } - ext := filepath.Ext(params.Filename) + // old maven package uses "groupId-artifactId" as package name, so we need to update to the new format "groupId:artifactId" + legacyPackage, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy()) + if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { + apiError(ctx, http.StatusInternalServerError, err) + return + } else if legacyPackage != nil { + err = packages_model.UpdatePackageNameByID(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, legacyPackage.ID, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + ext := path.Ext(params.Filename) // Do not upload checksum files but compare the hashes. if isChecksumExtension(ext) { pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvci.Owner.ID, pvci.PackageType, pvci.Name, pvci.Version) if err != nil { - if err == packages_model.ErrPackageNotExist { + if errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusNotFound, err) return } @@ -289,7 +308,7 @@ func UploadPackageFile(ctx *context.Context) { } pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, params.Filename[:len(params.Filename)-len(ext)], packages_model.EmptyFileKey) if err != nil { - if err == packages_model.ErrPackageFileNotExist { + if errors.Is(err, packages_model.ErrPackageFileNotExist) { apiError(ctx, http.StatusNotFound, err) return } @@ -343,7 +362,7 @@ func UploadPackageFile(ctx *context.Context) { if pvci.Metadata != nil { pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvci.Owner.ID, pvci.PackageType, pvci.Name, pvci.Version) - if err != nil && err != packages_model.ErrPackageNotExist { + if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusInternalServerError, err) return } @@ -399,9 +418,26 @@ type parameters struct { IsMeta bool } +func (p *parameters) toInternalPackageName() string { + // there cuold be 2 choices: "/" or ":" + // Maven says: "groupId:artifactId:version" in their document: https://maven.apache.org/pom.html#Maven_Coordinates + // but it would be slightly ugly in URL: "/-/packages/maven/group-id%3Aartifact-id" + return p.GroupID + ":" + p.ArtifactID +} + +func (p *parameters) toInternalPackageNameLegacy() string { + return p.GroupID + "-" + p.ArtifactID +} + func extractPathParameters(ctx *context.Context) (parameters, error) { parts := strings.Split(ctx.PathParam("*"), "/") + // formats: + // * /com/group/id/artifactId/maven-metadata.xml[.md5|.sha1|.sha256|.sha512] + // * /com/group/id/artifactId/version-SNAPSHOT/maven-metadata.xml[.md5|.sha1|.sha256|.sha512] + // * /com/group/id/artifactId/version/any-file + // * /com/group/id/artifactId/version-SNAPSHOT/any-file + p := parameters{ Filename: parts[len(parts)-1], } diff --git a/tests/integration/api_packages_maven_test.go b/tests/integration/api_packages_maven_test.go index e54238858c2ea..486a5af93e17b 100644 --- a/tests/integration/api_packages_maven_test.go +++ b/tests/integration/api_packages_maven_test.go @@ -6,6 +6,7 @@ package integration import ( "fmt" "net/http" + "net/url" "strconv" "strings" "sync" @@ -20,6 +21,7 @@ import ( "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPackageMaven(t *testing.T) { @@ -29,16 +31,14 @@ func TestPackageMaven(t *testing.T) { groupID := "com.gitea" artifactID := "test-project" - packageName := groupID + "-" + artifactID packageVersion := "1.0.1" packageDescription := "Test Description" - root := fmt.Sprintf("/api/packages/%s/maven/%s/%s", user.Name, strings.ReplaceAll(groupID, ".", "/"), artifactID) - filename := fmt.Sprintf("%s-%s.jar", packageName, packageVersion) + root := "/api/packages/user2/maven/com/gitea/test-project" + filename := "any-name.jar" putFile := func(t *testing.T, path, content string, expectedStatus int) { - req := NewRequestWithBody(t, "PUT", root+path, strings.NewReader(content)). - AddBasicAuth(user.Name) + req := NewRequestWithBody(t, "PUT", root+path, strings.NewReader(content)).AddBasicAuth(user.Name) MakeRequest(t, req, expectedStatus) } @@ -56,27 +56,67 @@ func TestPackageMaven(t *testing.T) { putFile(t, "/maven-metadata.xml", "test", http.StatusOK) pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, pvs, 1) pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) - assert.NoError(t, err) + require.NoError(t, err) assert.Nil(t, pd.SemVer) assert.Nil(t, pd.Metadata) - assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, groupID+":"+artifactID, pd.Package.Name) assert.Equal(t, packageVersion, pd.Version.Version) pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, pfs, 1) assert.Equal(t, filename, pfs[0].Name) assert.False(t, pfs[0].IsLead) pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, int64(4), pb.Size) }) + t.Run("UploadLegacy", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + legacyRootLink := "/api/packages/user2/maven/com/gitea/legacy-project" + req := NewRequestWithBody(t, "PUT", legacyRootLink+"/1.0.2/any-file-name?use_legacy_package_name=1", strings.NewReader("test-content")).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + p, err := packages.GetPackageByName(db.DefaultContext, user.ID, packages.TypeMaven, "com.gitea-legacy-project") + require.NoError(t, err) + assert.Equal(t, "com.gitea-legacy-project", p.Name) + + req = NewRequest(t, "HEAD", legacyRootLink+"/1.0.2/any-file-name").AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user2/-/packages/maven/com.gitea-legacy-project/1.0.2") + MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/user2/-/packages/maven/com.gitea:legacy-project/1.0.2") + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/user2/-/packages/maven/com.gitea%3Alegacy-project/1.0.2") + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithBody(t, "PUT", legacyRootLink+"/1.0.3/any-file-name", strings.NewReader("test-content")).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + _, err = packages.GetPackageByName(db.DefaultContext, user.ID, packages.TypeMaven, "com.gitea-legacy-project") + require.ErrorIs(t, err, packages.ErrPackageNotExist) + p, err = packages.GetPackageByName(db.DefaultContext, user.ID, packages.TypeMaven, "com.gitea:legacy-project") + require.NoError(t, err) + assert.Equal(t, "com.gitea:legacy-project", p.Name) + req = NewRequest(t, "HEAD", legacyRootLink+"/1.0.2/any-file-name").AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user2/-/packages/maven/com.gitea-legacy-project/1.0.2") + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/user2/-/packages/maven/com.gitea:legacy-project/1.0.2") + MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/user2/-/packages/maven/com.gitea%3Alegacy-project/1.0.2") + MakeRequest(t, req, http.StatusOK) + + require.NoError(t, packages.DeletePackageByID(db.DefaultContext, p.ID)) + }) + t.Run("UploadExists", func(t *testing.T) { defer tests.PrintCurrentTest(t)() @@ -86,14 +126,12 @@ func TestPackageMaven(t *testing.T) { t.Run("Download", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "HEAD", fmt.Sprintf("%s/%s/%s", root, packageVersion, filename)). - AddBasicAuth(user.Name) + req := NewRequest(t, "HEAD", fmt.Sprintf("%s/%s/%s", root, packageVersion, filename)).AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) checkHeaders(t, resp.Header(), "application/java-archive", 4) - req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", root, packageVersion, filename)). - AddBasicAuth(user.Name) + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", root, packageVersion, filename)).AddBasicAuth(user.Name) resp = MakeRequest(t, req, http.StatusOK) checkHeaders(t, resp.Header(), "application/java-archive", 4) @@ -101,7 +139,7 @@ func TestPackageMaven(t *testing.T) { assert.Equal(t, []byte("test"), resp.Body.Bytes()) pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, pvs, 1) assert.Equal(t, int64(0), pvs[0].DownloadCount) }) @@ -133,26 +171,26 @@ func TestPackageMaven(t *testing.T) { defer tests.PrintCurrentTest(t)() pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, pvs, 1) pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) - assert.NoError(t, err) + require.NoError(t, err) assert.Nil(t, pd.Metadata) putFile(t, fmt.Sprintf("/%s/%s.pom", packageVersion, filename), pomContent, http.StatusCreated) pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, pvs, 1) pd, err = packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) - assert.NoError(t, err) + require.NoError(t, err) assert.IsType(t, &maven.Metadata{}, pd.Metadata) assert.Equal(t, packageDescription, pd.Metadata.(*maven.Metadata).Description) pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, pfs, 2) for _, pf := range pfs { if strings.HasSuffix(pf.Name, ".pom") { @@ -167,14 +205,12 @@ func TestPackageMaven(t *testing.T) { t.Run("DownloadPOM", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "HEAD", fmt.Sprintf("%s/%s/%s.pom", root, packageVersion, filename)). - AddBasicAuth(user.Name) + req := NewRequest(t, "HEAD", fmt.Sprintf("%s/%s/%s.pom", root, packageVersion, filename)).AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) checkHeaders(t, resp.Header(), "text/xml", int64(len(pomContent))) - req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.pom", root, packageVersion, filename)). - AddBasicAuth(user.Name) + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.pom", root, packageVersion, filename)).AddBasicAuth(user.Name) resp = MakeRequest(t, req, http.StatusOK) checkHeaders(t, resp.Header(), "text/xml", int64(len(pomContent))) @@ -182,7 +218,7 @@ func TestPackageMaven(t *testing.T) { assert.Equal(t, []byte(pomContent), resp.Body.Bytes()) pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, pvs, 1) assert.Equal(t, int64(1), pvs[0].DownloadCount) }) @@ -190,8 +226,7 @@ func TestPackageMaven(t *testing.T) { t.Run("DownloadChecksums", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "GET", fmt.Sprintf("%s/1.2.3/%s", root, filename)). - AddBasicAuth(user.Name) + req := NewRequest(t, "GET", fmt.Sprintf("%s/1.2.3/%s", root, filename)).AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusNotFound) for key, checksum := range map[string]string{ @@ -200,8 +235,7 @@ func TestPackageMaven(t *testing.T) { "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "sha512": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", } { - req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.%s", root, packageVersion, filename, key)). - AddBasicAuth(user.Name) + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.%s", root, packageVersion, filename, key)).AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) assert.Equal(t, checksum, resp.Body.String()) @@ -211,8 +245,7 @@ func TestPackageMaven(t *testing.T) { t.Run("DownloadMetadata", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "GET", root+"/maven-metadata.xml"). - AddBasicAuth(user.Name) + req := NewRequest(t, "GET", root+"/maven-metadata.xml").AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) expectedMetadata := `` + "\ncom.giteatest-project1.0.11.0.11.0.1" @@ -227,8 +260,7 @@ func TestPackageMaven(t *testing.T) { "sha256": "3f48322f81c4b2c3bb8649ae1e5c9801476162b520e1c2734ac06b2c06143208", "sha512": "cb075aa2e2ef1a83cdc14dd1e08c505b72d633399b39e73a21f00f0deecb39a3e2c79f157c1163f8a3854828750706e0dec3a0f5e4778e91f8ec2cf351a855f2", } { - req := NewRequest(t, "GET", fmt.Sprintf("%s/maven-metadata.xml.%s", root, key)). - AddBasicAuth(user.Name) + req := NewRequest(t, "GET", fmt.Sprintf("%s/maven-metadata.xml.%s", root, key)).AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) assert.Equal(t, checksum, resp.Body.String()) @@ -245,9 +277,10 @@ func TestPackageMaven(t *testing.T) { }) t.Run("InvalidFile", func(t *testing.T) { - ver := packageVersion + "-invalid" - putFile(t, fmt.Sprintf("/%s/%s", ver, filename), "any invalid content", http.StatusCreated) - req := NewRequestf(t, "GET", "/%s/-/packages/maven/%s-%s/%s", user.Name, groupID, artifactID, ver) + invalidVersion := packageVersion + "-invalid" + putFile(t, fmt.Sprintf("/%s/%s", invalidVersion, filename), "any invalid content", http.StatusCreated) + + req := NewRequestf(t, "GET", "/%s/-/packages/maven/%s/%s", user.Name, url.QueryEscape(groupID+":"+artifactID), invalidVersion) resp := MakeRequest(t, req, http.StatusOK) assert.Contains(t, resp.Body.String(), "No metadata.") assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) @@ -266,8 +299,7 @@ func TestPackageMavenConcurrent(t *testing.T) { root := fmt.Sprintf("/api/packages/%s/maven/%s/%s", user.Name, strings.ReplaceAll(groupID, ".", "/"), artifactID) putFile := func(t *testing.T, path, content string, expectedStatus int) { - req := NewRequestWithBody(t, "PUT", root+path, strings.NewReader(content)). - AddBasicAuth(user.Name) + req := NewRequestWithBody(t, "PUT", root+path, strings.NewReader(content)).AddBasicAuth(user.Name) MakeRequest(t, req, expectedStatus) } From 58c092cfead130f775df6b430d459208c6ce5977 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 31 Dec 2024 14:37:37 +0800 Subject: [PATCH 04/55] Fix locale type (#33059) Follow #32872 --- options/locale/locale_en-US.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4050ed1a15dba..4e9ec275ddced 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1018,7 +1018,7 @@ owner_helper = Some organizations may not show up in the dropdown due to a maxim repo_name = Repository Name repo_name_profile_public_hint= .profile is a special repository that you can use to add README.md to your public organization profile, visible to anyone. Make sure it’s public and initialize it with a README in the profile directory to get started. repo_name_profile_private_hint = .profile-private is a special repository that you can use to add a README.md to your organization member profile, visible only to organization members. Make sure it’s private and initialize it with a README in the profile directory to get started. -repo_name_helper = Good repository names use short, memorable and unique keywords. A repository named '.profile' or '.profile-private' could be used to add a README.md for the user/organization profile. +repo_name_helper = Good repository names use short, memorable and unique keywords. A repository named ".profile" or ".profile-private" could be used to add a README.md for the user/organization profile. repo_size = Repository Size template = Template template_select = Select a template. @@ -2866,8 +2866,8 @@ teams.invite.by = Invited by %s teams.invite.description = Please click the button below to join the team. view_as_role = View as: %s -view_as_public_hint = You are viewing the README a public user. -view_as_member_hint = You are viewing the README a member of this organization. +view_as_public_hint = You are viewing the README as a public user. +view_as_member_hint = You are viewing the README as a member of this organization. [admin] maintenance = Maintenance From a0853e2278642b597fd4164f8cf17108b610f220 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 31 Dec 2024 18:45:05 +0800 Subject: [PATCH 05/55] Fix unittest and repo create bug (#33061) 1. `StatDir` was not right, fix the FIXME 2. Clarify the test cases for `IsUsableRepoName` 3. Fix regression bug in `repo-new.ts` Fix #33060 --- models/db/name.go | 19 +++---- models/repo/repo.go | 11 +++-- models/repo/repo_test.go | 12 +++++ models/unittest/fscopy.go | 6 +-- modules/assetfs/layered.go | 2 +- modules/repository/init.go | 2 +- modules/util/path.go | 87 +++++++++++---------------------- modules/util/path_test.go | 20 ++++++++ web_src/js/features/repo-new.ts | 3 +- 9 files changed, 80 insertions(+), 82 deletions(-) diff --git a/models/db/name.go b/models/db/name.go index 55c9dffb6ab28..5f98edbb28de8 100644 --- a/models/db/name.go +++ b/models/db/name.go @@ -5,20 +5,13 @@ package db import ( "fmt" - "regexp" "strings" "unicode/utf8" "code.gitea.io/gitea/modules/util" ) -var ( - // ErrNameEmpty name is empty error - ErrNameEmpty = util.SilentWrap{Message: "name is empty", Err: util.ErrInvalidArgument} - - // AlphaDashDotPattern characters prohibited in a username (anything except A-Za-z0-9_.-) - AlphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`) -) +var ErrNameEmpty = util.SilentWrap{Message: "name is empty", Err: util.ErrInvalidArgument} // ErrNameReserved represents a "reserved name" error. type ErrNameReserved struct { @@ -82,20 +75,20 @@ func (err ErrNameCharsNotAllowed) Unwrap() error { // IsUsableName checks if name is reserved or pattern of name is not allowed // based on given reserved names and patterns. -// Names are exact match, patterns can be prefix or suffix match with placeholder '*'. -func IsUsableName(names, patterns []string, name string) error { +// Names are exact match, patterns can be a prefix or suffix match with placeholder '*'. +func IsUsableName(reservedNames, reservedPatterns []string, name string) error { name = strings.TrimSpace(strings.ToLower(name)) if utf8.RuneCountInString(name) == 0 { return ErrNameEmpty } - for i := range names { - if name == names[i] { + for i := range reservedNames { + if name == reservedNames[i] { return ErrNameReserved{name} } } - for _, pat := range patterns { + for _, pat := range reservedPatterns { if pat[0] == '*' && strings.HasSuffix(name, pat[1:]) || (pat[len(pat)-1] == '*' && strings.HasPrefix(name, pat[:len(pat)-1])) { return ErrNamePatternNotAllowed{pat} diff --git a/models/repo/repo.go b/models/repo/repo.go index 2d9b9de88d499..5ef4d470c3bf6 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -11,6 +11,7 @@ import ( "net" "net/url" "path/filepath" + "regexp" "strconv" "strings" @@ -60,13 +61,15 @@ func (err ErrRepoIsArchived) Error() string { } var ( - reservedRepoNames = []string{".", "..", "-"} - reservedRepoPatterns = []string{"*.git", "*.wiki", "*.rss", "*.atom"} + validRepoNamePattern = regexp.MustCompile(`[-.\w]+`) + invalidRepoNamePattern = regexp.MustCompile(`[.]{2,}`) + reservedRepoNames = []string{".", "..", "-"} + reservedRepoPatterns = []string{"*.git", "*.wiki", "*.rss", "*.atom"} ) -// IsUsableRepoName returns true when repository is usable +// IsUsableRepoName returns true when name is usable func IsUsableRepoName(name string) error { - if db.AlphaDashDotPattern.MatchString(name) { + if !validRepoNamePattern.MatchString(name) || invalidRepoNamePattern.MatchString(name) { // Note: usually this error is normally caught up earlier in the UI return db.ErrNameCharsNotAllowed{Name: name} } diff --git a/models/repo/repo_test.go b/models/repo/repo_test.go index 6d88d170da377..001f8ecd84819 100644 --- a/models/repo/repo_test.go +++ b/models/repo/repo_test.go @@ -217,3 +217,15 @@ func TestComposeSSHCloneURL(t *testing.T) { setting.SSH.Port = 123 assert.Equal(t, "ssh://git@[::1]:123/user/repo.git", ComposeSSHCloneURL("user", "repo")) } + +func TestIsUsableRepoName(t *testing.T) { + assert.NoError(t, IsUsableRepoName("a")) + assert.NoError(t, IsUsableRepoName("-1_.")) + assert.NoError(t, IsUsableRepoName(".profile")) + + assert.Error(t, IsUsableRepoName("-")) + assert.Error(t, IsUsableRepoName("🌞")) + assert.Error(t, IsUsableRepoName("the..repo")) + assert.Error(t, IsUsableRepoName("foo.wiki")) + assert.Error(t, IsUsableRepoName("foo.git")) +} diff --git a/models/unittest/fscopy.go b/models/unittest/fscopy.go index 4d7ee2151dc29..690089bbc50b1 100644 --- a/models/unittest/fscopy.go +++ b/models/unittest/fscopy.go @@ -67,7 +67,7 @@ func SyncDirs(srcPath, destPath string) error { } // find and delete all untracked files - destFiles, err := util.StatDir(destPath, true) + destFiles, err := util.ListDirRecursively(destPath, &util.ListDirOptions{IncludeDir: true}) if err != nil { return err } @@ -86,13 +86,13 @@ func SyncDirs(srcPath, destPath string) error { } // sync src files to dest - srcFiles, err := util.StatDir(srcPath, true) + srcFiles, err := util.ListDirRecursively(srcPath, &util.ListDirOptions{IncludeDir: true}) if err != nil { return err } for _, srcFile := range srcFiles { destFilePath := filepath.Join(destPath, srcFile) - // util.StatDir appends a slash to the directory name + // util.ListDirRecursively appends a slash to the directory name if strings.HasSuffix(srcFile, "/") { err = os.MkdirAll(destFilePath, os.ModePerm) } else { diff --git a/modules/assetfs/layered.go b/modules/assetfs/layered.go index 9678d23ad675f..4f3811ba2b2fc 100644 --- a/modules/assetfs/layered.go +++ b/modules/assetfs/layered.go @@ -103,7 +103,7 @@ func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) { } func shouldInclude(info fs.FileInfo, fileMode ...bool) bool { - if util.CommonSkip(info.Name()) { + if util.IsCommonHiddenFileName(info.Name()) { return false } if len(fileMode) == 0 { diff --git a/modules/repository/init.go b/modules/repository/init.go index 5f500c5233faa..24602ae090108 100644 --- a/modules/repository/init.go +++ b/modules/repository/init.go @@ -81,7 +81,7 @@ func LoadRepoConfig() error { if isDir, err := util.IsDir(customPath); err != nil { return fmt.Errorf("failed to check custom %s dir: %w", t, err) } else if isDir { - if typeFiles[i].custom, err = util.StatDir(customPath); err != nil { + if typeFiles[i].custom, err = util.ListDirRecursively(customPath, &util.ListDirOptions{SkipCommonHiddenNames: true}); err != nil { return fmt.Errorf("failed to list custom %s files: %w", t, err) } } diff --git a/modules/util/path.go b/modules/util/path.go index d4594947c9e9a..d9f17bd124ba0 100644 --- a/modules/util/path.go +++ b/modules/util/path.go @@ -140,82 +140,51 @@ func IsExist(path string) (bool, error) { return false, err } -func statDir(dirPath, recPath string, includeDir, isDirOnly, followSymlinks bool) ([]string, error) { - dir, err := os.Open(dirPath) +func listDirRecursively(result *[]string, fsDir, recordParentPath string, opts *ListDirOptions) error { + dir, err := os.Open(fsDir) if err != nil { - return nil, err + return err } defer dir.Close() fis, err := dir.Readdir(0) if err != nil { - return nil, err + return err } - statList := make([]string, 0) for _, fi := range fis { - if CommonSkip(fi.Name()) { + if opts.SkipCommonHiddenNames && IsCommonHiddenFileName(fi.Name()) { continue } - - relPath := path.Join(recPath, fi.Name()) - curPath := path.Join(dirPath, fi.Name()) + relPath := path.Join(recordParentPath, fi.Name()) + curPath := filepath.Join(fsDir, fi.Name()) if fi.IsDir() { - if includeDir { - statList = append(statList, relPath+"/") - } - s, err := statDir(curPath, relPath, includeDir, isDirOnly, followSymlinks) - if err != nil { - return nil, err - } - statList = append(statList, s...) - } else if !isDirOnly { - statList = append(statList, relPath) - } else if followSymlinks && fi.Mode()&os.ModeSymlink != 0 { - link, err := os.Readlink(curPath) - if err != nil { - return nil, err - } - - isDir, err := IsDir(link) - if err != nil { - return nil, err + if opts.IncludeDir { + *result = append(*result, relPath+"/") } - if isDir { - if includeDir { - statList = append(statList, relPath+"/") - } - s, err := statDir(curPath, relPath, includeDir, isDirOnly, followSymlinks) - if err != nil { - return nil, err - } - statList = append(statList, s...) + if err = listDirRecursively(result, curPath, relPath, opts); err != nil { + return err } + } else { + *result = append(*result, relPath) } } - return statList, nil + return nil } -// StatDir gathers information of given directory by depth-first. -// It returns slice of file list and includes subdirectories if enabled; -// it returns error and nil slice when error occurs in underlying functions, -// or given path is not a directory or does not exist. -// +type ListDirOptions struct { + IncludeDir bool // subdirectories are also included with suffix slash + SkipCommonHiddenNames bool +} + +// ListDirRecursively gathers information of given directory by depth-first. +// The paths are always in "dir/slash/file" format (not "\\" even in Windows) // Slice does not include given path itself. -// If subdirectories is enabled, they will have suffix '/'. -// FIXME: it doesn't like dot-files, for example: "owner/.profile.git" -func StatDir(rootPath string, includeDir ...bool) ([]string, error) { - if isDir, err := IsDir(rootPath); err != nil { +func ListDirRecursively(rootDir string, opts *ListDirOptions) (res []string, err error) { + if err = listDirRecursively(&res, rootDir, "", opts); err != nil { return nil, err - } else if !isDir { - return nil, errors.New("not a directory or does not exist: " + rootPath) - } - - isIncludeDir := false - if len(includeDir) != 0 { - isIncludeDir = includeDir[0] } - return statDir(rootPath, "", isIncludeDir, false, false) + return res, nil } func isOSWindows() bool { @@ -266,8 +235,8 @@ func HomeDir() (home string, err error) { return home, nil } -// CommonSkip will check a provided name to see if it represents file or directory that should not be watched -func CommonSkip(name string) bool { +// IsCommonHiddenFileName will check a provided name to see if it represents file or directory that should not be watched +func IsCommonHiddenFileName(name string) bool { if name == "" { return true } @@ -276,9 +245,9 @@ func CommonSkip(name string) bool { case '.': return true case 't', 'T': - return name[1:] == "humbs.db" + return name[1:] == "humbs.db" // macOS case 'd', 'D': - return name[1:] == "esktop.ini" + return name[1:] == "esktop.ini" // Windows } return false diff --git a/modules/util/path_test.go b/modules/util/path_test.go index 6a38bf4ace863..79c37e55f7a57 100644 --- a/modules/util/path_test.go +++ b/modules/util/path_test.go @@ -5,10 +5,12 @@ package util import ( "net/url" + "os" "runtime" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFileURLToPath(t *testing.T) { @@ -210,3 +212,21 @@ func TestCleanPath(t *testing.T) { assert.Equal(t, c.expected, FilePathJoinAbs(c.elems[0], c.elems[1:]...), "case: %v", c.elems) } } + +func TestListDirRecursively(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(tmpDir+"/.config", nil, 0o644) + _ = os.Mkdir(tmpDir+"/d1", 0o755) + _ = os.WriteFile(tmpDir+"/d1/f-d1", nil, 0o644) + _ = os.Mkdir(tmpDir+"/d1/s1", 0o755) + _ = os.WriteFile(tmpDir+"/d1/s1/f-d1s1", nil, 0o644) + _ = os.Mkdir(tmpDir+"/d2", 0o755) + + res, err := ListDirRecursively(tmpDir, &ListDirOptions{IncludeDir: true}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{".config", "d1/", "d1/f-d1", "d1/s1/", "d1/s1/f-d1s1", "d2/"}, res) + + res, err = ListDirRecursively(tmpDir, &ListDirOptions{SkipCommonHiddenNames: true}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"d1/f-d1", "d1/s1/f-d1s1"}, res) +} diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts index ec44a14ce02ab..101545735f6a2 100644 --- a/web_src/js/features/repo-new.ts +++ b/web_src/js/features/repo-new.ts @@ -11,7 +11,8 @@ export function initRepoNew() { const updateUiAutoInit = () => { inputAutoInit.checked = Boolean(inputGitIgnores.value || inputLicense.value); }; - form.addEventListener('change', updateUiAutoInit); + inputGitIgnores.addEventListener('change', updateUiAutoInit); + inputLicense.addEventListener('change', updateUiAutoInit); updateUiAutoInit(); const inputRepoName = form.querySelector('input[name="repo_name"]'); From 20c7fba60157067252af49da41b6f8929a5ae31a Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Tue, 31 Dec 2024 03:19:53 -0800 Subject: [PATCH 06/55] Use project's redirect url instead of composing url (#33058) Fix #32992 --------- Co-authored-by: wxiaoguang --- models/project/project.go | 12 ++++++++++-- routers/web/org/projects.go | 6 +++--- routers/web/repo/issue_new.go | 13 ++++++++++--- routers/web/repo/projects.go | 6 +++--- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/models/project/project.go b/models/project/project.go index 87e679e1b73fe..edeb0b474228e 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -126,6 +126,14 @@ func (p *Project) LoadRepo(ctx context.Context) (err error) { return err } +func ProjectLinkForOrg(org *user_model.User, projectID int64) string { //nolint + return fmt.Sprintf("%s/-/projects/%d", org.HomeLink(), projectID) +} + +func ProjectLinkForRepo(repo *repo_model.Repository, projectID int64) string { //nolint + return fmt.Sprintf("%s/projects/%d", repo.Link(), projectID) +} + // Link returns the project's relative URL. func (p *Project) Link(ctx context.Context) string { if p.OwnerID > 0 { @@ -134,7 +142,7 @@ func (p *Project) Link(ctx context.Context) string { log.Error("LoadOwner: %v", err) return "" } - return fmt.Sprintf("%s/-/projects/%d", p.Owner.HomeLink(), p.ID) + return ProjectLinkForOrg(p.Owner, p.ID) } if p.RepoID > 0 { err := p.LoadRepo(ctx) @@ -142,7 +150,7 @@ func (p *Project) Link(ctx context.Context) string { log.Error("LoadRepo: %v", err) return "" } - return fmt.Sprintf("%s/projects/%d", p.Repo.Link(), p.ID) + return ProjectLinkForRepo(p.Repo, p.ID) } return "" } diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index c037d4a7fd0df..32da1b41d16d4 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -211,7 +211,7 @@ func ChangeProjectStatus(ctx *context.Context) { ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) return } - ctx.JSONRedirect(fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), id)) + ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.ContextUser, id)) } // DeleteProject delete a project @@ -261,7 +261,7 @@ func RenderEditProject(ctx *context.Context) { ctx.Data["redirect"] = ctx.FormString("redirect") ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() ctx.Data["card_type"] = p.CardType - ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), p.ID) + ctx.Data["CancelLink"] = project_model.ProjectLinkForOrg(ctx.ContextUser, p.ID) ctx.HTML(http.StatusOK, tplProjectsNew) } @@ -275,7 +275,7 @@ func EditProjectPost(ctx *context.Context) { ctx.Data["PageIsViewProjects"] = true ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) ctx.Data["CardTypes"] = project_model.GetCardConfig() - ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), projectID) + ctx.Data["CancelLink"] = project_model.ProjectLinkForOrg(ctx.ContextUser, projectID) shared_user.RenderUserHeader(ctx) diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index 9a941ce85714b..32daa3e48fabd 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -396,8 +396,15 @@ func NewIssuePost(ctx *context.Context) { log.Trace("Issue created: %d/%d", repo.ID, issue.ID) if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { - ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10)) - } else { - ctx.JSONRedirect(issue.Link()) + project, err := project_model.GetProjectByID(ctx, projectID) + if err == nil { + if project.Type == project_model.TypeOrganization { + ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID)) + } else { + ctx.JSONRedirect(project_model.ProjectLinkForRepo(repo, project.ID)) + } + return + } } + ctx.JSONRedirect(issue.Link()) } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 1800f60eaead6..346132102f6c7 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -181,7 +181,7 @@ func ChangeProjectStatus(ctx *context.Context) { ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) return } - ctx.JSONRedirect(fmt.Sprintf("%s/projects/%d", ctx.Repo.RepoLink, id)) + ctx.JSONRedirect(project_model.ProjectLinkForRepo(ctx.Repo.Repository, id)) } // DeleteProject delete a project @@ -235,7 +235,7 @@ func RenderEditProject(ctx *context.Context) { ctx.Data["content"] = p.Description ctx.Data["card_type"] = p.CardType ctx.Data["redirect"] = ctx.FormString("redirect") - ctx.Data["CancelLink"] = fmt.Sprintf("%s/projects/%d", ctx.Repo.Repository.Link(), p.ID) + ctx.Data["CancelLink"] = project_model.ProjectLinkForRepo(ctx.Repo.Repository, p.ID) ctx.HTML(http.StatusOK, tplProjectsNew) } @@ -249,7 +249,7 @@ func EditProjectPost(ctx *context.Context) { ctx.Data["PageIsEditProjects"] = true ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["CardTypes"] = project_model.GetCardConfig() - ctx.Data["CancelLink"] = fmt.Sprintf("%s/projects/%d", ctx.Repo.Repository.Link(), projectID) + ctx.Data["CancelLink"] = project_model.ProjectLinkForRepo(ctx.Repo.Repository, projectID) if ctx.HasError() { ctx.HTML(http.StatusOK, tplProjectsNew) From 6c89de494a1f6c0cbaf34efe7a18f310c408cd23 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Tue, 31 Dec 2024 20:08:36 +0800 Subject: [PATCH 07/55] feat(action): issue change title notifications (#33050) - Add `IssueChangeTitle` method to handle issue title changes - Add `notifyIssueChangeWithTitleOrContent` method to generalize notification handling for issue title or content changes action file as below: ```yaml name: Semantic Pull Request on: pull_request_target: types: [edited] ``` --------- Signed-off-by: Bo-Yi Wu Co-authored-by: Giteabot Co-authored-by: wxiaoguang --- services/actions/notifier.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/actions/notifier.go b/services/actions/notifier.go index a4ebdf9e888ee..67e33e7cce0a8 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -58,7 +58,15 @@ func (n *actionsNotifier) NewIssue(ctx context.Context, issue *issues_model.Issu // IssueChangeContent notifies change content of issue func (n *actionsNotifier) IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { ctx = withMethod(ctx, "IssueChangeContent") + n.notifyIssueChangeWithTitleOrContent(ctx, doer, issue) +} + +func (n *actionsNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { + ctx = withMethod(ctx, "IssueChangeTitle") + n.notifyIssueChangeWithTitleOrContent(ctx, doer, issue) +} +func (n *actionsNotifier) notifyIssueChangeWithTitleOrContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { var err error if err = issue.LoadRepo(ctx); err != nil { log.Error("LoadRepo: %v", err) From 92a2900a2d3130558d4965c5c220aa726c29a0d5 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Wed, 1 Jan 2025 00:35:43 +0000 Subject: [PATCH 08/55] [skip ci] Updated translations via Crowdin --- options/locale/locale_cs-CZ.ini | 2 +- options/locale/locale_de-DE.ini | 2 +- options/locale/locale_el-GR.ini | 2 +- options/locale/locale_es-ES.ini | 2 +- options/locale/locale_fa-IR.ini | 2 +- options/locale/locale_fi-FI.ini | 2 +- options/locale/locale_fr-FR.ini | 2 +- options/locale/locale_ga-IE.ini | 2 +- options/locale/locale_hu-HU.ini | 2 +- options/locale/locale_id-ID.ini | 2 +- options/locale/locale_is-IS.ini | 1 + options/locale/locale_it-IT.ini | 2 +- options/locale/locale_ja-JP.ini | 2 +- options/locale/locale_ko-KR.ini | 2 +- options/locale/locale_lv-LV.ini | 2 +- options/locale/locale_nl-NL.ini | 2 +- options/locale/locale_pl-PL.ini | 2 +- options/locale/locale_pt-BR.ini | 2 +- options/locale/locale_pt-PT.ini | 2 +- options/locale/locale_ru-RU.ini | 2 +- options/locale/locale_si-LK.ini | 2 +- options/locale/locale_sk-SK.ini | 2 +- options/locale/locale_sv-SE.ini | 2 +- options/locale/locale_tr-TR.ini | 2 +- options/locale/locale_uk-UA.ini | 2 +- options/locale/locale_zh-CN.ini | 2 +- options/locale/locale_zh-HK.ini | 1 + options/locale/locale_zh-TW.ini | 2 +- 28 files changed, 28 insertions(+), 26 deletions(-) diff --git a/options/locale/locale_cs-CZ.ini b/options/locale/locale_cs-CZ.ini index 57b209aaf9755..beeb1dc3b89c2 100644 --- a/options/locale/locale_cs-CZ.ini +++ b/options/locale/locale_cs-CZ.ini @@ -1012,7 +1012,6 @@ new_repo_helper=Repozitář obsahuje všechny projektové soubory, včetně hist owner=Vlastník owner_helper=Některé organizace se nemusejí v seznamu zobrazit kvůli maximálnímu dosaženému počtu repozitářů. repo_name=Název repozitáře -repo_name_helper=Dobrý název repozitáře většinou používá krátká, zapamatovatelná a unikátní klíčová slova. repo_size=Velikost repozitáře template=Šablona template_select=Vyberte šablonu. @@ -2833,6 +2832,7 @@ teams.invite.title=Byli jste pozváni do týmu %s v organizaci teams.invite.by=Pozvání od %s teams.invite.description=Pro připojení k týmu klikněte na tlačítko níže. + [admin] maintenance=Údržba dashboard=Přehled diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index 5f2f16bfdf34c..ce2c43cb568d0 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -1015,7 +1015,6 @@ new_repo_helper=Ein Repository enthält alle Projektdateien, einschließlich des owner=Besitzer owner_helper=Einige Organisationen könnten in der Dropdown-Liste nicht angezeigt werden, da die Anzahl an Repositories begrenzt ist. repo_name=Repository-Name -repo_name_helper=Ein guter Repository-Name besteht normalerweise aus kurzen, unvergesslichen und einzigartigen Schlagwörtern. repo_size=Repository-Größe template=Template template_select=Vorlage auswählen @@ -2861,6 +2860,7 @@ teams.invite.title=Du wurdest eingeladen, dem Team %s in der Or teams.invite.by=Von %s eingeladen teams.invite.description=Bitte klicke auf die folgende Schaltfläche, um dem Team beizutreten. + [admin] maintenance=Wartung dashboard=Dashboard diff --git a/options/locale/locale_el-GR.ini b/options/locale/locale_el-GR.ini index fa9c41d5de049..31e57bbf97819 100644 --- a/options/locale/locale_el-GR.ini +++ b/options/locale/locale_el-GR.ini @@ -908,7 +908,6 @@ new_repo_helper=Ένα αποθετήριο περιέχει όλα τα αρχ owner=Ιδιοκτήτης owner_helper=Ορισμένοι οργανισμοί ενδέχεται να μην εμφανίζονται στο αναπτυσσόμενο μενού λόγω του μέγιστου αριθμού αποθετηρίων. repo_name=Όνομα αποθετηρίου -repo_name_helper=Τα καλά ονόματα αποθετηρίων χρησιμοποιούν σύντομες, αξέχαστες και μοναδικές λέξεις-κλειδιά. repo_size=Μέγεθος Αποθετηρίου template=Πρότυπο template_select=Επιλέξτε πρότυπο. @@ -2593,6 +2592,7 @@ teams.invite.title=Έχετε προσκληθεί να συμμετάσχετε teams.invite.by=Προσκλήθηκε από %s teams.invite.description=Παρακαλώ κάντε κλικ στον παρακάτω σύνδεσμο για συμμετοχή στην ομάδα. + [admin] dashboard=Πίνακας Ελέγχου identity_access=Ταυτότητα & Πρόσβαση diff --git a/options/locale/locale_es-ES.ini b/options/locale/locale_es-ES.ini index d7d1cadd089a6..cdfe1fb2e5848 100644 --- a/options/locale/locale_es-ES.ini +++ b/options/locale/locale_es-ES.ini @@ -898,7 +898,6 @@ visibility.private_tooltip=Visible sólo para los miembros de organizaciones a l owner=Propietario owner_helper=Algunas organizaciones pueden no aparecer en el menú desplegable debido a un límite máximo de recuento de repositorios. repo_name=Nombre del repositorio -repo_name_helper=Un buen nombre de repositorio está compuesto por palabras clave cortas, memorables y únicas. repo_size=Tamaño del repositorio template=Plantilla template_select=Seleccionar una plantilla. @@ -2574,6 +2573,7 @@ teams.invite.title=Has sido invitado a unirte al equipo %s en l teams.invite.by=Invitado por %s teams.invite.description=Por favor, haga clic en el botón de abajo para unirse al equipo. + [admin] dashboard=Panel de control identity_access=Identidad y acceso diff --git a/options/locale/locale_fa-IR.ini b/options/locale/locale_fa-IR.ini index 6dcd35560a64d..4d90cf98762db 100644 --- a/options/locale/locale_fa-IR.ini +++ b/options/locale/locale_fa-IR.ini @@ -704,7 +704,6 @@ visibility.private=خصوصی owner=مالک owner_helper=بخاطر بیشینه تعداد مخزن، ممکن است برخی از سازمان‌ها در لیست کشویی دیده نشود. repo_name=نام مخزن -repo_name_helper=نام خوب مخزن معمولا از کلمات کلیدی کوتاه و به یاد ماندنی و منحصر به فرد تشکیل شده است. repo_size=اندازه مخزن template=قالب / الگو template_select=انتخاب یک قالب/ الگو. @@ -1993,6 +1992,7 @@ teams.all_repositories_read_permission_desc=این تیم دسترسی teams.all_repositories_write_permission_desc=این تیم دسترسی نوشتن مخازن همه را می بخشد: اعضا می توانند مخازن را مشاهده و درج کنند. teams.all_repositories_admin_permission_desc=این تیم دسترسی مدیر به مخازن همه را می بخشد: اعضا می توانند مخازن را بخواند، همکار و مخزن اضافه کنند. + [admin] dashboard=پیشخوان users=حساب کاربران diff --git a/options/locale/locale_fi-FI.ini b/options/locale/locale_fi-FI.ini index b4f3869db41eb..b5fa5c8afcd15 100644 --- a/options/locale/locale_fi-FI.ini +++ b/options/locale/locale_fi-FI.ini @@ -635,7 +635,6 @@ visibility.private=Yksityinen owner=Omistaja owner_helper=Jotkin organisaatiot eivät välttämättä näy pudotusvalikossa, koska repojen maksimimäärää on rajoitettu. repo_name=Repon nimi -repo_name_helper=Hyvä repon nimi on lyhyt, mieleenpainuva ja yksilöllinen. repo_size=Repon koko template=Malli template_select=Valitse malli. @@ -1361,6 +1360,7 @@ teams.repositories=Tiimin repot teams.members.none=Ei jäseniä tässä tiimissä. teams.all_repositories=Kaikki repot + [admin] dashboard=Kojelauta users=Käyttäjätilit diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index b024be4d882bd..743b7662565bd 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -1015,7 +1015,6 @@ new_repo_helper=Un dépôt contient tous les fichiers d’un projet, ainsi que l owner=Propriétaire owner_helper=Certaines organisations peuvent ne pas apparaître dans la liste déroulante en raison d'une limite maximale du nombre de dépôts. repo_name=Nom du dépôt -repo_name_helper=Idéalement, le nom d'un dépôt devrait être court, mémorisable et unique. repo_size=Taille du dépôt template=Modèle template_select=Répliquer un modèle @@ -2861,6 +2860,7 @@ teams.invite.title=Vous avez été invité à rejoindre l'équipe %s%spermessi teams.all_repositories_write_permission_desc=Questo team concede permessi di scrittura accesso a tutte le repository: i membri possono leggere e pushare le repository. teams.all_repositories_admin_permission_desc=Questo team concede a Amministratore l'accesso a tutte le repository: i membri possono leggere, pushare e aggiungere collaboratori alle repository. + [admin] dashboard=Pannello di Controllo users=Account utenti diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini index efcf34806a4cb..89582af89c3ac 100644 --- a/options/locale/locale_ja-JP.ini +++ b/options/locale/locale_ja-JP.ini @@ -1015,7 +1015,6 @@ new_repo_helper=リポジトリには、プロジェクトのすべてのファ owner=オーナー owner_helper=リポジトリ数の上限により、一部の組織はドロップダウンに表示されない場合があります。 repo_name=リポジトリ名 -repo_name_helper=リポジトリ名は、短く、覚えやすく、他と重複しないキーワードを使用しましょう。 repo_size=リポジトリサイズ template=テンプレート template_select=テンプレートを選択してください。 @@ -2853,6 +2852,7 @@ teams.invite.title=あなたは組織 %[2]s 内のチーム %[2]s< teams.invite.by=Uzaicināja %s teams.invite.description=Nospiediet pogu zemāk, lai pievienotos komandai. + [admin] dashboard=Infopanelis self_check=Pašpārbaude diff --git a/options/locale/locale_nl-NL.ini b/options/locale/locale_nl-NL.ini index fe41c4529aa95..8a6dabbceb2df 100644 --- a/options/locale/locale_nl-NL.ini +++ b/options/locale/locale_nl-NL.ini @@ -753,7 +753,6 @@ visibility.private=Privé owner=Eigenaar owner_helper=Sommige organisaties kunnen niet worden weergegeven in de dropdown vanwege een limiet op het maximale aantal repositories. repo_name=Naam van repository -repo_name_helper=Goede repository-namen zijn kort, makkelijk te onthouden en uniek. repo_size=Repositorygrootte template=Sjabloon template_select=Selecteer een sjabloon. @@ -2055,6 +2054,7 @@ teams.all_repositories=Alle repositories teams.all_repositories_helper=Team heeft toegang tot alle repositories. Door dit te selecteren worden alle bestaande repositories aan het team toegevoegd. teams.all_repositories_read_permission_desc=Dit team heeft Lees toegang tot alle repositories: leden kunnen repositories bekijken en klonen. + [admin] dashboard=Overzicht users=Gebruikersacount diff --git a/options/locale/locale_pl-PL.ini b/options/locale/locale_pl-PL.ini index 13c05eebe079b..4d049c83d18e9 100644 --- a/options/locale/locale_pl-PL.ini +++ b/options/locale/locale_pl-PL.ini @@ -711,7 +711,6 @@ visibility.private=Prywatny owner=Właściciel owner_helper=Niektóre organizacje mogą nie pojawiać się w liście ze względu na limit maksymalnej liczby repozytoriów. repo_name=Nazwa repozytorium -repo_name_helper=Dobra nazwa repozytorium jest utworzona z krótkich, łatwych do zapamiętania i unikalnych słów kluczowych. repo_size=Rozmiar repozytorium template=Szablon template_select=Wybierz szablon. @@ -1934,6 +1933,7 @@ teams.all_repositories_read_permission_desc=Ten zespół nadaje uprawnienie Zapisu do wszystkich repozytoriów: jego członkowie mogą odczytywać i przesyłać do repozytoriów. teams.all_repositories_admin_permission_desc=Ten zespół nadaje uprawnienia Administratora do wszystkich repozytoriów: jego członkowie mogą odczytywać, przesyłać oraz dodawać innych współtwórców do repozytoriów. + [admin] dashboard=Pulpit users=Konta użytkownika diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini index 9a8b6aeb62ca5..f0c034a1331f3 100644 --- a/options/locale/locale_pt-BR.ini +++ b/options/locale/locale_pt-BR.ini @@ -908,7 +908,6 @@ new_repo_helper=Um repositório contém todos os arquivos do projeto, inclusive owner=Proprietário owner_helper=Algumas organizações podem não aparecer no menu devido a um limite de contagem dos repositórios. repo_name=Nome do repositório -repo_name_helper=Um bom nome de repositório é composto por palavras curtas, memorizáveis e únicas. repo_size=Tamanho do repositório template=Modelo template_select=Selecione um modelo. @@ -2551,6 +2550,7 @@ teams.invite.title=Você foi convidado para fazer parte da equipe %s%s< teams.invite.by=Convidado(a) por %s teams.invite.description=Clique no botão abaixo para se juntar à equipa. + [admin] maintenance=Manutenção dashboard=Painel de controlo diff --git a/options/locale/locale_ru-RU.ini b/options/locale/locale_ru-RU.ini index dc72dd7621bb0..027a2cb19d3b8 100644 --- a/options/locale/locale_ru-RU.ini +++ b/options/locale/locale_ru-RU.ini @@ -899,7 +899,6 @@ visibility.private_tooltip=Виден только членам организа owner=Владелец owner_helper=Некоторые организации могут не отображаться в раскрывающемся списке из-за максимального ограничения количества репозиториев. repo_name=Название репозитория -repo_name_helper=Лучшие названия репозиториев состоят из коротких, легко запоминаемых и уникальных ключевых слов. repo_size=Размер репозитория template=Шаблон template_select=Выбрать шаблон. @@ -2542,6 +2541,7 @@ teams.invite.title=Вас пригласили присоединиться к teams.invite.by=Приглашен(а) %s teams.invite.description=Нажмите на кнопку ниже, чтобы присоединиться к команде. + [admin] dashboard=Панель identity_access=Идентификация и доступ diff --git a/options/locale/locale_si-LK.ini b/options/locale/locale_si-LK.ini index f722d160eb58c..167ecaf24a315 100644 --- a/options/locale/locale_si-LK.ini +++ b/options/locale/locale_si-LK.ini @@ -693,7 +693,6 @@ visibility.private=පෞද්ගලික owner=හිමිකරු owner_helper=උපරිම නිධි ගණන් සීමාවක් හේතුවෙන් සමහර සංවිධාන පහත වැටීමේ දී පෙන්විය නොහැක. repo_name=කෝෂ්ඨයේ නම -repo_name_helper=හොඳ ගබඩාවක් නම් කෙටි, අමතක නොවන සහ අද්විතීය මූල පද භාවිතා කරයි. repo_size=කෝෂ්ඨයේ ප්‍රමාණය template=සැකිල්ල template_select=අච්චුවක් තෝරන්න. @@ -1955,6 +1954,7 @@ teams.all_repositories_read_permission_desc=මෙම කණ්ඩායම ප teams.all_repositories_write_permission_desc=මෙම කණ්ඩායම ප්රදානය කරයි වෙත ප්රවේශය ලියන්න සියලු ගබඩාවන්ට: සාමාජිකයින්ට කියවීමට සහ ගබඩාවන්ට තල්ලු කළ හැකිය. teams.all_repositories_admin_permission_desc=මෙම කණ්ඩායම ප්රදානය කරයි පරිපාලක වෙත ප්රවේශය සියලු ගබඩාවන්ට: සාමාජිකයින්ට කියවීමට, තල්ලු කිරීමට සහ ගබඩාවන්ට සහයෝගීකයින් එකතු කිරීමට. + [admin] dashboard=උපකරණ පුවරුව users=පරිශීලක ගිණුම් diff --git a/options/locale/locale_sk-SK.ini b/options/locale/locale_sk-SK.ini index 39c13c358efb5..43b190098ff93 100644 --- a/options/locale/locale_sk-SK.ini +++ b/options/locale/locale_sk-SK.ini @@ -817,7 +817,6 @@ visibility.private=Súkromný owner=Vlastník owner_helper=Niektoré organizácie sa nemusia zobraziť v rozbaľovacej ponuke z dôvodu maximálneho limitu počtu repozitárov. repo_name=Názov repozitára -repo_name_helper=Dobrý názov repozitára sa zvyčajne skladá z krátkych, jedinečných a ľahko zapamätateľných kľúčových slov. repo_size=Veľkosť repozitára template=Šablóna template_select=Vyberte šablónu. @@ -1235,6 +1234,7 @@ teams.all_repositories_read_permission_desc=Tomuto tímu je pridelený prístup teams.all_repositories_write_permission_desc=Tomuto tímu je pridelený prístup na Zápis do všetkých repozitárov: členovia môžu prezerať a nahrávať do repozitárov. teams.all_repositories_admin_permission_desc=Tomuto tímu je pridelený Admin prístup ku všetkým repozitárom: členovia môžu prezerať, nahrávať do repozitárov a pridávať do nich spolupracovníkov. + [admin] repositories=Repozitáre hooks=Webhooky diff --git a/options/locale/locale_sv-SE.ini b/options/locale/locale_sv-SE.ini index 2fd8277b76f1c..0315ebe9a1178 100644 --- a/options/locale/locale_sv-SE.ini +++ b/options/locale/locale_sv-SE.ini @@ -598,7 +598,6 @@ visibility.private=Privat [repo] owner=Ägare repo_name=Utvecklingskatalogens namn -repo_name_helper=Bra namn på utvecklingskataloger består utav korta, unika nyckelord som är enkla att komma ihåg. repo_size=Utvecklingskatalogens storlek template=Mall template_select=Välj mall. @@ -1592,6 +1591,7 @@ teams.all_repositories_read_permission_desc=Detta team beviljar LäsSkriv-rättigheter till alla utvecklingskataloger: medlemmar kan läsa från och pusha till utvecklingskataloger. teams.all_repositories_admin_permission_desc=Detta team beviljar Admin-rättigheter till alla utvecklingskataloger: medlemmar kan läsa från, pusha till och lägga till kollaboratörer för utvecklingskatalogerna. + [admin] dashboard=Instrumentpanel users=Användarkonto diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini index 0c3884fd64887..ea938bab59ad8 100644 --- a/options/locale/locale_tr-TR.ini +++ b/options/locale/locale_tr-TR.ini @@ -984,7 +984,6 @@ new_repo_helper=Bir depo, sürüm geçmişi dahil tüm proje dosyalarını içer owner=Sahibi owner_helper=Bazı organizasyonlar, en çok depo sayısı sınırı nedeniyle açılır menüde görünmeyebilir. repo_name=Depo İsmi -repo_name_helper=İyi bir depo ismi kısa, akılda kalıcı ve özgün anahtar kelimelerden oluşur. repo_size=Depo Boyutu template=Şablon template_select=Bir şablon seçin. @@ -2747,6 +2746,7 @@ teams.invite.title=%s takımına (Organizasyon: %sЗапис для всіх репозиторіїв: учасники можуть переглядати та виконувати push в репозиторіях. teams.all_repositories_admin_permission_desc=Ця команда надає дозвіл Адміністрування для всіх репозиторіїв: учасники можуть переглядати, виконувати push та додавати співробітників. + [admin] dashboard=Панель управління users=Облікові записи користувачів diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 572ad2d667c7e..5e4723a4cd848 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -1015,7 +1015,6 @@ new_repo_helper=代码仓库包含了所有的项目文件,包括版本历史 owner=拥有者 owner_helper=由于最大仓库数量限制,一些组织可能不会显示在下拉列表中。 repo_name=仓库名称 -repo_name_helper=好的仓库名称应当使用简短、有意义和独特的关键字。 repo_size=仓库大小 template=模板 template_select=选择模板 @@ -2861,6 +2860,7 @@ teams.invite.title=您已被邀请加入组织 %s 中的团队 teams.invite.by=邀请人 %s teams.invite.description=请点击下面的按钮加入团队。 + [admin] maintenance=维护 dashboard=管理面板 diff --git a/options/locale/locale_zh-HK.ini b/options/locale/locale_zh-HK.ini index e1ca6ebfc83ca..77f8d8a25d8b1 100644 --- a/options/locale/locale_zh-HK.ini +++ b/options/locale/locale_zh-HK.ini @@ -684,6 +684,7 @@ teams.add_team_member=新增團隊成員 teams.delete_team_success=該團隊已被刪除。 teams.repositories=團隊儲存庫 + [admin] dashboard=控制面版 organizations=組織管理 diff --git a/options/locale/locale_zh-TW.ini b/options/locale/locale_zh-TW.ini index a3bf6ca88863c..948b47bc9cb67 100644 --- a/options/locale/locale_zh-TW.ini +++ b/options/locale/locale_zh-TW.ini @@ -1012,7 +1012,6 @@ new_repo_helper=儲存庫包含所有專案檔案,包括修訂歷史。已經 owner=擁有者 owner_helper=組織可能因為儲存庫數量上限而未列入此選單。 repo_name=儲存庫名稱 -repo_name_helper=好的儲存庫名稱通常是簡短的、好記的、且獨特的。 repo_size=儲存庫大小 template=範本 template_select=選擇範本 @@ -2852,6 +2851,7 @@ teams.invite.title=您已被邀請加入組織 %s 中的團隊 teams.invite.by=邀請人 %s teams.invite.description=請點擊下方按鈕加入團隊。 + [admin] maintenance=維護 dashboard=資訊主頁 From 57eb9d0b644bb893ffb90d0066066a3971de3f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20Schr=C3=B6ter?= Date: Wed, 1 Jan 2025 03:55:13 +0100 Subject: [PATCH 09/55] Inherit submodules from template repository content (#16237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #10316 --------- Signed-off-by: Steffen Schröter Co-authored-by: wxiaoguang --- modules/git/batch_reader.go | 6 +- modules/git/parse.go | 78 +++++++++++ modules/git/parse_nogogit.go | 67 ++------- modules/git/pipeline/lfs_nogogit.go | 2 +- modules/git/submodule.go | 66 +++++++++ modules/git/submodule_test.go | 48 +++++++ modules/git/tests/repos/repo4_submodules/HEAD | 1 + .../git/tests/repos/repo4_submodules/config | 4 + .../97/c3d30df0e6492348292600920a6482feaebb74 | Bin 0 -> 110 bytes .../c7/e064ed49b44523cba8a5dfbc37d2ce1bb41d34 | Bin 0 -> 112 bytes .../e1/e59caba97193d48862d6809912043871f37437 | 2 + .../repos/repo4_submodules/refs/heads/master | 1 + modules/git/tree.go | 4 +- modules/git/tree_blob_nogogit.go | 1 - modules/git/tree_entry_nogogit.go | 12 +- services/repository/generate.go | 130 +++++++++--------- templates/repo/view_list.tmpl | 4 +- 17 files changed, 290 insertions(+), 136 deletions(-) create mode 100644 modules/git/parse.go create mode 100644 modules/git/submodule.go create mode 100644 modules/git/submodule_test.go create mode 100644 modules/git/tests/repos/repo4_submodules/HEAD create mode 100644 modules/git/tests/repos/repo4_submodules/config create mode 100644 modules/git/tests/repos/repo4_submodules/objects/97/c3d30df0e6492348292600920a6482feaebb74 create mode 100644 modules/git/tests/repos/repo4_submodules/objects/c7/e064ed49b44523cba8a5dfbc37d2ce1bb41d34 create mode 100644 modules/git/tests/repos/repo4_submodules/objects/e1/e59caba97193d48862d6809912043871f37437 create mode 100644 modules/git/tests/repos/repo4_submodules/refs/heads/master diff --git a/modules/git/batch_reader.go b/modules/git/batch_reader.go index 532dbad9894b5..33e54fe75cb3e 100644 --- a/modules/git/batch_reader.go +++ b/modules/git/batch_reader.go @@ -242,7 +242,7 @@ func BinToHex(objectFormat ObjectFormat, sha, out []byte) []byte { return out } -// ParseTreeLine reads an entry from a tree in a cat-file --batch stream +// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream // This carefully avoids allocations - except where fnameBuf is too small. // It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations // @@ -250,7 +250,7 @@ func BinToHex(objectFormat ObjectFormat, sha, out []byte) []byte { // SP NUL // // We don't attempt to convert the raw HASH to save a lot of time -func ParseTreeLine(objectFormat ObjectFormat, rd *bufio.Reader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) { +func ParseCatFileTreeLine(objectFormat ObjectFormat, rd *bufio.Reader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) { var readBytes []byte // Read the Mode & fname @@ -260,7 +260,7 @@ func ParseTreeLine(objectFormat ObjectFormat, rd *bufio.Reader, modeBuf, fnameBu } idx := bytes.IndexByte(readBytes, ' ') if idx < 0 { - log.Debug("missing space in readBytes ParseTreeLine: %s", readBytes) + log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes) return mode, fname, sha, n, &ErrNotExist{} } diff --git a/modules/git/parse.go b/modules/git/parse.go new file mode 100644 index 0000000000000..eb26632cc0e5c --- /dev/null +++ b/modules/git/parse.go @@ -0,0 +1,78 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/optional" +) + +var sepSpace = []byte{' '} + +type LsTreeEntry struct { + ID ObjectID + EntryMode EntryMode + Name string + Size optional.Option[int64] +} + +func parseLsTreeLine(line []byte) (*LsTreeEntry, error) { + // expect line to be of the form: + // \t + // \t + + var err error + posTab := bytes.IndexByte(line, '\t') + if posTab == -1 { + return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line) + } + + entry := new(LsTreeEntry) + + entryAttrs := line[:posTab] + entryName := line[posTab+1:] + + entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) + _ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type + entryObjectID, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) + if len(entryAttrs) > 0 { + entrySize := entryAttrs // the last field is the space-padded-size + size, _ := strconv.ParseInt(strings.TrimSpace(string(entrySize)), 10, 64) + entry.Size = optional.Some(size) + } + + switch string(entryMode) { + case "100644": + entry.EntryMode = EntryModeBlob + case "100755": + entry.EntryMode = EntryModeExec + case "120000": + entry.EntryMode = EntryModeSymlink + case "160000": + entry.EntryMode = EntryModeCommit + case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons + entry.EntryMode = EntryModeTree + default: + return nil, fmt.Errorf("unknown type: %v", string(entryMode)) + } + + entry.ID, err = NewIDFromString(string(entryObjectID)) + if err != nil { + return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err) + } + + if len(entryName) > 0 && entryName[0] == '"' { + entry.Name, err = strconv.Unquote(string(entryName)) + if err != nil { + return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err) + } + } else { + entry.Name = string(entryName) + } + return entry, nil +} diff --git a/modules/git/parse_nogogit.go b/modules/git/parse_nogogit.go index 546b38be37964..676bb3c76c09f 100644 --- a/modules/git/parse_nogogit.go +++ b/modules/git/parse_nogogit.go @@ -10,8 +10,6 @@ import ( "bytes" "fmt" "io" - "strconv" - "strings" "code.gitea.io/gitea/modules/log" ) @@ -21,71 +19,30 @@ func ParseTreeEntries(data []byte) ([]*TreeEntry, error) { return parseTreeEntries(data, nil) } -var sepSpace = []byte{' '} - +// parseTreeEntries FIXME this function's design is not right, it should make the caller read all data into memory func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { - var err error entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1) for pos := 0; pos < len(data); { - // expect line to be of the form: - // \t - // \t posEnd := bytes.IndexByte(data[pos:], '\n') if posEnd == -1 { posEnd = len(data) } else { posEnd += pos } - line := data[pos:posEnd] - posTab := bytes.IndexByte(line, '\t') - if posTab == -1 { - return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line) - } - - entry := new(TreeEntry) - entry.ptree = ptree - - entryAttrs := line[:posTab] - entryName := line[posTab+1:] - - entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) - _ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type - entryObjectID, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) - if len(entryAttrs) > 0 { - entrySize := entryAttrs // the last field is the space-padded-size - entry.size, _ = strconv.ParseInt(strings.TrimSpace(string(entrySize)), 10, 64) - entry.sized = true - } - switch string(entryMode) { - case "100644": - entry.entryMode = EntryModeBlob - case "100755": - entry.entryMode = EntryModeExec - case "120000": - entry.entryMode = EntryModeSymlink - case "160000": - entry.entryMode = EntryModeCommit - case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons - entry.entryMode = EntryModeTree - default: - return nil, fmt.Errorf("unknown type: %v", string(entryMode)) - } - - entry.ID, err = NewIDFromString(string(entryObjectID)) + line := data[pos:posEnd] + lsTreeLine, err := parseLsTreeLine(line) if err != nil { - return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err) + return nil, err } - - if len(entryName) > 0 && entryName[0] == '"' { - entry.name, err = strconv.Unquote(string(entryName)) - if err != nil { - return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err) - } - } else { - entry.name = string(entryName) + entry := &TreeEntry{ + ptree: ptree, + ID: lsTreeLine.ID, + entryMode: lsTreeLine.EntryMode, + name: lsTreeLine.Name, + size: lsTreeLine.Size.Value(), + sized: lsTreeLine.Size.Has(), } - pos = posEnd + 1 entries = append(entries, entry) } @@ -100,7 +57,7 @@ func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd *bufio. loop: for sz > 0 { - mode, fname, sha, count, err := ParseTreeLine(objectFormat, rd, modeBuf, fnameBuf, shaBuf) + mode, fname, sha, count, err := ParseCatFileTreeLine(objectFormat, rd, modeBuf, fnameBuf, shaBuf) if err != nil { if err == io.EOF { break loop diff --git a/modules/git/pipeline/lfs_nogogit.go b/modules/git/pipeline/lfs_nogogit.go index b22805c1327e9..92e35c5a1028a 100644 --- a/modules/git/pipeline/lfs_nogogit.go +++ b/modules/git/pipeline/lfs_nogogit.go @@ -114,7 +114,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err case "tree": var n int64 for n < size { - mode, fname, binObjectID, count, err := git.ParseTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf) + mode, fname, binObjectID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf) if err != nil { return nil, err } diff --git a/modules/git/submodule.go b/modules/git/submodule.go new file mode 100644 index 0000000000000..017b644052b93 --- /dev/null +++ b/modules/git/submodule.go @@ -0,0 +1,66 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "context" + "fmt" + "os" + + "code.gitea.io/gitea/modules/log" +) + +type TemplateSubmoduleCommit struct { + Path string + Commit string +} + +// GetTemplateSubmoduleCommits returns a list of submodules paths and their commits from a repository +// This function is only for generating new repos based on existing template, the template couldn't be too large. +func GetTemplateSubmoduleCommits(ctx context.Context, repoPath string) (submoduleCommits []TemplateSubmoduleCommit, _ error) { + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + return nil, err + } + opts := &RunOpts{ + Dir: repoPath, + Stdout: stdoutWriter, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + defer stdoutReader.Close() + + scanner := bufio.NewScanner(stdoutReader) + for scanner.Scan() { + entry, err := parseLsTreeLine(scanner.Bytes()) + if err != nil { + cancel() + return err + } + if entry.EntryMode == EntryModeCommit { + submoduleCommits = append(submoduleCommits, TemplateSubmoduleCommit{Path: entry.Name, Commit: entry.ID.String()}) + } + } + return scanner.Err() + }, + } + err = NewCommand(ctx, "ls-tree", "-r", "--", "HEAD").Run(opts) + if err != nil { + return nil, fmt.Errorf("GetTemplateSubmoduleCommits: error running git ls-tree: %v", err) + } + return submoduleCommits, nil +} + +// AddTemplateSubmoduleIndexes Adds the given submodules to the git index. +// It is only for generating new repos based on existing template, requires the .gitmodules file to be already present in the work dir. +func AddTemplateSubmoduleIndexes(ctx context.Context, repoPath string, submodules []TemplateSubmoduleCommit) error { + for _, submodule := range submodules { + cmd := NewCommand(ctx, "update-index", "--add", "--cacheinfo", "160000").AddDynamicArguments(submodule.Commit, submodule.Path) + if stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath}); err != nil { + log.Error("Unable to add %s as submodule to repo %s: stdout %s\nError: %v", submodule.Path, repoPath, stdout, err) + return err + } + } + return nil +} diff --git a/modules/git/submodule_test.go b/modules/git/submodule_test.go new file mode 100644 index 0000000000000..d53946a27d40f --- /dev/null +++ b/modules/git/submodule_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetTemplateSubmoduleCommits(t *testing.T) { + testRepoPath := filepath.Join(testReposDir, "repo4_submodules") + submodules, err := GetTemplateSubmoduleCommits(DefaultContext, testRepoPath) + require.NoError(t, err) + + assert.Len(t, submodules, 2) + + assert.EqualValues(t, "<°)))><", submodules[0].Path) + assert.EqualValues(t, "d2932de67963f23d43e1c7ecf20173e92ee6c43c", submodules[0].Commit) + + assert.EqualValues(t, "libtest", submodules[1].Path) + assert.EqualValues(t, "1234567890123456789012345678901234567890", submodules[1].Commit) +} + +func TestAddTemplateSubmoduleIndexes(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + var err error + _, _, err = NewCommand(ctx, "init").RunStdString(&RunOpts{Dir: tmpDir}) + require.NoError(t, err) + _ = os.Mkdir(filepath.Join(tmpDir, "new-dir"), 0o755) + err = AddTemplateSubmoduleIndexes(ctx, tmpDir, []TemplateSubmoduleCommit{{Path: "new-dir", Commit: "1234567890123456789012345678901234567890"}}) + require.NoError(t, err) + _, _, err = NewCommand(ctx, "add", "--all").RunStdString(&RunOpts{Dir: tmpDir}) + require.NoError(t, err) + _, _, err = NewCommand(ctx, "-c", "user.name=a", "-c", "user.email=b", "commit", "-m=test").RunStdString(&RunOpts{Dir: tmpDir}) + require.NoError(t, err) + submodules, err := GetTemplateSubmoduleCommits(DefaultContext, tmpDir) + require.NoError(t, err) + assert.Len(t, submodules, 1) + assert.EqualValues(t, "new-dir", submodules[0].Path) + assert.EqualValues(t, "1234567890123456789012345678901234567890", submodules[0].Commit) +} diff --git a/modules/git/tests/repos/repo4_submodules/HEAD b/modules/git/tests/repos/repo4_submodules/HEAD new file mode 100644 index 0000000000000..cb089cd89a7d7 --- /dev/null +++ b/modules/git/tests/repos/repo4_submodules/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo4_submodules/config b/modules/git/tests/repos/repo4_submodules/config new file mode 100644 index 0000000000000..07d359d07cf1e --- /dev/null +++ b/modules/git/tests/repos/repo4_submodules/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/modules/git/tests/repos/repo4_submodules/objects/97/c3d30df0e6492348292600920a6482feaebb74 b/modules/git/tests/repos/repo4_submodules/objects/97/c3d30df0e6492348292600920a6482feaebb74 new file mode 100644 index 0000000000000000000000000000000000000000..7596090b49fc8304d4c5599e09fa97e65fd86ceb GIT binary patch literal 110 zcmV-!0FnQA0V^p=O;s>7G+;0^FfcPQQP4}zEXmDJDa}bOW;p&J<*nxySLM?ymfqiE ze(9X_7FiQRGXo${usO6rQ&ZEPh6%)`10HMe&nHoGW2LJ#7 literal 0 HcmV?d00001 diff --git a/modules/git/tests/repos/repo4_submodules/objects/c7/e064ed49b44523cba8a5dfbc37d2ce1bb41d34 b/modules/git/tests/repos/repo4_submodules/objects/c7/e064ed49b44523cba8a5dfbc37d2ce1bb41d34 new file mode 100644 index 0000000000000000000000000000000000000000..e3a13c156dce4d306921d33f4414867aea4b58c0 GIT binary patch literal 112 zcmV-$0FVE80ZYosPf{>6HDQP@E=|hKPbtkwRZz;wOe#q&E>Vi*;w(rk$xyIWfQoQ& zmKNmzxfvxT1;tkS`Z@W@i8&eh#U=Vs1$yb3C0xix*&N!Ssi|pa12jtk3ZO>9WZ>cu SqxDO23-n=fVB-M&&@!0EvNJaT literal 0 HcmV?d00001 diff --git a/modules/git/tests/repos/repo4_submodules/objects/e1/e59caba97193d48862d6809912043871f37437 b/modules/git/tests/repos/repo4_submodules/objects/e1/e59caba97193d48862d6809912043871f37437 new file mode 100644 index 0000000000000..a8d6e5c17c8f2 --- /dev/null +++ b/modules/git/tests/repos/repo4_submodules/objects/e1/e59caba97193d48862d6809912043871f37437 @@ -0,0 +1,2 @@ +x[ +0E*_$M5tifBk Iŕ7k~9ܘܠ.j O "z`#IirF͹$%|4)?t=:K#[$D^ӒyHU/f?G \ No newline at end of file diff --git a/modules/git/tests/repos/repo4_submodules/refs/heads/master b/modules/git/tests/repos/repo4_submodules/refs/heads/master new file mode 100644 index 0000000000000..102bc34da8ca0 --- /dev/null +++ b/modules/git/tests/repos/repo4_submodules/refs/heads/master @@ -0,0 +1 @@ +e1e59caba97193d48862d6809912043871f37437 diff --git a/modules/git/tree.go b/modules/git/tree.go index d35dc58d8d0ed..5a644f6c87aa1 100644 --- a/modules/git/tree.go +++ b/modules/git/tree.go @@ -17,7 +17,7 @@ func NewTree(repo *Repository, id ObjectID) *Tree { } } -// SubTree get a sub tree by the sub dir path +// SubTree get a subtree by the sub dir path func (t *Tree) SubTree(rpath string) (*Tree, error) { if len(rpath) == 0 { return t, nil @@ -63,7 +63,7 @@ func (repo *Repository) LsTree(ref string, filenames ...string) ([]string, error return filelist, err } -// GetTreePathLatestCommitID returns the latest commit of a tree path +// GetTreePathLatestCommit returns the latest commit of a tree path func (repo *Repository) GetTreePathLatestCommit(refName, treePath string) (*Commit, error) { stdout, _, err := NewCommand(repo.Ctx, "rev-list", "-1"). AddDynamicArguments(refName).AddDashesAndList(treePath). diff --git a/modules/git/tree_blob_nogogit.go b/modules/git/tree_blob_nogogit.go index 92d3d107a7425..b7bcf40edd2a9 100644 --- a/modules/git/tree_blob_nogogit.go +++ b/modules/git/tree_blob_nogogit.go @@ -17,7 +17,6 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) { ptree: t, ID: t.ID, name: "", - fullName: "", entryMode: EntryModeTree, }, nil } diff --git a/modules/git/tree_entry_nogogit.go b/modules/git/tree_entry_nogogit.go index 1c3bcd197a01d..81fb638d56fbe 100644 --- a/modules/git/tree_entry_nogogit.go +++ b/modules/git/tree_entry_nogogit.go @@ -9,23 +9,17 @@ import "code.gitea.io/gitea/modules/log" // TreeEntry the leaf in the git tree type TreeEntry struct { - ID ObjectID - + ID ObjectID ptree *Tree entryMode EntryMode name string - - size int64 - sized bool - fullName string + size int64 + sized bool } // Name returns the name of the entry func (te *TreeEntry) Name() string { - if te.fullName != "" { - return te.fullName - } return te.name } diff --git a/services/repository/generate.go b/services/repository/generate.go index 24cf9d1b9bfc1..ef9a8dc94065b 100644 --- a/services/repository/generate.go +++ b/services/repository/generate.go @@ -9,7 +9,6 @@ import ( "context" "fmt" "os" - "path" "path/filepath" "regexp" "strconv" @@ -123,7 +122,7 @@ func (gt *GiteaTemplate) Globs() []glob.Glob { return gt.globs } -func checkGiteaTemplate(tmpDir string) (*GiteaTemplate, error) { +func readGiteaTemplateFile(tmpDir string) (*GiteaTemplate, error) { gtPath := filepath.Join(tmpDir, ".gitea", "template") if _, err := os.Stat(gtPath); os.IsNotExist(err) { return nil, nil @@ -136,12 +135,55 @@ func checkGiteaTemplate(tmpDir string) (*GiteaTemplate, error) { return nil, err } - gt := &GiteaTemplate{ - Path: gtPath, - Content: content, + return &GiteaTemplate{Path: gtPath, Content: content}, nil +} + +func processGiteaTemplateFile(tmpDir string, templateRepo, generateRepo *repo_model.Repository, giteaTemplateFile *GiteaTemplate) error { + if err := util.Remove(giteaTemplateFile.Path); err != nil { + return fmt.Errorf("remove .giteatemplate: %w", err) } + if len(giteaTemplateFile.Globs()) == 0 { + return nil // Avoid walking tree if there are no globs + } + tmpDirSlash := strings.TrimSuffix(filepath.ToSlash(tmpDir), "/") + "/" + return filepath.WalkDir(tmpDirSlash, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + base := strings.TrimPrefix(filepath.ToSlash(path), tmpDirSlash) + for _, g := range giteaTemplateFile.Globs() { + if g.Match(base) { + content, err := os.ReadFile(path) + if err != nil { + return err + } - return gt, nil + generatedContent := []byte(generateExpansion(string(content), templateRepo, generateRepo, false)) + if err := os.WriteFile(path, generatedContent, 0o644); err != nil { + return err + } + + substPath := filepath.FromSlash(filepath.Join(tmpDirSlash, generateExpansion(base, templateRepo, generateRepo, true))) + + // Create parent subdirectories if needed or continue silently if it exists + if err = os.MkdirAll(filepath.Dir(substPath), 0o755); err != nil { + return err + } + + // Substitute filename variables + if err = os.Rename(path, substPath); err != nil { + return err + } + break + } + } + return nil + }) // end: WalkDir } func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *repo_model.Repository, tmpDir string) error { @@ -167,81 +209,43 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r return fmt.Errorf("git clone: %w", err) } - if err := util.RemoveAll(path.Join(tmpDir, ".git")); err != nil { + // Get active submodules from the template + submodules, err := git.GetTemplateSubmoduleCommits(ctx, tmpDir) + if err != nil { + return fmt.Errorf("GetTemplateSubmoduleCommits: %w", err) + } + + if err = util.RemoveAll(filepath.Join(tmpDir, ".git")); err != nil { return fmt.Errorf("remove git dir: %w", err) } // Variable expansion - gt, err := checkGiteaTemplate(tmpDir) + giteaTemplateFile, err := readGiteaTemplateFile(tmpDir) if err != nil { - return fmt.Errorf("checkGiteaTemplate: %w", err) + return fmt.Errorf("readGiteaTemplateFile: %w", err) } - if gt != nil { - if err := util.Remove(gt.Path); err != nil { - return fmt.Errorf("remove .giteatemplate: %w", err) - } - - // Avoid walking tree if there are no globs - if len(gt.Globs()) > 0 { - tmpDirSlash := strings.TrimSuffix(filepath.ToSlash(tmpDir), "/") + "/" - if err := filepath.WalkDir(tmpDirSlash, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - - if d.IsDir() { - return nil - } - - base := strings.TrimPrefix(filepath.ToSlash(path), tmpDirSlash) - for _, g := range gt.Globs() { - if g.Match(base) { - content, err := os.ReadFile(path) - if err != nil { - return err - } - - if err := os.WriteFile(path, - []byte(generateExpansion(string(content), templateRepo, generateRepo, false)), - 0o644); err != nil { - return err - } - - substPath := filepath.FromSlash(filepath.Join(tmpDirSlash, - generateExpansion(base, templateRepo, generateRepo, true))) - - // Create parent subdirectories if needed or continue silently if it exists - if err := os.MkdirAll(filepath.Dir(substPath), 0o755); err != nil { - return err - } - - // Substitute filename variables - if err := os.Rename(path, substPath); err != nil { - return err - } - - break - } - } - return nil - }); err != nil { - return err - } + if giteaTemplateFile != nil { + err = processGiteaTemplateFile(tmpDir, templateRepo, generateRepo, giteaTemplateFile) + if err != nil { + return err } } - if err := git.InitRepository(ctx, tmpDir, false, templateRepo.ObjectFormatName); err != nil { + if err = git.InitRepository(ctx, tmpDir, false, templateRepo.ObjectFormatName); err != nil { return err } - repoPath := repo.RepoPath() - if stdout, _, err := git.NewCommand(ctx, "remote", "add", "origin").AddDynamicArguments(repoPath). + if stdout, _, err := git.NewCommand(ctx, "remote", "add", "origin").AddDynamicArguments(repo.RepoPath()). RunStdString(&git.RunOpts{Dir: tmpDir, Env: env}); err != nil { log.Error("Unable to add %v as remote origin to temporary repo to %s: stdout %s\nError: %v", repo, tmpDir, stdout, err) return fmt.Errorf("git remote add: %w", err) } + if err = git.AddTemplateSubmoduleIndexes(ctx, tmpDir, submodules); err != nil { + return fmt.Errorf("failed to add submodules: %v", err) + } + // set default branch based on whether it's specified in the newly generated repo or not defaultBranch := repo.DefaultBranch if strings.TrimSpace(defaultBranch) == "" { diff --git a/templates/repo/view_list.tmpl b/templates/repo/view_list.tmpl index 01bb70e06ff43..c8e97d2617fd1 100644 --- a/templates/repo/view_list.tmpl +++ b/templates/repo/view_list.tmpl @@ -19,9 +19,9 @@ {{svg "octicon-file-submodule"}} {{$refURL := $subModuleFile.RefURL AppUrl $.Repository.FullName $.SSHDomain}} {{/* FIXME: the usage of AppUrl seems incorrect, it would be fixed in the future, use AppSubUrl instead */}} {{if $refURL}} - {{$entry.Name}}@{{ShortSha $subModuleFile.RefID}} + {{$entry.Name}} @ {{ShortSha $subModuleFile.RefID}} {{else}} - {{$entry.Name}}@{{ShortSha $subModuleFile.RefID}} + {{$entry.Name}} @ {{ShortSha $subModuleFile.RefID}} {{end}} {{else}} {{if $entry.IsDir}} From 2564c15cb006a551ab21dac66369c363a4e78473 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 1 Jan 2025 18:02:34 +0800 Subject: [PATCH 10/55] Remove some unnecessary template helpers (#33069) DisableGitHooks and DisableImportLocal are only used when editing a user, so only set them in `editUserCommon` --- modules/setting/security.go | 5 +- modules/templates/helper.go | 6 - routers/web/admin/users.go | 2 + templates/admin/user/edit.tmpl | 8 +- .../hooks/pre-receive.d/pre-receive | 3 +- tests/integration/api_repo_git_hook_test.go | 343 +++++++++--------- tests/mssql.ini.tmpl | 1 - tests/mysql.ini.tmpl | 1 - tests/pgsql.ini.tmpl | 1 - tests/sqlite.ini.tmpl | 1 - 10 files changed, 184 insertions(+), 187 deletions(-) diff --git a/modules/setting/security.go b/modules/setting/security.go index 3d12fcf8d9fdd..2f798b75c7e73 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -13,8 +13,9 @@ import ( "code.gitea.io/gitea/modules/log" ) +// Security settings + var ( - // Security settings InstallLock bool SecretKey string InternalToken string // internal access token @@ -27,7 +28,7 @@ var ( ReverseProxyTrustedProxies []string MinPasswordLength int ImportLocalPaths bool - DisableGitHooks bool + DisableGitHooks = true DisableWebhooks bool OnlyAllowPushIfGiteaEnvironmentSet bool PasswordComplexity []string diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 7529cadca4d51..48d3a8ff89aed 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -131,15 +131,9 @@ func NewFuncMap() template.FuncMap { "EnableTimetracking": func() bool { return setting.Service.EnableTimetracking }, - "DisableGitHooks": func() bool { - return setting.DisableGitHooks - }, "DisableWebhooks": func() bool { return setting.DisableWebhooks }, - "DisableImportLocal": func() bool { - return !setting.ImportLocalPaths - }, "UserThemeName": userThemeName, "NotificationSettings": func() map[string]any { return map[string]any{ diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index be2ba4424cdc6..f6a3af1c866d4 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -313,6 +313,8 @@ func editUserCommon(ctx *context.Context) { ctx.Data["PageIsAdminUsers"] = true ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations + ctx.Data["DisableGitHooks"] = setting.DisableGitHooks + ctx.Data["DisableImportLocal"] = !setting.ImportLocalPaths ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) } diff --git a/templates/admin/user/edit.tmpl b/templates/admin/user/edit.tmpl index 41b00defb4549..d591a645d8983 100644 --- a/templates/admin/user/edit.tmpl +++ b/templates/admin/user/edit.tmpl @@ -128,16 +128,16 @@
-
+
- +
-
+
- +
{{if not .DisableRegularOrgCreation}} diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/pre-receive b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/pre-receive index b26a3b9b6876f..205086810d4c6 100755 --- a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/pre-receive +++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/pre-receive @@ -1,3 +1,2 @@ #!/bin/bash - -echo Hello, World! +echo "TestGitHookScript" diff --git a/tests/integration/api_repo_git_hook_test.go b/tests/integration/api_repo_git_hook_test.go index 9917b41790d12..c28c4336e2d78 100644 --- a/tests/integration/api_repo_git_hook_test.go +++ b/tests/integration/api_repo_git_hook_test.go @@ -12,185 +12,190 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" ) -const testHookContent = `#!/bin/bash +func TestAPIGitHooks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.DisableGitHooks, false)() -echo Hello, World! + const testHookContent = `#!/bin/bash +echo "TestGitHookScript" ` -func TestAPIListGitHooks(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - // user1 is an admin user - session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). - AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var apiGitHooks []*api.GitHook - DecodeJSON(t, resp, &apiGitHooks) - assert.Len(t, apiGitHooks, 3) - for _, apiGitHook := range apiGitHooks { - if apiGitHook.Name == "pre-receive" { - assert.True(t, apiGitHook.IsActive) - assert.Equal(t, testHookContent, apiGitHook.Content) - } else { + t.Run("ListGitHooks", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHooks []*api.GitHook + DecodeJSON(t, resp, &apiGitHooks) + assert.Len(t, apiGitHooks, 3) + for _, apiGitHook := range apiGitHooks { + if apiGitHook.Name == "pre-receive" { + assert.True(t, apiGitHook.IsActive) + assert.Equal(t, testHookContent, apiGitHook.Content) + } else { + assert.False(t, apiGitHook.IsActive) + assert.Empty(t, apiGitHook.Content) + } + } + }) + + t.Run("NoGitHooks", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHooks []*api.GitHook + DecodeJSON(t, resp, &apiGitHooks) + assert.Len(t, apiGitHooks, 3) + for _, apiGitHook := range apiGitHooks { assert.False(t, apiGitHook.IsActive) assert.Empty(t, apiGitHook.Content) } - } -} - -func TestAPIListGitHooksNoHooks(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - // user1 is an admin user - session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). - AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var apiGitHooks []*api.GitHook - DecodeJSON(t, resp, &apiGitHooks) - assert.Len(t, apiGitHooks, 3) - for _, apiGitHook := range apiGitHooks { - assert.False(t, apiGitHook.IsActive) - assert.Empty(t, apiGitHook.Content) - } -} - -func TestAPIListGitHooksNoAccess(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - session := loginUser(t, owner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusForbidden) -} - -func TestAPIGetGitHook(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - // user1 is an admin user - session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). - AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var apiGitHook *api.GitHook - DecodeJSON(t, resp, &apiGitHook) - assert.True(t, apiGitHook.IsActive) - assert.Equal(t, testHookContent, apiGitHook.Content) -} - -func TestAPIGetGitHookNoAccess(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - session := loginUser(t, owner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusForbidden) -} - -func TestAPIEditGitHook(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - // user1 is an admin user - session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/git/pre-receive", - owner.Name, repo.Name) - req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditGitHookOption{ - Content: testHookContent, - }).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var apiGitHook *api.GitHook - DecodeJSON(t, resp, &apiGitHook) - assert.True(t, apiGitHook.IsActive) - assert.Equal(t, testHookContent, apiGitHook.Content) - - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). - AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - var apiGitHook2 *api.GitHook - DecodeJSON(t, resp, &apiGitHook2) - assert.True(t, apiGitHook2.IsActive) - assert.Equal(t, testHookContent, apiGitHook2.Content) -} - -func TestAPIEditGitHookNoAccess(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - session := loginUser(t, owner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name) - req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditGitHookOption{ - Content: testHookContent, - }).AddTokenAuth(token) - MakeRequest(t, req, http.StatusForbidden) -} - -func TestAPIDeleteGitHook(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - // user1 is an admin user - session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - - req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusNoContent) - - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). - AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var apiGitHook2 *api.GitHook - DecodeJSON(t, resp, &apiGitHook2) - assert.False(t, apiGitHook2.IsActive) - assert.Empty(t, apiGitHook2.Content) -} - -func TestAPIDeleteGitHookNoAccess(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - session := loginUser(t, owner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("ListGitHooksNoAccess", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("GetGitHook", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHook *api.GitHook + DecodeJSON(t, resp, &apiGitHook) + assert.True(t, apiGitHook.IsActive) + assert.Equal(t, testHookContent, apiGitHook.Content) + }) + t.Run("GetGitHookNoAccess", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("EditGitHook", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/git/pre-receive", + owner.Name, repo.Name) + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditGitHookOption{ + Content: testHookContent, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHook *api.GitHook + DecodeJSON(t, resp, &apiGitHook) + assert.True(t, apiGitHook.IsActive) + assert.Equal(t, testHookContent, apiGitHook.Content) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var apiGitHook2 *api.GitHook + DecodeJSON(t, resp, &apiGitHook2) + assert.True(t, apiGitHook2.IsActive) + assert.Equal(t, testHookContent, apiGitHook2.Content) + }) + + t.Run("EditGitHookNoAccess", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name) + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditGitHookOption{ + Content: testHookContent, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("DeleteGitHook", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHook2 *api.GitHook + DecodeJSON(t, resp, &apiGitHook2) + assert.False(t, apiGitHook2.IsActive) + assert.Empty(t, apiGitHook2.Content) + }) + + t.Run("DeleteGitHookNoAccess", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) } diff --git a/tests/mssql.ini.tmpl b/tests/mssql.ini.tmpl index 77c969e813611..b50816b2cdec8 100644 --- a/tests/mssql.ini.tmpl +++ b/tests/mssql.ini.tmpl @@ -93,7 +93,6 @@ COLORIZE = true LEVEL = Debug [security] -DISABLE_GIT_HOOKS = false INSTALL_LOCK = true SECRET_KEY = 9pCviYTWSb INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ diff --git a/tests/mysql.ini.tmpl b/tests/mysql.ini.tmpl index 0fddde46de69e..ec8307acc3632 100644 --- a/tests/mysql.ini.tmpl +++ b/tests/mysql.ini.tmpl @@ -94,7 +94,6 @@ COLORIZE = true LEVEL = Debug [security] -DISABLE_GIT_HOOKS = false INSTALL_LOCK = true SECRET_KEY = 9pCviYTWSb INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ diff --git a/tests/pgsql.ini.tmpl b/tests/pgsql.ini.tmpl index 695662c2e9d2e..139ea9c2b7ab7 100644 --- a/tests/pgsql.ini.tmpl +++ b/tests/pgsql.ini.tmpl @@ -94,7 +94,6 @@ COLORIZE = true LEVEL = Debug [security] -DISABLE_GIT_HOOKS = false INSTALL_LOCK = true SECRET_KEY = 9pCviYTWSb INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index 1cbcd8b2e591a..2f7a3e8182eef 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -93,7 +93,6 @@ COLORIZE = true LEVEL = Debug [security] -DISABLE_GIT_HOOKS = false INSTALL_LOCK = true SECRET_KEY = 9pCviYTWSb INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTI3OTU5ODN9.OQkH5UmzID2XBdwQ9TAI6Jj2t1X-wElVTjbE7aoN4I8 From d030cace1a6fc19874ab5a2ae20544c702fcb6c5 Mon Sep 17 00:00:00 2001 From: lonix1 <40320097+lonix1@users.noreply.github.com> Date: Wed, 1 Jan 2025 13:07:10 +0200 Subject: [PATCH 11/55] feat: link to nuget dependencies (#26554) Add links to dependencies and their versions, as done in nuget site. Makes it easier to use. Co-authored-by: wxiaoguang --- options/locale/locale_en-US.ini | 1 + templates/package/content/nuget.tmpl | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4e9ec275ddced..6029d49ade0bc 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3538,6 +3538,7 @@ versions = Versions versions.view_all = View all dependency.id = ID dependency.version = Version +search_in_external_registry = Search in %s alpine.registry = Setup this registry by adding the url in your /etc/apk/repositories file: alpine.registry.key = Download the registry public RSA key into the /etc/apk/keys/ folder to verify the index signature: alpine.registry.info = Choose $branch and $repository from the list below. diff --git a/templates/package/content/nuget.tmpl b/templates/package/content/nuget.tmpl index 5bb98a86ddf9e..4a4ea8ca43657 100644 --- a/templates/package/content/nuget.tmpl +++ b/templates/package/content/nuget.tmpl @@ -35,11 +35,12 @@ + {{$tooltipSearchInNuget := ctx.Locale.Tr "packages.search_in_external_registry" "nuget.org"}} {{range $framework, $dependencies := .PackageDescriptor.Metadata.Dependencies}} {{range $dependencies}} - {{.ID}} - {{.Version}} + {{.ID}} {{svg "octicon-link-external"}} + {{.Version}} {{svg "octicon-link-external"}} {{$framework}} {{end}} From 85c756e2799c6e427c6b05901dd60d52c9b25142 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 2 Jan 2025 01:16:09 +0800 Subject: [PATCH 12/55] Refactor pull-request compare&create page (#33071) The old code is unnecessarily complex. --- templates/repo/diff/compare.tmpl | 359 +++++++++++----------- tests/integration/compare_test.go | 4 +- web_src/css/repo.css | 9 - web_src/js/features/repo-issue-sidebar.ts | 2 +- web_src/js/features/repo-legacy.ts | 74 +---- 5 files changed, 195 insertions(+), 253 deletions(-) diff --git a/templates/repo/diff/compare.tmpl b/templates/repo/diff/compare.tmpl index 118d6478d19a6..9a7a04a328bc3 100644 --- a/templates/repo/diff/compare.tmpl +++ b/templates/repo/diff/compare.tmpl @@ -1,230 +1,229 @@ {{template "base/head" .}}
{{template "repo/header" .}} - {{$showDiffBox := false}} +
-

- {{if and $.PageIsComparePull $.IsSigned (not .Repository.IsArchived)}} - {{ctx.Locale.Tr "repo.pulls.compare_changes"}} -
{{ctx.Locale.Tr "repo.pulls.compare_changes_desc"}}
- {{else}} - {{ctx.Locale.Tr "action.compare_commits_general"}} - {{end}} -

- {{$BaseCompareName := $.BaseName -}} - {{- $HeadCompareName := $.HeadRepo.OwnerName -}} - {{- if and (eq $.BaseName $.HeadRepo.OwnerName) (ne $.Repository.Name $.HeadRepo.Name) -}} - {{- $HeadCompareName = printf "%s/%s" $.HeadRepo.OwnerName $.HeadRepo.Name -}} - {{- end -}} - {{- $OwnForkCompareName := "" -}} - {{- if .OwnForkRepo -}} - {{- $OwnForkCompareName = .OwnForkRepo.OwnerName -}} - {{- end -}} - {{- $RootRepoCompareName := "" -}} - {{- if .RootRepo -}} - {{- $RootRepoCompareName = .RootRepo.OwnerName -}} - {{- if eq $.HeadRepo.OwnerName .RootRepo.OwnerName -}} +

+ {{if and $.PageIsComparePull $.IsSigned (not .Repository.IsArchived)}} + {{ctx.Locale.Tr "repo.pulls.compare_changes"}} +
{{ctx.Locale.Tr "repo.pulls.compare_changes_desc"}}
+ {{else}} + {{ctx.Locale.Tr "action.compare_commits_general"}} + {{end}} +

+ {{$BaseCompareName := $.BaseName -}} + {{- $HeadCompareName := $.HeadRepo.OwnerName -}} + {{- if and (eq $.BaseName $.HeadRepo.OwnerName) (ne $.Repository.Name $.HeadRepo.Name) -}} {{- $HeadCompareName = printf "%s/%s" $.HeadRepo.OwnerName $.HeadRepo.Name -}} {{- end -}} - {{- end -}} -
- {{svg "octicon-git-compare"}} -

{{ctx.Locale.Tr "settings.twofa_is_enrolled"}}
- {{if .scratch_code}} -
- - -
- - {{else}} -
- - -
- {{end}} + {{if .scratch_code}} +
+ + +
+ + {{else}} +
+ + +
+ {{end}} {{end}}
diff --git a/templates/user/auth/signin_inner.tmpl b/templates/user/auth/signin_inner.tmpl index dd608e5aa1e53..fbf86a92bf636 100644 --- a/templates/user/auth/signin_inner.tmpl +++ b/templates/user/auth/signin_inner.tmpl @@ -18,9 +18,9 @@
{{if or (not .DisablePassword) .LinkAccountMode}} -
- -
+
+
+ {{ctx.Locale.Tr "auth.forgot_password"}}
diff --git a/templates/user/auth/signup_openid_connect.tmpl b/templates/user/auth/signup_openid_connect.tmpl index e4b7936374355..b27093d853dab 100644 --- a/templates/user/auth/signup_openid_connect.tmpl +++ b/templates/user/auth/signup_openid_connect.tmpl @@ -7,28 +7,28 @@ {{ctx.Locale.Tr "auth.openid_connect_title"}}

-

- {{ctx.Locale.Tr "auth.openid_connect_desc"}} -

-
- {{.CsrfTokenHtml}} -
- - -
-
- - -
-
- - -
-
- - - {{ctx.Locale.Tr "auth.forgot_password"}} -
+ + {{.CsrfTokenHtml}} +
+ {{ctx.Locale.Tr "auth.openid_connect_desc"}} +
+
+ + +
+
+ + +
+
+ + +
+
+ + + {{ctx.Locale.Tr "auth.forgot_password"}} +
diff --git a/templates/user/auth/signup_openid_register.tmpl b/templates/user/auth/signup_openid_register.tmpl index c017a0e65b26a..df6268d151142 100644 --- a/templates/user/auth/signup_openid_register.tmpl +++ b/templates/user/auth/signup_openid_register.tmpl @@ -7,7 +7,7 @@ {{ctx.Locale.Tr "auth.openid_register_title"}}
-

+

{{ctx.Locale.Tr "auth.openid_register_desc"}}

diff --git a/web_src/css/base.css b/web_src/css/base.css index d6ab5e1009c54..49d5743158444 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -985,22 +985,6 @@ overflow-menu .ui.label { margin-top: 3em !important; } -/* multiple radio or checkboxes as inline element */ -.inline-grouped-list { - display: inline-block; - vertical-align: top; -} - -.inline-grouped-list > .ui { - display: block; - margin-top: 5px; - margin-bottom: 10px; -} - -.inline-grouped-list > .ui:first-child { - margin-top: 1px; -} - .lines-blame-btn { padding: 0 0 0 5px; display: flex; diff --git a/web_src/css/form.css b/web_src/css/form.css index a92ba354b41a4..266b8c73f27e8 100644 --- a/web_src/css/form.css +++ b/web_src/css/form.css @@ -38,11 +38,6 @@ textarea, color: var(--color-input-text); } -/* fix fomantic small dropdown having inconsistent padding with input */ -.ui.small.selection.dropdown { - padding: .67857143em 1.6em .67857143em 1em; -} - input:hover, textarea:hover, .ui.input input:hover, @@ -109,9 +104,8 @@ textarea:focus, color: var(--color-input-text); } -/* match */ -.ui.form select { - padding: 0.67857143em 1em; +.ui.form .field > .selection.dropdown { + min-width: 14em; /* matches the default min width */ } .form .help { @@ -120,47 +114,6 @@ textarea:focus, display: inline-block; } -#create-page-form form { - margin: auto; -} - -#create-page-form form .ui.message { - text-align: center; -} - -@media (min-width: 768px) { - #create-page-form form { - width: 800px !important; - } - #create-page-form form .header { - padding-left: 280px !important; - } - #create-page-form form .inline.field > label { - text-align: right; - width: 250px !important; - word-wrap: break-word; - } - #create-page-form form .help { - margin-left: 265px !important; - } - #create-page-form form .optional .title { - margin-left: 250px !important; - } - #create-page-form form .inline.field > input, - #create-page-form form .inline.field > textarea { - width: 50%; - } -} - -@media (max-width: 767.98px) { - #create-page-form form .optional .title { - margin-left: 15px; - } - #create-page-form form .inline.field > label { - display: block; - } -} - .m-captcha-style { width: 100%; height: 5em; @@ -187,7 +140,7 @@ textarea:focus, } @media (max-height: 575px) { - #rc-imageselect, + #rc-imageselect, /* google recaptcha */ .g-recaptcha-style, .h-captcha-style { transform: scale(0.77); @@ -195,295 +148,40 @@ textarea:focus, } } -.user.forgot.password form, -.user.reset.password form, -.user.signup form { - margin: auto; - width: 700px !important; -} - -.user.activate form .ui.message, -.user.forgot.password form .ui.message, -.user.reset.password form .ui.message, -.user.link-account form .ui.message, -.user.signin form .ui.message, -.user.signup form .ui.message { - text-align: center; -} - -@media (min-width: 768px) { - .user.activate form, - .user.forgot.password form, - .user.reset.password form, - .user.link-account form, - .user.signin form, - .user.signup form { - width: 800px !important; - } - .user.activate form .header, - .user.forgot.password form .header, - .user.reset.password form .header, - .user.link-account form .header, - .user.signin form .header, - .user.signup form .header { - padding-left: 280px !important; - } - .user.activate form .inline.field > label { - text-align: right; - width: 250px !important; - word-wrap: break-word; - } - .user.activate form .help, - .user.forgot.password form .help, - .user.reset.password form .help, - .user.link-account form .help, - .user.signin form .help, - .user.signup form .help { - margin-left: 265px !important; - } - .user.activate form .optional .title, - .user.forgot.password form .optional .title, - .user.reset.password form .optional .title, - .user.link-account form .optional .title, - .user.signin form .optional .title, - .user.signup form .optional .title { - margin-left: 250px !important; - } -} - -@media (max-width: 767.98px) { - .user.activate form .optional .title, - .user.forgot.password form .optional .title, - .user.reset.password form .optional .title, - .user.link-account form .optional .title, - .user.signin form .optional .title, - .user.signup form .optional .title { - margin-left: 15px; - } - .user.activate form .inline.field > label, - .user.forgot.password form .inline.field > label, - .user.reset.password form .inline.field > label, - .user.link-account form .inline.field > label, - .user.signin form .inline.field > label, - .user.signup form .inline.field > label { - display: block; - } -} - -.user.activate form .header, -.user.forgot.password form .header, -.user.reset.password form .header, -.user.link-account form .header, -.user.signin form .header, -.user.signup form .header { - padding-left: 0 !important; - text-align: center; -} - -.user.activate form .inline.field > label, -.user.forgot.password form .inline.field > label, -.user.reset.password form .inline.field > label, -.user.link-account form .inline.field > label, -.user.signin form .inline.field > label, -.user.signup form .inline.field > label { - width: 200px; -} - -@media (max-width: 767.98px) { - .user.activate form .inline.field > label, - .user.forgot.password form .inline.field > label, - .user.reset.password form .inline.field > label, - .user.link-account form .inline.field > label, - .user.signin form .inline.field > label, - .user.signup form .inline.field > label { - width: 100% !important; - } -} - -.user.activate form input[type="number"], -.user.forgot.password form input[type="number"], -.user.reset.password form input[type="number"], -.user.link-account form input[type="number"], -.user.signin form input[type="number"], -.user.signup form input[type="number"] { - -moz-appearance: textfield; -} - -.user.activate form input::-webkit-outer-spin-button, -.user.forgot.password form input::-webkit-outer-spin-button, -.user.reset.password form input::-webkit-outer-spin-button, -.user.link-account form input::-webkit-outer-spin-button, -.user.signin form input::-webkit-outer-spin-button, -.user.signup form input::-webkit-outer-spin-button, -.user.activate form input::-webkit-inner-spin-button, -.user.forgot.password form input::-webkit-inner-spin-button, -.user.reset.password form input::-webkit-inner-spin-button, -.user.link-account form input::-webkit-inner-spin-button, -.user.signin form input::-webkit-inner-spin-button, -.user.signup form input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.repository.new-repo form, -.repository.new.migrate form, -.repository.new.fork form { - margin: auto; -} - -.repository.new-repo form .ui.message, -.repository.new.migrate form .ui.message, -.repository.new.fork form .ui.message { - text-align: center; -} - -@media (min-width: 768px) { - .repository.new-repo form, - .repository.new.migrate form, - .repository.new.fork form { - width: 800px !important; - } - .repository.new-repo form .header, - .repository.new.migrate form .header, - .repository.new.fork form .header { - padding-left: 280px !important; - } - .repository.new-repo form .inline.field > label, - .repository.new.migrate form .inline.field > label, - .repository.new.fork form .inline.field > label { - text-align: right; - width: 250px !important; - word-wrap: break-word; - } - .repository.new-repo form .help, - .repository.new.migrate form .help, - .repository.new.fork form .help { - margin-left: 265px !important; - } - .repository.new-repo form .optional .title, - .repository.new.migrate form .optional .title, - .repository.new.fork form .optional .title { - margin-left: 250px !important; - } - .repository.new-repo form .inline.field > input, - .repository.new.migrate form .inline.field > input, - .repository.new.fork form .inline.field > input, - .repository.new-repo form .inline.field > textarea, - .repository.new.migrate form .inline.field > textarea, - .repository.new.fork form .inline.field > textarea { - width: 50%; - } -} - -@media (max-width: 767.98px) { - .repository.new-repo form .optional .title, - .repository.new.migrate form .optional .title, - .repository.new.fork form .optional .title { - margin-left: 15px; - } - .repository.new-repo form .inline.field > label, - .repository.new.migrate form .inline.field > label, - .repository.new.fork form .inline.field > label { - display: block; - } +.ui.form.left-right-form .inline.field > label { + text-align: right; + width: 250px; + margin-right: 10px; } -.repository.new-repo form .dropdown .text, -.repository.new.migrate form .dropdown .text, -.repository.new.fork form .dropdown .text { - margin-right: 0 !important; +.ui.form.left-right-form .inline.field > .help { + margin-left: calc(250px + 15px); } -.repository.new-repo form .header, -.repository.new.migrate form .header, -.repository.new.fork form .header { - padding-left: 0 !important; - text-align: center; +.ui.form.left-right-form .inline.field input:not([type="checkbox"], [type="radio"]), +.ui.form.left-right-form .inline.field .ui.dropdown, +.ui.form.left-right-form .inline.field textarea { + width: 50%; } -.repository.new-repo form .selection.dropdown, -.repository.new.migrate form .selection.dropdown, -.repository.new.fork form .selection.dropdown, -.repository.new.fork form .field a { - vertical-align: middle; - width: 50% !important; +.ui.form.left-right-form .inline.field .inline-right { + display: inline-flex; + flex-direction: column; + gap: 0.5em; } @media (max-width: 767.98px) { - .repository.new-repo form label, - .repository.new.migrate form label, - .repository.new.fork form label, - .repository.new-repo form .inline.field > input, - .repository.new.migrate form .inline.field > input, - .repository.new.fork form .inline.field > input, - .repository.new.fork form .field a, - .repository.new-repo form .selection.dropdown, - .repository.new.migrate form .selection.dropdown, - .repository.new.fork form .selection.dropdown { - width: 100% !important; - } - .repository.new-repo form .field button, - .repository.new.migrate form .field button, - .repository.new.fork form .field button, - .repository.new-repo form .field a, - .repository.new.migrate form .field a { - margin-bottom: 1em; + .ui.form.left-right-form .inline.field > label { width: 100%; + margin: 0; + text-align: left; } -} - -@media (min-width: 768px) { - .repository.new-repo .ui.form #auto-init { - margin-left: 265px !important; - } -} - -.repository.new-repo .ui.form .selection.dropdown:not(.owner) { - width: 50% !important; -} - -@media (max-width: 767.98px) { - .repository.new-repo .ui.form .selection.dropdown:not(.owner) { - width: 100% !important; + .ui.form.left-right-form .inline.field > .help { + margin: 0; } -} - -/* form fields with additional content besides their label, used on login form - * use like
*/ -.form-field-content-aside-label { - display: grid; - grid-template-columns: 1fr 1fr; -} -.form-field-content-aside-label > *:nth-child(2) { - text-align: right; -} -.form-field-content-aside-label input { - grid-column: span 2; -} - -.ui.form .field > .selection.dropdown { - min-width: 14em; /* matches the default min width */ -} - -.new.webhook form .help { - margin-left: 25px; -} - -.new.webhook .events.fields .column { - padding-left: 40px; -} - -.githook textarea { - font-family: var(--fonts-monospace); -} - -@media (max-width: 767.98px) { - .new.org .ui.form .field button, - .new.org .ui.form .field a { - margin-bottom: 1em; + .ui.form.left-right-form .inline.field input:not([type="checkbox"], [type="radio"]), + .ui.form.left-right-form .inline.field .ui.dropdown, + .ui.form.left-right-form .inline.field textarea { width: 100%; } - .new.org .ui.form .field input { - width: 100% !important; - } } diff --git a/web_src/css/org.css b/web_src/css/org.css index 1082625041901..48b41de297e97 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -1,94 +1,7 @@ -#create-page-form form { - margin: auto; -} - -#create-page-form form .ui.message { - text-align: center; -} - -@media (min-width: 768px) { - #create-page-form form { - width: 800px !important; - } - #create-page-form form .header { - padding-left: 280px !important; - } - #create-page-form form .inline.field > label { - text-align: right; - width: 250px !important; - word-wrap: break-word; - } - #create-page-form form .help { - margin-left: 265px !important; - } - #create-page-form form .optional .title { - margin-left: 250px !important; - } - #create-page-form form .inline.field > input, - #create-page-form form .inline.field > textarea { - width: 50%; - } -} - -@media (max-width: 767.98px) { - #create-page-form form .optional .title { - margin-left: 15px; - } - #create-page-form form .inline.field > label { - display: block; - } -} - .organization .head .ui.header .ui.right { margin-top: 5px; } -.organization.new.org form { - margin: auto; -} - -.organization.new.org form .ui.message { - text-align: center; -} - -@media (min-width: 768px) { - .organization.new.org form { - width: 800px !important; - } - .organization.new.org form .header { - padding-left: 280px !important; - } - .organization.new.org form .inline.field > label { - text-align: right; - width: 250px !important; - word-wrap: break-word; - } - .organization.new.org form .help { - margin-left: 265px !important; - } - .organization.new.org form .optional .title { - margin-left: 250px !important; - } - .organization.new.org form .inline.field > input, - .organization.new.org form .inline.field > textarea { - width: 50%; - } -} - -@media (max-width: 767.98px) { - .organization.new.org form .optional .title { - margin-left: 15px; - } - .organization.new.org form .inline.field > label { - display: block; - } -} - -.organization.new.org form .header { - padding-left: 0 !important; - text-align: center; -} - .page-content.organization .org-avatar { margin-right: 15px; } From 188e0ee8e40ad0b32f9db33a0a217043cfdf3610 Mon Sep 17 00:00:00 2001 From: Kemal Zebari <60799661+kemzeb@users.noreply.github.com> Date: Fri, 3 Jan 2025 13:09:47 -0800 Subject: [PATCH 24/55] Use `Project-URL` metadata field to get a PyPI package's homepage URL (#33089) Resolves #33085. --- routers/api/packages/pypi/pypi.go | 47 ++++++++++++-- routers/api/packages/pypi/pypi_test.go | 10 +++ tests/integration/api_packages_pypi_test.go | 69 +++++++++++++++++++-- 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/routers/api/packages/pypi/pypi.go b/routers/api/packages/pypi/pypi.go index 5ea86071a9e64..19aa54628d278 100644 --- a/routers/api/packages/pypi/pypi.go +++ b/routers/api/packages/pypi/pypi.go @@ -10,6 +10,7 @@ import ( "regexp" "sort" "strings" + "unicode" packages_model "code.gitea.io/gitea/models/packages" packages_module "code.gitea.io/gitea/modules/packages" @@ -139,9 +140,30 @@ func UploadPackageFile(ctx *context.Context) { return } - projectURL := ctx.Req.FormValue("home_page") - if !validation.IsValidURL(projectURL) { - projectURL = "" + // Ensure ctx.Req.Form exists. + _ = ctx.Req.ParseForm() + + var homepageURL string + projectURLs := ctx.Req.Form["project_urls"] + for _, purl := range projectURLs { + label, url, found := strings.Cut(purl, ",") + if !found { + continue + } + if normalizeLabel(label) != "homepage" { + continue + } + homepageURL = strings.TrimSpace(url) + break + } + + if len(homepageURL) == 0 { + // TODO: Home-page is a deprecated metadata field. Remove this branch once it's no longer apart of the spec. + homepageURL = ctx.Req.FormValue("home_page") + } + + if !validation.IsValidURL(homepageURL) { + homepageURL = "" } _, _, err = packages_service.CreatePackageOrAddFileToExisting( @@ -160,7 +182,7 @@ func UploadPackageFile(ctx *context.Context) { Description: ctx.Req.FormValue("description"), LongDescription: ctx.Req.FormValue("long_description"), Summary: ctx.Req.FormValue("summary"), - ProjectURL: projectURL, + ProjectURL: homepageURL, License: ctx.Req.FormValue("license"), RequiresPython: ctx.Req.FormValue("requires_python"), }, @@ -189,6 +211,23 @@ func UploadPackageFile(ctx *context.Context) { ctx.Status(http.StatusCreated) } +// Normalizes a Project-URL label. +// See https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization. +func normalizeLabel(label string) string { + var builder strings.Builder + + // "A label is normalized by deleting all ASCII punctuation and whitespace, and then converting the result + // to lowercase." + for _, r := range label { + if unicode.IsPunct(r) || unicode.IsSpace(r) { + continue + } + builder.WriteRune(unicode.ToLower(r)) + } + + return builder.String() +} + func isValidNameAndVersion(packageName, packageVersion string) bool { return nameMatcher.MatchString(packageName) && versionMatcher.MatchString(packageVersion) } diff --git a/routers/api/packages/pypi/pypi_test.go b/routers/api/packages/pypi/pypi_test.go index 3023692177fd9..786105693f169 100644 --- a/routers/api/packages/pypi/pypi_test.go +++ b/routers/api/packages/pypi/pypi_test.go @@ -36,3 +36,13 @@ func TestIsValidNameAndVersion(t *testing.T) { assert.False(t, isValidNameAndVersion("test-name", "1.0.1aa")) assert.False(t, isValidNameAndVersion("test-name", "1.0.0-alpha.beta")) } + +func TestNormalizeLabel(t *testing.T) { + // Cases fetched from https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization. + assert.Equal(t, "homepage", normalizeLabel("Homepage")) + assert.Equal(t, "homepage", normalizeLabel("Home-page")) + assert.Equal(t, "homepage", normalizeLabel("Home page")) + assert.Equal(t, "changelog", normalizeLabel("Change_Log")) + assert.Equal(t, "whatsnew", normalizeLabel("What's New?")) + assert.Equal(t, "github", normalizeLabel("github")) +} diff --git a/tests/integration/api_packages_pypi_test.go b/tests/integration/api_packages_pypi_test.go index e973f6a52af77..2dabb5005bcf8 100644 --- a/tests/integration/api_packages_pypi_test.go +++ b/tests/integration/api_packages_pypi_test.go @@ -32,15 +32,16 @@ func TestPackagePyPI(t *testing.T) { packageVersion := "1!1.0.1+r1234" packageAuthor := "KN4CK3R" packageDescription := "Test Description" + projectURL := "https://example.com" content := "test" hashSHA256 := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" root := fmt.Sprintf("/api/packages/%s/pypi", user.Name) - uploadFile := func(t *testing.T, filename, content string, expectedStatus int) { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) + createBasicMultipartFile := func(filename, packageName, content string) (body *bytes.Buffer, writer *multipart.Writer, closer func() error) { + body = &bytes.Buffer{} + writer = multipart.NewWriter(body) part, _ := writer.CreateFormFile("content", filename) _, _ = io.Copy(part, strings.NewReader(content)) @@ -52,14 +53,27 @@ func TestPackagePyPI(t *testing.T) { writer.WriteField("sha256_digest", hashSHA256) writer.WriteField("requires_python", "3.6") - _ = writer.Close() + return body, writer, writer.Close + } + uploadHelper := func(t *testing.T, body *bytes.Buffer, contentType string, expectedStatus int) { req := NewRequestWithBody(t, "POST", root, body). - SetHeader("Content-Type", writer.FormDataContentType()). + SetHeader("Content-Type", contentType). AddBasicAuth(user.Name) MakeRequest(t, req, expectedStatus) } + uploadFile := func(t *testing.T, filename, content string, expectedStatus int) { + body, writer, closeFunc := createBasicMultipartFile(filename, packageName, content) + + writer.WriteField("project_urls", "DOCUMENTATION , https://readthedocs.org") + writer.WriteField("project_urls", fmt.Sprintf("Home-page, %s", projectURL)) + + _ = closeFunc() + + uploadHelper(t, body, writer.FormDataContentType(), expectedStatus) + } + t.Run("Upload", func(t *testing.T) { defer tests.PrintCurrentTest(t)() @@ -74,6 +88,7 @@ func TestPackagePyPI(t *testing.T) { assert.NoError(t, err) assert.Nil(t, pd.SemVer) assert.IsType(t, &pypi.Metadata{}, pd.Metadata) + assert.Equal(t, projectURL, pd.Metadata.(*pypi.Metadata).ProjectURL) assert.Equal(t, packageName, pd.Package.Name) assert.Equal(t, packageVersion, pd.Version.Version) @@ -133,6 +148,48 @@ func TestPackagePyPI(t *testing.T) { uploadFile(t, "test.tar.gz", content, http.StatusConflict) }) + t.Run("UploadUsingDeprecatedHomepageMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pkgName := "homepage-package" + body, writer, closeFunc := createBasicMultipartFile("test.whl", pkgName, content) + + writer.WriteField("home_page", projectURL) + + _ = closeFunc() + + uploadHelper(t, body, writer.FormDataContentType(), http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageName(db.DefaultContext, user.ID, packages.TypePyPI, pkgName) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + assert.NoError(t, err) + assert.IsType(t, &pypi.Metadata{}, pd.Metadata) + assert.Equal(t, projectURL, pd.Metadata.(*pypi.Metadata).ProjectURL) + }) + + t.Run("UploadWithoutAnyHomepageURLMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pkgName := "no-project-url-or-homepage-package" + body, writer, closeFunc := createBasicMultipartFile("test.whl", pkgName, content) + + _ = closeFunc() + + uploadHelper(t, body, writer.FormDataContentType(), http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageName(db.DefaultContext, user.ID, packages.TypePyPI, pkgName) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + assert.NoError(t, err) + assert.IsType(t, &pypi.Metadata{}, pd.Metadata) + assert.Empty(t, pd.Metadata.(*pypi.Metadata).ProjectURL) + }) + t.Run("Download", func(t *testing.T) { defer tests.PrintCurrentTest(t)() @@ -147,7 +204,7 @@ func TestPackagePyPI(t *testing.T) { downloadFile("test.whl") downloadFile("test.tar.gz") - pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePyPI) + pvs, err := packages.GetVersionsByPackageName(db.DefaultContext, user.ID, packages.TypePyPI, packageName) assert.NoError(t, err) assert.Len(t, pvs, 1) assert.Equal(t, int64(2), pvs[0].DownloadCount) From 2b064b8637dee3904e882baada99221204f4f874 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 4 Jan 2025 10:56:07 +0800 Subject: [PATCH 25/55] Refactor legacy line-number and scroll code (#33094) 1. remove jquery 2. rewrite the "line number selection", fix various edge cases 3. fix the scroll --- web_src/css/base.css | 9 +- web_src/js/features/repo-code.test.ts | 17 --- web_src/js/features/repo-code.ts | 162 ++++++++++---------------- web_src/js/features/repo-diff.ts | 5 + web_src/js/features/repo-issue.ts | 21 +--- 5 files changed, 73 insertions(+), 141 deletions(-) delete mode 100644 web_src/js/features/repo-code.test.ts diff --git a/web_src/css/base.css b/web_src/css/base.css index 49d5743158444..a1ee7044ecf80 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -336,8 +336,13 @@ a.label, border-color: var(--color-secondary); } +.ui.dropdown .menu > .header { + text-transform: none; /* reset fomantic's "uppercase" */ +} + .ui.dropdown .menu > .header:not(.ui) { color: var(--color-text); + font-size: 0.95em; /* reset fomantic's small font-size */ } .ui.dropdown .menu > .item { @@ -691,10 +696,6 @@ input:-webkit-autofill:active, box-shadow: 0 6px 18px var(--color-shadow) !important; } -.ui.dropdown .menu > .header { - font-size: 0.8em; -} - .ui .text.left { text-align: left !important; } diff --git a/web_src/js/features/repo-code.test.ts b/web_src/js/features/repo-code.test.ts deleted file mode 100644 index 27554aa847efc..0000000000000 --- a/web_src/js/features/repo-code.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {singleAnchorRegex, rangeAnchorRegex} from './repo-code.ts'; - -test('singleAnchorRegex', () => { - expect(singleAnchorRegex.test('#L0')).toEqual(false); - expect(singleAnchorRegex.test('#L1')).toEqual(true); - expect(singleAnchorRegex.test('#L01')).toEqual(false); - expect(singleAnchorRegex.test('#n0')).toEqual(false); - expect(singleAnchorRegex.test('#n1')).toEqual(true); - expect(singleAnchorRegex.test('#n01')).toEqual(false); -}); - -test('rangeAnchorRegex', () => { - expect(rangeAnchorRegex.test('#L0-L10')).toEqual(false); - expect(rangeAnchorRegex.test('#L1-L10')).toEqual(true); - expect(rangeAnchorRegex.test('#L01-L10')).toEqual(false); - expect(rangeAnchorRegex.test('#L1-L01')).toEqual(false); -}); diff --git a/web_src/js/features/repo-code.ts b/web_src/js/features/repo-code.ts index a8d6e8f97ddd5..207022ca42c6e 100644 --- a/web_src/js/features/repo-code.ts +++ b/web_src/js/features/repo-code.ts @@ -1,12 +1,8 @@ -import $ from 'jquery'; import {svg} from '../svg.ts'; -import {invertFileFolding} from './file-fold.ts'; import {createTippy} from '../modules/tippy.ts'; import {clippie} from 'clippie'; import {toAbsoluteUrl} from '../utils.ts'; - -export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/; -export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/; +import {addDelegatedEventListener} from '../utils/dom.ts'; function changeHash(hash: string) { if (window.history.pushState) { @@ -16,20 +12,11 @@ function changeHash(hash: string) { } } -function isBlame() { - return Boolean(document.querySelector('div.blame')); -} +// it selects the code lines defined by range: `L1-L3` (3 lines) or `L2` (singe line) +function selectRange(range: string): Element { + for (const el of document.querySelectorAll('.code-view tr.active')) el.classList.remove('active'); + const elLineNums = document.querySelectorAll(`.code-view td.lines-num span[data-line-number]`); -function getLineEls() { - return document.querySelectorAll(`.code-view td.lines-code${isBlame() ? '.blame-code' : ''}`); -} - -function selectRange($linesEls, $selectionEndEl, $selectionStartEls?) { - for (const el of $linesEls) { - el.closest('tr').classList.remove('active'); - } - - // add hashchange to permalink const refInNewIssue = document.querySelector('a.ref-in-new-issue'); const copyPermalink = document.querySelector('a.copy-line-permalink'); const viewGitBlame = document.querySelector('a.view_git_blame'); @@ -59,37 +46,30 @@ function selectRange($linesEls, $selectionEndEl, $selectionStartEls?) { copyPermalink.setAttribute('data-url', link); }; - if ($selectionStartEls) { - let a = parseInt($selectionEndEl[0].getAttribute('rel').slice(1)); - let b = parseInt($selectionStartEls[0].getAttribute('rel').slice(1)); - let c; - if (a !== b) { - if (a > b) { - c = a; - a = b; - b = c; - } - const classes = []; - for (let i = a; i <= b; i++) { - classes.push(`[rel=L${i}]`); - } - $linesEls.filter(classes.join(',')).each(function () { - this.closest('tr').classList.add('active'); - }); - changeHash(`#L${a}-L${b}`); - - updateIssueHref(`L${a}-L${b}`); - updateViewGitBlameFragment(`L${a}-L${b}`); - updateCopyPermalinkUrl(`L${a}-L${b}`); - return; - } + const rangeFields = range ? range.split('-') : []; + const start = rangeFields[0] ?? ''; + if (!start) return null; + const stop = rangeFields[1] || start; + + // format is i.e. 'L14-L26' + let startLineNum = parseInt(start.substring(1)); + let stopLineNum = parseInt(stop.substring(1)); + if (startLineNum > stopLineNum) { + const tmp = startLineNum; + startLineNum = stopLineNum; + stopLineNum = tmp; + range = `${stop}-${start}`; } - $selectionEndEl[0].closest('tr').classList.add('active'); - changeHash(`#${$selectionEndEl[0].getAttribute('rel')}`); - updateIssueHref($selectionEndEl[0].getAttribute('rel')); - updateViewGitBlameFragment($selectionEndEl[0].getAttribute('rel')); - updateCopyPermalinkUrl($selectionEndEl[0].getAttribute('rel')); + const first = elLineNums[startLineNum - 1] ?? null; + for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) { + elLineNums[i].closest('tr').classList.add('active'); + } + changeHash(`#${range}`); + updateIssueHref(range); + updateViewGitBlameFragment(range); + updateCopyPermalinkUrl(range); + return first; } function showLineButton() { @@ -103,6 +83,8 @@ function showLineButton() { // find active row and add button const tr = document.querySelector('.code-view tr.active'); + if (!tr) return; + const td = tr.querySelector('td.lines-num'); const btn = document.createElement('button'); btn.classList.add('code-line-button', 'ui', 'basic', 'button'); @@ -128,62 +110,36 @@ function showLineButton() { } export function initRepoCodeView() { - if ($('.code-view .lines-num').length > 0) { - $(document).on('click', '.lines-num span', function (e) { - const linesEls = getLineEls(); - const selectedEls = Array.from(linesEls).filter((el) => { - return el.matches(`[rel=${this.getAttribute('id')}]`); - }); - - let from; - if (e.shiftKey) { - from = Array.from(linesEls).filter((el) => { - return el.closest('tr').classList.contains('active'); - }); - } - selectRange($(linesEls), $(selectedEls), from ? $(from) : null); - window.getSelection().removeAllRanges(); - showLineButton(); - }); - - $(window).on('hashchange', () => { - let m = rangeAnchorRegex.exec(window.location.hash); - const $linesEls = $(getLineEls()); - let $first; - if (m) { - $first = $linesEls.filter(`[rel=${m[1]}]`); - if ($first.length) { - selectRange($linesEls, $first, $linesEls.filter(`[rel=${m[2]}]`)); - - // show code view menu marker (don't show in blame page) - if (!isBlame()) { - showLineButton(); - } - - $('html, body').scrollTop($first.offset().top - 200); - return; - } - } - m = singleAnchorRegex.exec(window.location.hash); - if (m) { - $first = $linesEls.filter(`[rel=L${m[2]}]`); - if ($first.length) { - selectRange($linesEls, $first); - - // show code view menu marker (don't show in blame page) - if (!isBlame()) { - showLineButton(); - } - - $('html, body').scrollTop($first.offset().top - 200); - } - } - }).trigger('hashchange'); - } - $(document).on('click', '.fold-file', ({currentTarget}) => { - invertFileFolding(currentTarget.closest('.file-content'), currentTarget); + if (!document.querySelector('.code-view .lines-num')) return; + + let selRangeStart: string; + addDelegatedEventListener(document, 'click', '.lines-num span', (el: HTMLElement, e: KeyboardEvent) => { + if (!selRangeStart || !e.shiftKey) { + selRangeStart = el.getAttribute('id'); + selectRange(selRangeStart); + } else { + const selRangeStop = el.getAttribute('id'); + selectRange(`${selRangeStart}-${selRangeStop}`); + } + window.getSelection().removeAllRanges(); + showLineButton(); }); - $(document).on('click', '.copy-line-permalink', async ({currentTarget}) => { - await clippie(toAbsoluteUrl(currentTarget.getAttribute('data-url'))); + + const onHashChange = () => { + if (!window.location.hash) return; + const range = window.location.hash.substring(1); + const first = selectRange(range); + if (first) { + // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing + if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual'; + first.scrollIntoView({block: 'start'}); + showLineButton(); + } + }; + onHashChange(); + window.addEventListener('hashchange', onHashChange); + + addDelegatedEventListener(document, 'click', '.copy-line-permalink', (el) => { + clippie(toAbsoluteUrl(el.getAttribute('data-url'))); }); } diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 2b405abb9bc61..0cb2e566c057d 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -19,6 +19,7 @@ import { import {POST, GET} from '../modules/fetch.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {createTippy} from '../modules/tippy.ts'; +import {invertFileFolding} from './file-fold.ts'; const {pageData, i18n} = window.config; @@ -244,4 +245,8 @@ export function initRepoDiffView() { initRepoDiffFileViewToggle(); initViewedCheckboxListenerFor(); initExpandAndCollapseFilesButton(); + + addDelegatedEventListener(document, 'click', '.fold-file', (el) => { + invertFileFolding(el.closest('.file-content'), el); + }); } diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index a9dda39a7f07a..d74d3f7700258 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -373,10 +373,6 @@ export async function handleReply(el) { export function initRepoPullRequestReview() { if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) { - // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing - if (window.history.scrollRestoration !== 'manual') { - window.history.scrollRestoration = 'manual'; - } const commentDiv = document.querySelector(window.location.hash); if (commentDiv) { // get the name of the parent id @@ -384,14 +380,6 @@ export function initRepoPullRequestReview() { if (groupID && groupID.startsWith('code-comments-')) { const id = groupID.slice(14); const ancestorDiffBox = commentDiv.closest('.diff-file-box'); - // on pages like conversation, there is no diff header - const diffHeader = ancestorDiffBox?.querySelector('.diff-file-header'); - - // offset is for scrolling - let offset = 30; - if (diffHeader) { - offset += $('.diff-detail-box').outerHeight() + $(diffHeader).outerHeight(); - } hideElem(`#show-outdated-${id}`); showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`); @@ -399,12 +387,11 @@ export function initRepoPullRequestReview() { if (ancestorDiffBox?.getAttribute('data-folded') === 'true') { setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file'), false); } - - window.scrollTo({ - top: $(commentDiv).offset().top - offset, - behavior: 'instant', - }); } + // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing + if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual'; + // wait for a while because some elements (eg: image, editor, etc.) may change the viewport's height. + setTimeout(() => commentDiv.scrollIntoView({block: 'start'}), 100); } } From 3d544a3ad35f221d48591757ae3beaab0820e4ff Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 4 Jan 2025 18:47:24 +0800 Subject: [PATCH 26/55] Fix empty git repo handling logic (#33101) Fix #33092 --- models/repo/repo.go | 2 ++ options/locale/locale_en-US.ini | 1 + routers/web/repo/view_home.go | 46 +++++++++++++++++++++------------ services/context/repo.go | 3 --- templates/repo/empty.tmpl | 21 +++++++-------- templates/repo/header.tmpl | 2 +- 6 files changed, 43 insertions(+), 32 deletions(-) diff --git a/models/repo/repo.go b/models/repo/repo.go index 5ef4d470c3bf6..af4a1f7fb5888 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -279,6 +279,8 @@ func (repo *Repository) IsBroken() bool { } // MarkAsBrokenEmpty marks the repo as broken and empty +// FIXME: the status "broken" and "is_empty" were abused, +// The code always set them together, no way to distinguish whether a repo is really "empty" or "broken" func (repo *Repository) MarkAsBrokenEmpty() { repo.Status = RepositoryBroken repo.IsEmpty = true diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 6029d49ade0bc..07c9ffa9fc5ad 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1235,6 +1235,7 @@ create_new_repo_command = Creating a new repository on the command line push_exist_repo = Pushing an existing repository from the command line empty_message = This repository does not contain any content. broken_message = The Git data underlying this repository cannot be read. Contact the administrator of this instance or delete this repository. +no_branch = This repository doesn’t have any branches. code = Code code.desc = Access source code, files, commits and branches. diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index 70ba07f9a89a3..3fcd7bba8ebbb 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -223,16 +223,37 @@ func prepareRecentlyPushedNewBranches(ctx *context.Context) { } } +func updateContextRepoEmptyAndStatus(ctx *context.Context, empty bool, status repo_model.RepositoryStatus) { + ctx.Repo.Repository.IsEmpty = empty + if ctx.Repo.Repository.Status == repo_model.RepositoryReady || ctx.Repo.Repository.Status == repo_model.RepositoryBroken { + ctx.Repo.Repository.Status = status // only handle ready and broken status, leave other status as-is + } + if err := repo_model.UpdateRepositoryCols(ctx, ctx.Repo.Repository, "is_empty", "status"); err != nil { + ctx.ServerError("updateContextRepoEmptyAndStatus: UpdateRepositoryCols", err) + return + } +} + func handleRepoEmptyOrBroken(ctx *context.Context) { showEmpty := true - var err error if ctx.Repo.GitRepo != nil { - showEmpty, err = ctx.Repo.GitRepo.IsEmpty() + reallyEmpty, err := ctx.Repo.GitRepo.IsEmpty() if err != nil { + showEmpty = true // the repo is broken + updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryBroken) log.Error("GitRepo.IsEmpty: %v", err) - ctx.Repo.Repository.Status = repo_model.RepositoryBroken - showEmpty = true ctx.Flash.Error(ctx.Tr("error.occurred"), true) + } else if reallyEmpty { + showEmpty = true // the repo is really empty + updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryReady) + } else if ctx.Repo.Commit == nil { + showEmpty = true // it is not really empty, but there is no branch + // at the moment, other repo units like "actions" are not able to handle such case, + // so we just mark the repo as empty to prevent from displaying these units. + updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryReady) + } else { + // the repo is actually not empty and has branches, need to update the database later + showEmpty = false } } if showEmpty { @@ -240,18 +261,11 @@ func handleRepoEmptyOrBroken(ctx *context.Context) { return } - // the repo is not really empty, so we should update the modal in database - // such problem may be caused by: - // 1) an error occurs during pushing/receiving. 2) the user replaces an empty git repo manually - // and even more: the IsEmpty flag is deeply broken and should be removed with the UI changed to manage to cope with empty repos. - // it's possible for a repository to be non-empty by that flag but still 500 - // because there are no branches - only tags -or the default branch is non-extant as it has been 0-pushed. - ctx.Repo.Repository.IsEmpty = false - if err = repo_model.UpdateRepositoryCols(ctx, ctx.Repo.Repository, "is_empty"); err != nil { - ctx.ServerError("UpdateRepositoryCols", err) - return - } - if err = repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil { + // The repo is not really empty, so we should update the model in database, such problem may be caused by: + // 1) an error occurs during pushing/receiving. + // 2) the user replaces an empty git repo manually. + updateContextRepoEmptyAndStatus(ctx, false, repo_model.RepositoryReady) + if err := repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil { ctx.ServerError("UpdateRepoSize", err) return } diff --git a/services/context/repo.go b/services/context/repo.go index 2a473f4a5419e..63529e1d81128 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -897,10 +897,8 @@ func RepoRefByType(detectRefType RepoRefType, opts ...RepoRefByTypeOptions) func refName = brs[0].Name } else if len(brs) == 0 { log.Error("No branches in non-empty repository %s", ctx.Repo.GitRepo.Path) - ctx.Repo.Repository.MarkAsBrokenEmpty() } else { log.Error("GetBranches error: %v", err) - ctx.Repo.Repository.MarkAsBrokenEmpty() } } ctx.Repo.RefName = refName @@ -911,7 +909,6 @@ func RepoRefByType(detectRefType RepoRefType, opts ...RepoRefByTypeOptions) func } else if strings.Contains(err.Error(), "fatal: not a git repository") || strings.Contains(err.Error(), "object does not exist") { // if the repository is broken, we can continue to the handler code, to show "Settings -> Delete Repository" for end users log.Error("GetBranchCommit: %v", err) - ctx.Repo.Repository.MarkAsBrokenEmpty() } else { ctx.ServerError("GetBranchCommit", err) return diff --git a/templates/repo/empty.tmpl b/templates/repo/empty.tmpl index 7170fe360203d..dfda5b7b2b4d2 100644 --- a/templates/repo/empty.tmpl +++ b/templates/repo/empty.tmpl @@ -14,14 +14,13 @@ {{end}}
{{end}} + {{if .Repository.IsBroken}} -
- {{ctx.Locale.Tr "repo.broken_message"}} -
+
{{ctx.Locale.Tr "repo.broken_message"}}
+ {{else if .Repository.IsEmpty}} +
{{ctx.Locale.Tr "repo.no_branch"}}
{{else if .CanWriteCode}} -

- {{ctx.Locale.Tr "repo.quick_guide"}} -

+

{{ctx.Locale.Tr "repo.quick_guide"}}

{{ctx.Locale.Tr "repo.clone_this_repo"}} {{ctx.Locale.Tr "repo.clone_helper" "http://git-scm.com/book/en/v2/Git-Basics-Getting-a-Git-Repository"}}

@@ -66,12 +65,10 @@ git push -u origin {{.Repository.DefaultBranch}}
{{end}} - {{else}} -
- {{ctx.Locale.Tr "repo.empty_message"}} -
- {{end}} -
+ + {{else}} +
{{ctx.Locale.Tr "repo.empty_message"}}
+ {{end}} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index c3ae697f31fbf..e187ef1a87dd7 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -162,7 +162,7 @@
{{end}} - {{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}} + {{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo)}} {{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}} {{if .Repository.NumOpenActionRuns}} From df9d1fe8c5a628f57b74c04fed91e7dafa5eb1a5 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Jan 2025 06:25:50 +0800 Subject: [PATCH 27/55] Trivial fixes (#33103) 1. remove `gock` dependency, it is not needed 2. fix a regression from org private profile readme --- go.mod | 2 -- go.sum | 6 ----- modules/auth/password/pwn/pwn_test.go | 33 ++++++++++++++++++--------- templates/user/profile.tmpl | 2 +- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index dc80d2ca2bb3a..084b2946091f1 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,6 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/feeds v1.2.0 github.com/gorilla/sessions v1.4.0 - github.com/h2non/gock v1.2.0 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/huandu/xstrings v1.5.0 @@ -230,7 +229,6 @@ require ( github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect - github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect diff --git a/go.sum b/go.sum index b5e64321b53ed..40add64289dce 100644 --- a/go.sum +++ b/go.sum @@ -452,10 +452,6 @@ github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= -github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= -github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -599,8 +595,6 @@ github.com/msteinert/pam v1.2.0 h1:mYfjlvN2KYs2Pb9G6nb/1f/nPfAttT/Jee5Sq9r3bGE= github.com/msteinert/pam v1.2.0/go.mod h1:d2n0DCUK8rGecChV3JzvmsDjOY4R7AYbsNxAT+ftQl0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek= github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o= github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= diff --git a/modules/auth/password/pwn/pwn_test.go b/modules/auth/password/pwn/pwn_test.go index b3e7734c3fc1e..ae03fabc57905 100644 --- a/modules/auth/password/pwn/pwn_test.go +++ b/modules/auth/password/pwn/pwn_test.go @@ -4,46 +4,57 @@ package pwn import ( + "errors" + "io" "net/http" + "strings" "testing" - "time" - "github.com/h2non/gock" "github.com/stretchr/testify/assert" ) -var client = New(WithHTTP(&http.Client{ - Timeout: time.Second * 2, -})) +type mockTransport struct{} + +func (mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Host != "api.pwnedpasswords.com" { + return nil, errors.New("unsupported host") + } + respMap := map[string]string{ + "/range/5c1d8": "EAF2F254732680E8AC339B84F3266ECCBB5:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2", + "/range/ba189": "FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4", + "/range/a1733": "C4CE0F1F0062B27B9E2F41AF0C08218017C:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2\r\nFE81480327C992FE62065A827429DD1318B:0", + "/range/5617b": "FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0", + "/range/79082": "FDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0\r\nAFEF386F56EB0B4BE314E07696E5E6E6536:0", + } + if resp, ok := respMap[req.URL.Path]; ok { + return &http.Response{Request: req, Body: io.NopCloser(strings.NewReader(resp))}, nil + } + return nil, errors.New("unsupported path") +} func TestPassword(t *testing.T) { - defer gock.Off() + client := New(WithHTTP(&http.Client{Transport: mockTransport{}})) count, err := client.CheckPassword("", false) assert.ErrorIs(t, err, ErrEmptyPassword, "blank input should return ErrEmptyPassword") assert.Equal(t, -1, count) - gock.New("https://api.pwnedpasswords.com").Get("/range/5c1d8").Times(1).Reply(200).BodyString("EAF2F254732680E8AC339B84F3266ECCBB5:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2") count, err = client.CheckPassword("pwned", false) assert.NoError(t, err) assert.Equal(t, 1, count) - gock.New("https://api.pwnedpasswords.com").Get("/range/ba189").Times(1).Reply(200).BodyString("FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4") count, err = client.CheckPassword("notpwned", false) assert.NoError(t, err) assert.Equal(t, 0, count) - gock.New("https://api.pwnedpasswords.com").Get("/range/a1733").Times(1).Reply(200).BodyString("C4CE0F1F0062B27B9E2F41AF0C08218017C:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2\r\nFE81480327C992FE62065A827429DD1318B:0") count, err = client.CheckPassword("paddedpwned", true) assert.NoError(t, err) assert.Equal(t, 1, count) - gock.New("https://api.pwnedpasswords.com").Get("/range/5617b").Times(1).Reply(200).BodyString("FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0") count, err = client.CheckPassword("paddednotpwned", true) assert.NoError(t, err) assert.Equal(t, 0, count) - gock.New("https://api.pwnedpasswords.com").Get("/range/79082").Times(1).Reply(200).BodyString("FDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0\r\nAFEF386F56EB0B4BE314E07696E5E6E6536:0") count, err = client.CheckPassword("paddednotpwnedzero", true) assert.NoError(t, err) assert.Equal(t, 0, count) diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index 2c83ce97cd3df..345872b00d17e 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -26,7 +26,7 @@ {{else if eq .TabName "followers"}} {{template "repo/user_cards" .}} {{else if eq .TabName "overview"}} -
{{.ProfileReadme}}
+
{{.ProfileReadmeContent}}
{{else if eq .TabName "organizations"}} {{template "repo/user_cards" .}} {{else}} From 3078826d0111e818dc765d94c650f4cb5b3ecce7 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Sun, 5 Jan 2025 00:35:35 +0000 Subject: [PATCH 28/55] [skip ci] Updated translations via Crowdin --- options/locale/locale_pt-PT.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 0fb56f6763528..cbb7f09c82bc2 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -495,7 +495,7 @@ register_notify.text_3=Se esta conta foi criada para si,
defina a s reset_password=Recupere a sua conta reset_password.title=%s, você pediu para recuperar a sua conta -reset_password.text=Por favor clique na seguinte ligação para recuperar a sua conta em %s: +reset_password.text=Para recuperar a sua conta, clique na ligação seguinte (válida por %s): register_success=Inscrição bem sucedida @@ -1017,6 +1017,8 @@ owner=Proprietário(a) owner_helper=Algumas organizações podem não aparecer na lista suspensa devido a um limite máximo de contagem de repositórios. repo_name=Nome do repositório repo_name_profile_public_hint=.profile é um repositório especial que pode usar para adicionar README.md ao seu perfil público da organização, visível para qualquer pessoa. Certifique-se que é público e inicialize-o com um README na pasta do perfil para começar. +repo_name_profile_private_hint=.profile-private é um repositório especial que pode usar para adicionar um README.md ao seu perfil de membro da organização, visível apenas para membros da organização. Certifique-se que é privado e inicialize-o com um README na pasta de perfil para começar. +repo_name_helper=Bons nomes de repositórios usam palavras-chave curtas, memorizáveis e únicas. Um repositório chamado ".profile" ou ".profile-private" pode ser usado para adicionar um README.md ao perfil do utilizador ou da organização. repo_size=Tamanho do repositório template=Modelo template_select=Escolha um modelo. From 42377360296b7c810b284472ba6743bf684186fb Mon Sep 17 00:00:00 2001 From: ChristopherHX Date: Sun, 5 Jan 2025 14:47:18 +0100 Subject: [PATCH 29/55] workflow_dispatch use workflow from trigger branch (#33098) * htmx updates the input form on branch switch * add workflow warning to dispatch modal * use name if description of input is empty * show error if workflow_dispatch not available on branch Closes #33073 Closes #33099 --------- Co-authored-by: wxiaoguang --- options/locale/locale_en-US.ini | 1 + routers/web/repo/actions/actions.go | 216 +++++++++++------- routers/web/repo/actions/view.go | 9 +- routers/web/web.go | 3 +- templates/repo/actions/workflow_dispatch.tmpl | 27 +-- .../actions/workflow_dispatch_inputs.tmpl | 45 ++++ 6 files changed, 184 insertions(+), 117 deletions(-) create mode 100644 templates/repo/actions/workflow_dispatch_inputs.tmpl diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 07c9ffa9fc5ad..96404a6143189 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3765,6 +3765,7 @@ workflow.not_found = Workflow '%s' not found. workflow.run_success = Workflow '%s' run successfully. workflow.from_ref = Use workflow from workflow.has_workflow_dispatch = This workflow has a workflow_dispatch event trigger. +workflow.has_no_workflow_dispatch = Workflow '%s' has no workflow_dispatch event trigger. need_approval_desc = Need approval to run workflows for fork pull request. diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index f0d8d81fee598..539c4b6ed003c 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -32,8 +32,9 @@ import ( ) const ( - tplListActions templates.TplName = "repo/actions/list" - tplViewActions templates.TplName = "repo/actions/view" + tplListActions templates.TplName = "repo/actions/list" + tplDispatchInputsActions templates.TplName = "repo/actions/workflow_dispatch_inputs" + tplViewActions templates.TplName = "repo/actions/view" ) type Workflow struct { @@ -64,107 +65,143 @@ func MustEnableActions(ctx *context.Context) { func List(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("actions.actions") ctx.Data["PageIsActions"] = true + + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.ServerError("GetBranchCommit", err) + return + } + + workflows := prepareWorkflowDispatchTemplate(ctx, commit) + if ctx.Written() { + return + } + + prepareWorkflowList(ctx, workflows) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplListActions) +} + +func WorkflowDispatchInputs(ctx *context.Context) { + ref := ctx.FormString("ref") + if ref == "" { + ctx.NotFound("WorkflowDispatchInputs: no ref", nil) + return + } + // get target commit of run from specified ref + refName := git.RefName(ref) + var commit *git.Commit + var err error + if refName.IsTag() { + commit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName()) + } else if refName.IsBranch() { + commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName()) + } else { + ctx.ServerError("UnsupportedRefType", nil) + return + } + if err != nil { + ctx.ServerError("GetTagCommit/GetBranchCommit", err) + return + } + prepareWorkflowDispatchTemplate(ctx, commit) + if ctx.Written() { + return + } + ctx.HTML(http.StatusOK, tplDispatchInputsActions) +} + +func prepareWorkflowDispatchTemplate(ctx *context.Context, commit *git.Commit) (workflows []Workflow) { workflowID := ctx.FormString("workflow") - actorID := ctx.FormInt64("actor") - status := ctx.FormInt("status") ctx.Data["CurWorkflow"] = workflowID + ctx.Data["CurWorkflowExists"] = false - var workflows []Workflow var curWorkflow *model.Workflow - if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { - ctx.ServerError("IsEmpty", err) - return - } else if !empty { - commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) - if err != nil { - ctx.ServerError("GetBranchCommit", err) - return - } - entries, err := actions.ListWorkflows(commit) - if err != nil { - ctx.ServerError("ListWorkflows", err) - return - } - // Get all runner labels - runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ - RepoID: ctx.Repo.Repository.ID, - IsOnline: optional.Some(true), - WithAvailable: true, - }) + entries, err := actions.ListWorkflows(commit) + if err != nil { + ctx.ServerError("ListWorkflows", err) + return nil + } + + // Get all runner labels + runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ + RepoID: ctx.Repo.Repository.ID, + IsOnline: optional.Some(true), + WithAvailable: true, + }) + if err != nil { + ctx.ServerError("FindRunners", err) + return nil + } + allRunnerLabels := make(container.Set[string]) + for _, r := range runners { + allRunnerLabels.AddMultiple(r.AgentLabels...) + } + + workflows = make([]Workflow, 0, len(entries)) + for _, entry := range entries { + workflow := Workflow{Entry: *entry} + content, err := actions.GetContentFromEntry(entry) if err != nil { - ctx.ServerError("FindRunners", err) - return + ctx.ServerError("GetContentFromEntry", err) + return nil } - allRunnerLabels := make(container.Set[string]) - for _, r := range runners { - allRunnerLabels.AddMultiple(r.AgentLabels...) + wf, err := model.ReadWorkflow(bytes.NewReader(content)) + if err != nil { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + workflows = append(workflows, workflow) + continue } - - workflows = make([]Workflow, 0, len(entries)) - for _, entry := range entries { - workflow := Workflow{Entry: *entry} - content, err := actions.GetContentFromEntry(entry) - if err != nil { - ctx.ServerError("GetContentFromEntry", err) - return - } - wf, err := model.ReadWorkflow(bytes.NewReader(content)) - if err != nil { - workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) - workflows = append(workflows, workflow) + // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. + hasJobWithoutNeeds := false + // Check whether you have matching runner and a job without "needs" + emptyJobsNumber := 0 + for _, j := range wf.Jobs { + if j == nil { + emptyJobsNumber++ continue } - // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. - hasJobWithoutNeeds := false - // Check whether have matching runner and a job without "needs" - emptyJobsNumber := 0 - for _, j := range wf.Jobs { - if j == nil { - emptyJobsNumber++ + if !hasJobWithoutNeeds && len(j.Needs()) == 0 { + hasJobWithoutNeeds = true + } + runsOnList := j.RunsOn() + for _, ro := range runsOnList { + if strings.Contains(ro, "${{") { + // Skip if it contains expressions. + // The expressions could be very complex and could not be evaluated here, + // so just skip it, it's OK since it's just a tooltip message. continue } - if !hasJobWithoutNeeds && len(j.Needs()) == 0 { - hasJobWithoutNeeds = true - } - runsOnList := j.RunsOn() - for _, ro := range runsOnList { - if strings.Contains(ro, "${{") { - // Skip if it contains expressions. - // The expressions could be very complex and could not be evaluated here, - // so just skip it, it's OK since it's just a tooltip message. - continue - } - if !allRunnerLabels.Contains(ro) { - workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro) - break - } - } - if workflow.ErrMsg != "" { + if !allRunnerLabels.Contains(ro) { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro) break } } - if !hasJobWithoutNeeds { - workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs") - } - if emptyJobsNumber == len(wf.Jobs) { - workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") + if workflow.ErrMsg != "" { + break } - workflows = append(workflows, workflow) + } + if !hasJobWithoutNeeds { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs") + } + if emptyJobsNumber == len(wf.Jobs) { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") + } + workflows = append(workflows, workflow) - if workflow.Entry.Name() == workflowID { - curWorkflow = wf - } + if workflow.Entry.Name() == workflowID { + curWorkflow = wf + ctx.Data["CurWorkflowExists"] = true } } + ctx.Data["workflows"] = workflows ctx.Data["RepoLink"] = ctx.Repo.Repository.Link() - page := ctx.FormInt("page") - if page <= 0 { - page = 1 - } - actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() ctx.Data["ActionsConfig"] = actionsConfig @@ -188,7 +225,7 @@ func List(ctx *context.Context) { branches, err := git_model.FindBranchNames(ctx, branchOpts) if err != nil { ctx.ServerError("FindBranchNames", err) - return + return nil } // always put default branch on the top if it exists if slices.Contains(branches, ctx.Repo.Repository.DefaultBranch) { @@ -200,12 +237,23 @@ func List(ctx *context.Context) { tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("GetTagNamesByRepoID", err) - return + return nil } ctx.Data["Tags"] = tags } } } + return workflows +} + +func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { + actorID := ctx.FormInt64("actor") + status := ctx.FormInt("status") + workflowID := ctx.FormString("workflow") + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } // if status or actor query param is not given to frontend href, (href="//actions") // they will be 0 by default, which indicates get all status or actors @@ -264,8 +312,6 @@ func List(ctx *context.Context) { pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0 - - ctx.HTML(http.StatusOK, tplListActions) } // loadIsRefDeleted loads the IsRefDeleted field for each run in the list. diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index ba17fa427d1a8..9a18ca530582f 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -812,13 +812,8 @@ func Run(ctx *context_module.Context) { return } - // get workflow entry from default branch commit - defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - entries, err := actions.ListWorkflows(defaultBranchCommit) + // get workflow entry from runTargetCommit + entries, err := actions.ListWorkflows(runTargetCommit) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return diff --git a/routers/web/web.go b/routers/web/web.go index 5e0995545e814..ff91bda3d2e65 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1412,6 +1412,7 @@ func registerRoutes(m *web.Router) { m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) m.Post("/run", reqRepoActionsWriter, actions.Run) + m.Get("/workflow-dispatch-inputs", reqRepoActionsWriter, actions.WorkflowDispatchInputs) m.Group("/runs/{run}", func() { m.Combo(""). @@ -1433,7 +1434,7 @@ func registerRoutes(m *web.Router) { m.Group("/workflows/{workflow_name}", func() { m.Get("/badge.svg", actions.GetWorkflowBadge) }) - }, optSignIn, context.RepoAssignment, reqRepoActionsReader, actions.MustEnableActions) + }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions) // end "/{username}/{reponame}/actions" m.Group("/{username}/{reponame}/wiki", func() { diff --git a/templates/repo/actions/workflow_dispatch.tmpl b/templates/repo/actions/workflow_dispatch.tmpl index 21f3ef2077294..55fe1224194ea 100644 --- a/templates/repo/actions/workflow_dispatch.tmpl +++ b/templates/repo/actions/workflow_dispatch.tmpl @@ -11,7 +11,7 @@ diff --git a/templates/repo/actions/workflow_dispatch_inputs.tmpl b/templates/repo/actions/workflow_dispatch_inputs.tmpl new file mode 100644 index 0000000000000..8b8292af1d84e --- /dev/null +++ b/templates/repo/actions/workflow_dispatch_inputs.tmpl @@ -0,0 +1,45 @@ +{{if not .WorkflowDispatchConfig}} +
{{/* using "ui message" in "ui form" needs to force to display */}} + {{if not .CurWorkflowExists}} + {{ctx.Locale.Tr "actions.workflow.not_found" $.CurWorkflow}} + {{else}} + {{ctx.Locale.Tr "actions.workflow.has_no_workflow_dispatch" $.CurWorkflow}} + {{end}} +
+{{else}} + {{range $item := .WorkflowDispatchConfig.Inputs}} +
+ {{if eq .Type "choice"}} + + {{/* htmx won't initialize the fomantic dropdown, so it is a standard "select" input */}} + + {{else if eq .Type "boolean"}} + {{/* htmx doesn't trigger our JS code to attach fomantic label to checkbox, so here we use standard checkbox */}} + + {{else if eq .Type "number"}} + + + {{else}} + + + {{end}} +
+ {{end}} +
+ +
+{{end}} +{{range .workflows}} + {{if and .ErrMsg (eq .Entry.Name $.CurWorkflow)}} +
+
{{svg "octicon-alert" 16 "text red"}} {{.ErrMsg}}
+
+ {{end}} +{{end}} From cf60734a4d05215095cb2089c6fb898793dd4e37 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 6 Jan 2025 05:20:22 +0800 Subject: [PATCH 30/55] Fix dropdown menu header and mobile view (#33108) ![image](https://github.com/user-attachments/assets/3f831c8c-ef87-4282-880a-c2738f3e1d17) ---- ![image](https://github.com/user-attachments/assets/c4c0519b-cfa6-42b4-bd28-205ee514eb34) ---- ![image](https://github.com/user-attachments/assets/8624a605-9f2b-4905-9cbc-0af073972874) --- templates/base/head_navbar.tmpl | 4 ++-- templates/repo/commit_page.tmpl | 2 +- templates/user/dashboard/navbar.tmpl | 4 ++-- web_src/css/modules/navbar.css | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index bf0e7e632b71e..baf37494b98d3 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -70,7 +70,7 @@ {{svg "octicon-triangle-down"}}