From 2d36a0c9ff93004f8dabe8d137cd20d0f2263451 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 19 Oct 2025 00:37:50 +0800 Subject: [PATCH 01/29] Fix various bugs (#35684) 1. Fix incorrect column in `applySubscribedCondition`, add a test 2. Fix debian version parsing, add more tests fix #35695 3. Fix log level for HTTP errors, fix #35651 4. Fix abused "panic" handler in API `Migrate` 5. Fix the redirection from PR to issue, add a test 6. Fix Actions variable & secret name validation, add more tests * envNameCIRegexMatch is unnecessary, removed * validating in "delete" function doesn't make sense, removed 7. Fix incorrect link in release email --------- Signed-off-by: wxiaoguang Co-authored-by: delvh --- models/issues/issue_search.go | 2 +- models/issues/issue_test.go | 6 +++ modules/packages/debian/metadata.go | 2 +- modules/packages/debian/metadata_test.go | 8 ++++ modules/web/routing/logger.go | 3 +- routers/api/v1/repo/migrate.go | 50 +++++++++++----------- routers/web/repo/pull.go | 2 +- services/actions/variables.go | 34 --------------- services/secrets/secrets.go | 4 -- services/secrets/validation.go | 24 +++++++---- services/secrets/validation_test.go | 29 +++++++++++++ templates/mail/repo/release.tmpl | 4 +- tests/integration/api_repo_secrets_test.go | 4 -- tests/integration/api_user_secrets_test.go | 4 -- tests/integration/issue_test.go | 8 +++- 15 files changed, 98 insertions(+), 86 deletions(-) create mode 100644 services/secrets/validation_test.go diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 466e788d6c..049dcc7de8 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -476,7 +476,7 @@ func applySubscribedCondition(sess *xorm.Session, subscriberID int64) { ), builder.Eq{"issue.poster_id": subscriberID}, builder.In("issue.repo_id", builder. - Select("id"). + Select("repo_id"). From("watch"). Where(builder.And(builder.Eq{"user_id": subscriberID}, builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))), diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 09fd492667..55a90f50a1 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -197,6 +197,12 @@ func TestIssues(t *testing.T) { }, []int64{2}, }, + { + issues_model.IssuesOptions{ + SubscriberID: 11, + }, + []int64{11, 5, 9, 8, 3, 2, 1}, + }, } { issues, err := issues_model.Issues(t.Context(), &test.Opts) assert.NoError(t, err) diff --git a/modules/packages/debian/metadata.go b/modules/packages/debian/metadata.go index e76db63975..1cae46ecb2 100644 --- a/modules/packages/debian/metadata.go +++ b/modules/packages/debian/metadata.go @@ -46,7 +46,7 @@ var ( // https://www.debian.org/doc/debian-policy/ch-controlfields.html#source namePattern = regexp.MustCompile(`\A[a-z0-9][a-z0-9+-.]+\z`) // https://www.debian.org/doc/debian-policy/ch-controlfields.html#version - versionPattern = regexp.MustCompile(`\A(?:[0-9]:)?[a-zA-Z0-9.+~]+(?:-[a-zA-Z0-9.+-~]+)?\z`) + versionPattern = regexp.MustCompile(`\A(?:(0|[1-9][0-9]*):)?[a-zA-Z0-9.+~]+(?:-[a-zA-Z0-9.+-~]+)?\z`) ) type Package struct { diff --git a/modules/packages/debian/metadata_test.go b/modules/packages/debian/metadata_test.go index a56b131416..fedd276614 100644 --- a/modules/packages/debian/metadata_test.go +++ b/modules/packages/debian/metadata_test.go @@ -176,4 +176,12 @@ func TestParseControlFile(t *testing.T) { assert.Equal(t, []string{"a", "b"}, p.Metadata.Dependencies) assert.Equal(t, full, p.Control) }) + + t.Run("ValidVersions", func(t *testing.T) { + for _, version := range []string{"1.0", "0:1.2", "9:1.0", "10:1.0", "900:1a.2b-x-y_z~1+2"} { + p, err := ParseControlFile(buildContent("testpkg", version, "amd64")) + assert.NoError(t, err, "ParseControlFile with version %q", version) + assert.NotNil(t, p) + } + }) } diff --git a/modules/web/routing/logger.go b/modules/web/routing/logger.go index 3bca9b3420..a6a0e0d517 100644 --- a/modules/web/routing/logger.go +++ b/modules/web/routing/logger.go @@ -104,7 +104,8 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) { } logf := logInfo // lower the log level for some specific requests, in most cases these logs are not useful - if strings.HasPrefix(req.RequestURI, "/assets/") /* static assets */ || + if status > 0 && status < 400 && + strings.HasPrefix(req.RequestURI, "/assets/") /* static assets */ || req.RequestURI == "/user/events" /* Server-Sent Events (SSE) handler */ || req.RequestURI == "/api/actions/runner.v1.RunnerService/FetchTask" /* Actions Runner polling */ { logf = logTrace diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index c1e0b47d33..17259dc724 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -4,7 +4,7 @@ package repo import ( - "bytes" + gocontext "context" "errors" "fmt" "net/http" @@ -173,7 +173,7 @@ func Migrate(ctx *context.APIContext) { opts.AWSSecretAccessKey = form.AWSSecretAccessKey } - repo, err := repo_service.CreateRepositoryDirectly(ctx, ctx.Doer, repoOwner, repo_service.CreateRepoOptions{ + createdRepo, err := repo_service.CreateRepositoryDirectly(ctx, ctx.Doer, repoOwner, repo_service.CreateRepoOptions{ Name: opts.RepoName, Description: opts.Description, OriginalURL: form.CloneAddr, @@ -187,35 +187,37 @@ func Migrate(ctx *context.APIContext) { return } - opts.MigrateToRepoID = repo.ID + opts.MigrateToRepoID = createdRepo.ID - defer func() { - if e := recover(); e != nil { - var buf bytes.Buffer - fmt.Fprintf(&buf, "Handler crashed with error: %v", log.Stack(2)) - - err = errors.New(buf.String()) - } - - if err == nil { - notify_service.MigrateRepository(ctx, ctx.Doer, repoOwner, repo) - return - } - - if repo != nil { - if errDelete := repo_service.DeleteRepositoryDirectly(ctx, repo.ID); errDelete != nil { - log.Error("DeleteRepository: %v", errDelete) + doLongTimeMigrate := func(ctx gocontext.Context, doer *user_model.User) (migratedRepo *repo_model.Repository, retErr error) { + defer func() { + if e := recover(); e != nil { + log.Error("MigrateRepository panic: %v\n%s", e, log.Stack(2)) + if errDelete := repo_service.DeleteRepositoryDirectly(ctx, createdRepo.ID); errDelete != nil { + log.Error("Unable to delete repo after MigrateRepository panic: %v", errDelete) + } + retErr = errors.New("MigrateRepository panic") // no idea why it would happen, just legacy code } - } - }() + }() - if repo, err = migrations.MigrateRepository(graceful.GetManager().HammerContext(), ctx.Doer, repoOwner.Name, opts, nil); err != nil { + migratedRepo, err := migrations.MigrateRepository(ctx, doer, repoOwner.Name, opts, nil) + if err != nil { + return nil, err + } + notify_service.MigrateRepository(ctx, doer, repoOwner, migratedRepo) + return migratedRepo, nil + } + + // use a background context, don't cancel the migration even if the client goes away + // HammerContext doesn't seem right (from https://github.com/go-gitea/gitea/pull/9335/files) + // There are other abuses, maybe most HammerContext abuses should be fixed together in the future. + migratedRepo, err := doLongTimeMigrate(graceful.GetManager().HammerContext(), ctx.Doer) + if err != nil { handleMigrateError(ctx, repoOwner, err) return } - log.Trace("Repository migrated: %s/%s", repoOwner.Name, form.RepoName) - ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeAdmin})) + ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, migratedRepo, access_model.Permission{AccessMode: perm.AccessModeAdmin})) } func handleMigrateError(ctx *context.APIContext, repoOwner *user_model.User, err error) { diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index ea3e700b77..f61571231f 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -131,7 +131,7 @@ func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { ctx.Data["Issue"] = issue if !issue.IsPull { - ctx.NotFound(nil) + ctx.Redirect(issue.Link()) return nil, false } diff --git a/services/actions/variables.go b/services/actions/variables.go index 2603f1d461..57e6af1d9b 100644 --- a/services/actions/variables.go +++ b/services/actions/variables.go @@ -5,10 +5,8 @@ package actions import ( "context" - "regexp" actions_model "code.gitea.io/gitea/models/actions" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" secret_service "code.gitea.io/gitea/services/secrets" ) @@ -18,10 +16,6 @@ func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data, desc return nil, err } - if err := envNameCIRegexMatch(name); err != nil { - return nil, err - } - v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.ReserveLineBreakForTextarea(data), description) if err != nil { return nil, err @@ -35,10 +29,6 @@ func UpdateVariableNameData(ctx context.Context, variable *actions_model.ActionV return false, err } - if err := envNameCIRegexMatch(variable.Name); err != nil { - return false, err - } - variable.Data = util.ReserveLineBreakForTextarea(variable.Data) return actions_model.UpdateVariableCols(ctx, variable, "name", "data", "description") @@ -49,14 +39,6 @@ func DeleteVariableByID(ctx context.Context, variableID int64) error { } func DeleteVariableByName(ctx context.Context, ownerID, repoID int64, name string) error { - if err := secret_service.ValidateName(name); err != nil { - return err - } - - if err := envNameCIRegexMatch(name); err != nil { - return err - } - v, err := GetVariable(ctx, actions_model.FindVariablesOpts{ OwnerID: ownerID, RepoID: repoID, @@ -79,19 +61,3 @@ func GetVariable(ctx context.Context, opts actions_model.FindVariablesOpts) (*ac } return vars[0], nil } - -// some regular expression of `variables` and `secrets` -// reference to: -// https://docs.github.com/en/actions/learn-github-actions/variables#naming-conventions-for-configuration-variables -// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets -var ( - forbiddenEnvNameCIRx = regexp.MustCompile("(?i)^CI") -) - -func envNameCIRegexMatch(name string) error { - if forbiddenEnvNameCIRx.MatchString(name) { - log.Error("Env Name cannot be ci") - return util.NewInvalidArgumentErrorf("env name cannot be ci") - } - return nil -} diff --git a/services/secrets/secrets.go b/services/secrets/secrets.go index ec6a3cb062..1aec6e146d 100644 --- a/services/secrets/secrets.go +++ b/services/secrets/secrets.go @@ -56,10 +56,6 @@ func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) erro } func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) error { - if err := ValidateName(name); err != nil { - return err - } - s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ OwnerID: ownerID, RepoID: repoID, diff --git a/services/secrets/validation.go b/services/secrets/validation.go index 3db5b96452..d3636ded84 100644 --- a/services/secrets/validation.go +++ b/services/secrets/validation.go @@ -5,21 +5,29 @@ package secrets import ( "regexp" + "strings" + "sync" "code.gitea.io/gitea/modules/util" ) +// https://docs.github.com/en/actions/learn-github-actions/variables#naming-conventions-for-configuration-variables // https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets -var ( - namePattern = regexp.MustCompile("(?i)^[A-Z_][A-Z0-9_]*$") - forbiddenPrefixPattern = regexp.MustCompile("(?i)^GIT(EA|HUB)_") - - ErrInvalidName = util.NewInvalidArgumentErrorf("invalid secret name") -) +var globalVars = sync.OnceValue(func() (ret struct { + namePattern, forbiddenPrefixPattern *regexp.Regexp +}, +) { + ret.namePattern = regexp.MustCompile("(?i)^[A-Z_][A-Z0-9_]*$") + ret.forbiddenPrefixPattern = regexp.MustCompile("(?i)^GIT(EA|HUB)_") + return ret +}) func ValidateName(name string) error { - if !namePattern.MatchString(name) || forbiddenPrefixPattern.MatchString(name) { - return ErrInvalidName + vars := globalVars() + if !vars.namePattern.MatchString(name) || + vars.forbiddenPrefixPattern.MatchString(name) || + strings.EqualFold(name, "CI") /* CI is always set to true in GitHub Actions*/ { + return util.NewInvalidArgumentErrorf("invalid variable or secret name") } return nil } diff --git a/services/secrets/validation_test.go b/services/secrets/validation_test.go new file mode 100644 index 0000000000..6ee47513dc --- /dev/null +++ b/services/secrets/validation_test.go @@ -0,0 +1,29 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateName(t *testing.T) { + cases := []struct { + name string + valid bool + }{ + {"FOO", true}, + {"FOO1_BAR2", true}, + {"_FOO", true}, // really? why support this + {"1FOO", false}, + {"giteA_xx", false}, + {"githuB_xx", false}, + {"cI", false}, + } + for _, c := range cases { + err := ValidateName(c.name) + assert.Equal(t, c.valid, err == nil, "ValidateName(%q)", c.name) + } +} diff --git a/templates/mail/repo/release.tmpl b/templates/mail/repo/release.tmpl index 3172e6a19d..b306ddf5b9 100644 --- a/templates/mail/repo/release.tmpl +++ b/templates/mail/repo/release.tmpl @@ -32,10 +32,10 @@
    {{if not .DisableDownloadSourceArchives}}
  • - {{.locale.Tr "mail.release.download.zip"}} + {{.locale.Tr "mail.release.download.zip"}}
  • - {{.locale.Tr "mail.release.download.targz"}} + {{.locale.Tr "mail.release.download.targz"}}
  • {{end}} {{if .Release.Attachments}} diff --git a/tests/integration/api_repo_secrets_test.go b/tests/integration/api_repo_secrets_test.go index 2059aff484..dc37e2beb6 100644 --- a/tests/integration/api_repo_secrets_test.go +++ b/tests/integration/api_repo_secrets_test.go @@ -131,9 +131,5 @@ func TestAPIRepoSecrets(t *testing.T) { req = NewRequest(t, "DELETE", url). AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) - - req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/secrets/000", repo.FullName())). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusBadRequest) }) } diff --git a/tests/integration/api_user_secrets_test.go b/tests/integration/api_user_secrets_test.go index 10024ac090..0d2439c3cb 100644 --- a/tests/integration/api_user_secrets_test.go +++ b/tests/integration/api_user_secrets_test.go @@ -92,9 +92,5 @@ func TestAPIUserSecrets(t *testing.T) { req = NewRequest(t, "DELETE", url). AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) - - req = NewRequest(t, "DELETE", "/api/v1/user/actions/secrets/000"). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusBadRequest) }) } diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index 613353e55c..0da08796dc 100644 --- a/tests/integration/issue_test.go +++ b/tests/integration/issue_test.go @@ -497,9 +497,13 @@ func TestIssueRedirect(t *testing.T) { resp = session.MakeRequest(t, req, http.StatusSeeOther) assert.Equal(t, "/user2/repo1/pulls/2", test.RedirectURL(resp)) - repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: 1, Type: unit.TypeIssues}) + // by the way, test PR redirection + req = NewRequest(t, "GET", "/user2/repo1/pulls/1") + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/user2/repo1/issues/1", test.RedirectURL(resp)) - // disable issue unit, it will be reset + // disable issue unit + repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: 1, Type: unit.TypeIssues}) _, err := db.DeleteByID[repo_model.RepoUnit](t.Context(), repoUnit.ID) assert.NoError(t, err) From c30d74d0f9d088e3ede41f9fd4b48a0c0b0af3f0 Mon Sep 17 00:00:00 2001 From: Bryan Mutai Date: Sun, 19 Oct 2025 13:19:12 +0300 Subject: [PATCH 02/29] feat(diff): Enable commenting on expanded lines in PR diffs (#35662) Fixes #32257 /claim #32257 Implemented commenting on unchanged lines in Pull Request diffs, lines are accessed by expanding the diff preview. Comments also appear in the "Files Changed" tab on the unchanged lines where they were placed. --------- Co-authored-by: wxiaoguang --- routers/web/repo/commit.go | 23 +- routers/web/repo/compare.go | 79 +++- routers/web/repo/compare_test.go | 40 ++ routers/web/repo/pull.go | 6 + services/gitdiff/gitdiff.go | 179 +++++++-- services/gitdiff/gitdiff_test.go | 343 ++++++++++++++++++ services/pull/review.go | 10 + templates/repo/diff/blob_excerpt.tmpl | 90 ++--- templates/repo/diff/section_split.tmpl | 24 +- templates/repo/diff/section_unified.tmpl | 26 +- .../repo/issue/view_content/conversation.tmpl | 5 + web_src/css/review.css | 21 +- web_src/js/features/repo-diff.ts | 45 ++- 13 files changed, 753 insertions(+), 138 deletions(-) create mode 100644 routers/web/repo/compare_test.go diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 1a86a62fae..0383e4ca9e 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -276,20 +276,24 @@ func Diff(ctx *context.Context) { userName := ctx.Repo.Owner.Name repoName := ctx.Repo.Repository.Name commitID := ctx.PathParam("sha") - var ( - gitRepo *git.Repository - err error - ) + + diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{ + BaseLink: ctx.Repo.RepoLink + "/blob_excerpt", + DiffStyle: ctx.FormString("style"), + AfterCommitID: commitID, + } + gitRepo := ctx.Repo.GitRepo + var gitRepoStore gitrepo.Repository = ctx.Repo.Repository if ctx.Data["PageIsWiki"] != nil { - gitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo()) + var err error + gitRepoStore = ctx.Repo.Repository.WikiStorageRepo() + gitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, gitRepoStore) if err != nil { ctx.ServerError("Repo.GitRepo.GetCommit", err) return } - defer gitRepo.Close() - } else { - gitRepo = ctx.Repo.GitRepo + diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt" } commit, err := gitRepo.GetCommit(commitID) @@ -324,7 +328,7 @@ func Diff(ctx *context.Context) { ctx.NotFound(err) return } - diffShortStat, err := gitdiff.GetDiffShortStat(ctx, ctx.Repo.Repository, gitRepo, "", commitID) + diffShortStat, err := gitdiff.GetDiffShortStat(ctx, gitRepoStore, gitRepo, "", commitID) if err != nil { ctx.ServerError("GetDiffShortStat", err) return @@ -360,6 +364,7 @@ func Diff(ctx *context.Context) { ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID) ctx.Data["Commit"] = commit ctx.Data["Diff"] = diff + ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData if !fileOnly { diffTree, err := gitdiff.GetDiffTree(ctx, gitRepo, false, parentCommitID, commitID) diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 9262703078..08bd0a7e74 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "path/filepath" + "sort" "strings" "code.gitea.io/gitea/models/db" @@ -43,6 +44,7 @@ import ( "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/gitdiff" pull_service "code.gitea.io/gitea/services/pull" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -638,6 +640,11 @@ func PrepareCompareDiff( } ctx.Data["DiffShortStat"] = diffShortStat ctx.Data["Diff"] = diff + ctx.Data["DiffBlobExcerptData"] = &gitdiff.DiffBlobExcerptData{ + BaseLink: ci.HeadRepo.Link() + "/blob_excerpt", + DiffStyle: ctx.FormString("style"), + AfterCommitID: headCommitID, + } ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 if !fileOnly { @@ -865,6 +872,28 @@ func CompareDiff(ctx *context.Context) { ctx.HTML(http.StatusOK, tplCompare) } +// attachCommentsToLines attaches comments to their corresponding diff lines +func attachCommentsToLines(section *gitdiff.DiffSection, lineComments map[int64][]*issues_model.Comment) { + for _, line := range section.Lines { + if comments, ok := lineComments[int64(line.LeftIdx*-1)]; ok { + line.Comments = append(line.Comments, comments...) + } + if comments, ok := lineComments[int64(line.RightIdx)]; ok { + line.Comments = append(line.Comments, comments...) + } + sort.SliceStable(line.Comments, func(i, j int) bool { + return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix + }) + } +} + +// attachHiddenCommentIDs calculates and attaches hidden comment IDs to expand buttons +func attachHiddenCommentIDs(section *gitdiff.DiffSection, lineComments map[int64][]*issues_model.Comment) { + for _, line := range section.Lines { + gitdiff.FillHiddenCommentIDsForDiffLine(line, lineComments) + } +} + // ExcerptBlob render blob excerpt contents func ExcerptBlob(ctx *context.Context) { commitID := ctx.PathParam("sha") @@ -874,19 +903,26 @@ func ExcerptBlob(ctx *context.Context) { idxRight := ctx.FormInt("right") leftHunkSize := ctx.FormInt("left_hunk_size") rightHunkSize := ctx.FormInt("right_hunk_size") - anchor := ctx.FormString("anchor") direction := ctx.FormString("direction") filePath := ctx.FormString("path") gitRepo := ctx.Repo.GitRepo + + diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{ + BaseLink: ctx.Repo.RepoLink + "/blob_excerpt", + DiffStyle: ctx.FormString("style"), + AfterCommitID: commitID, + } + if ctx.Data["PageIsWiki"] == true { var err error - gitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo()) + gitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository.WikiStorageRepo()) if err != nil { ctx.ServerError("OpenRepository", err) return } - defer gitRepo.Close() + diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt" } + chunkSize := gitdiff.BlobExcerptChunkSize commit, err := gitRepo.GetCommit(commitID) if err != nil { @@ -947,10 +983,43 @@ func ExcerptBlob(ctx *context.Context) { section.Lines = append(section.Lines, lineSection) } } + + diffBlobExcerptData.PullIssueIndex = ctx.FormInt64("pull_issue_index") + if diffBlobExcerptData.PullIssueIndex > 0 { + if !ctx.Repo.CanRead(unit.TypePullRequests) { + ctx.NotFound(nil) + return + } + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, diffBlobExcerptData.PullIssueIndex) + if err != nil { + log.Error("GetIssueByIndex error: %v", err) + } else if issue.IsPull { + // FIXME: DIFF-CONVERSATION-DATA: the following data assignment is fragile + ctx.Data["Issue"] = issue + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + // and "diff/comment_form.tmpl" (reply comment) needs them + ctx.Data["PageIsPullFiles"] = true + ctx.Data["AfterCommitID"] = diffBlobExcerptData.AfterCommitID + + allComments, err := issues_model.FetchCodeComments(ctx, issue, ctx.Doer, ctx.FormBool("show_outdated")) + if err != nil { + log.Error("FetchCodeComments error: %v", err) + } else { + if lineComments, ok := allComments[filePath]; ok { + attachCommentsToLines(section, lineComments) + attachHiddenCommentIDs(section, lineComments) + } + } + } + } + ctx.Data["section"] = section ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath) - ctx.Data["AfterCommitID"] = commitID - ctx.Data["Anchor"] = anchor + ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData + ctx.HTML(http.StatusOK, tplBlobExcerpt) } diff --git a/routers/web/repo/compare_test.go b/routers/web/repo/compare_test.go new file mode 100644 index 0000000000..61472dc71e --- /dev/null +++ b/routers/web/repo/compare_test.go @@ -0,0 +1,40 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/services/gitdiff" + + "github.com/stretchr/testify/assert" +) + +func TestAttachCommentsToLines(t *testing.T) { + section := &gitdiff.DiffSection{ + Lines: []*gitdiff.DiffLine{ + {LeftIdx: 5, RightIdx: 10}, + {LeftIdx: 6, RightIdx: 11}, + }, + } + + lineComments := map[int64][]*issues_model.Comment{ + -5: {{ID: 100, CreatedUnix: 1000}}, // left side comment + 10: {{ID: 200, CreatedUnix: 2000}}, // right side comment + 11: {{ID: 300, CreatedUnix: 1500}, {ID: 301, CreatedUnix: 2500}}, // multiple comments + } + + attachCommentsToLines(section, lineComments) + + // First line should have left and right comments + assert.Len(t, section.Lines[0].Comments, 2) + assert.Equal(t, int64(100), section.Lines[0].Comments[0].ID) + assert.Equal(t, int64(200), section.Lines[0].Comments[1].ID) + + // Second line should have two comments, sorted by creation time + assert.Len(t, section.Lines[1].Comments, 2) + assert.Equal(t, int64(300), section.Lines[1].Comments[0].ID) + assert.Equal(t, int64(301), section.Lines[1].Comments[1].ID) +} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index f61571231f..cfe9a7ae02 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -827,6 +827,12 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { } ctx.Data["Diff"] = diff + ctx.Data["DiffBlobExcerptData"] = &gitdiff.DiffBlobExcerptData{ + BaseLink: ctx.Repo.RepoLink + "/blob_excerpt", + PullIssueIndex: pull.Index, + DiffStyle: ctx.FormString("style"), + AfterCommitID: afterCommitID, + } ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 if ctx.IsSigned && ctx.Doer != nil { diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index db0f565b52..96aea8308c 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -22,19 +22,21 @@ import ( git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" pull_model "code.gitea.io/gitea/models/pull" - repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/analyze" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/attribute" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/util" @@ -67,18 +69,6 @@ const ( DiffFileCopy ) -// DiffLineExpandDirection represents the DiffLineSection expand direction -type DiffLineExpandDirection uint8 - -// DiffLineExpandDirection possible values. -const ( - DiffLineExpandNone DiffLineExpandDirection = iota + 1 - DiffLineExpandSingle - DiffLineExpandUpDown - DiffLineExpandUp - DiffLineExpandDown -) - // DiffLine represents a line difference in a DiffSection. type DiffLine struct { LeftIdx int // line number, 1-based @@ -99,6 +89,8 @@ type DiffLineSectionInfo struct { RightIdx int LeftHunkSize int RightHunkSize int + + HiddenCommentIDs []int64 // IDs of hidden comments in this section } // DiffHTMLOperation is the HTML version of diffmatchpatch.Diff @@ -153,8 +145,7 @@ func (d *DiffLine) GetLineTypeMarker() string { return "" } -// GetBlobExcerptQuery builds query string to get blob excerpt -func (d *DiffLine) GetBlobExcerptQuery() string { +func (d *DiffLine) getBlobExcerptQuery() string { query := fmt.Sprintf( "last_left=%d&last_right=%d&"+ "left=%d&right=%d&"+ @@ -167,19 +158,88 @@ func (d *DiffLine) GetBlobExcerptQuery() string { return query } -// GetExpandDirection gets DiffLineExpandDirection -func (d *DiffLine) GetExpandDirection() DiffLineExpandDirection { +func (d *DiffLine) getExpandDirection() string { if d.Type != DiffLineSection || d.SectionInfo == nil || d.SectionInfo.LeftIdx-d.SectionInfo.LastLeftIdx <= 1 || d.SectionInfo.RightIdx-d.SectionInfo.LastRightIdx <= 1 { - return DiffLineExpandNone + return "" } if d.SectionInfo.LastLeftIdx <= 0 && d.SectionInfo.LastRightIdx <= 0 { - return DiffLineExpandUp + return "up" } else if d.SectionInfo.RightIdx-d.SectionInfo.LastRightIdx > BlobExcerptChunkSize && d.SectionInfo.RightHunkSize > 0 { - return DiffLineExpandUpDown + return "updown" } else if d.SectionInfo.LeftHunkSize <= 0 && d.SectionInfo.RightHunkSize <= 0 { - return DiffLineExpandDown + return "down" } - return DiffLineExpandSingle + return "single" +} + +type DiffBlobExcerptData struct { + BaseLink string + IsWikiRepo bool + PullIssueIndex int64 + DiffStyle string + AfterCommitID string +} + +func (d *DiffLine) RenderBlobExcerptButtons(fileNameHash string, data *DiffBlobExcerptData) template.HTML { + dataHiddenCommentIDs := strings.Join(base.Int64sToStrings(d.SectionInfo.HiddenCommentIDs), ",") + anchor := fmt.Sprintf("diff-%sK%d", fileNameHash, d.SectionInfo.RightIdx) + + makeButton := func(direction, svgName string) template.HTML { + style := util.IfZero(data.DiffStyle, "unified") + link := data.BaseLink + "/" + data.AfterCommitID + fmt.Sprintf("?style=%s&direction=%s&anchor=%s", url.QueryEscape(style), direction, url.QueryEscape(anchor)) + "&" + d.getBlobExcerptQuery() + if data.PullIssueIndex > 0 { + link += fmt.Sprintf("&pull_issue_index=%d", data.PullIssueIndex) + } + return htmlutil.HTMLFormat( + ``, + link, dataHiddenCommentIDs, svg.RenderHTML(svgName), + ) + } + var content template.HTML + + if len(d.SectionInfo.HiddenCommentIDs) > 0 { + tooltip := fmt.Sprintf("%d hidden comment(s)", len(d.SectionInfo.HiddenCommentIDs)) + content += htmlutil.HTMLFormat(`%d`, tooltip, len(d.SectionInfo.HiddenCommentIDs)) + } + + expandDirection := d.getExpandDirection() + if expandDirection == "up" || expandDirection == "updown" { + content += makeButton("up", "octicon-fold-up") + } + if expandDirection == "updown" || expandDirection == "down" { + content += makeButton("down", "octicon-fold-down") + } + if expandDirection == "single" { + content += makeButton("single", "octicon-fold") + } + return htmlutil.HTMLFormat(`
    %s
    `, expandDirection, content) +} + +// FillHiddenCommentIDsForDiffLine finds comment IDs that are in the hidden range of an expand button +func FillHiddenCommentIDsForDiffLine(line *DiffLine, lineComments map[int64][]*issues_model.Comment) { + if line.Type != DiffLineSection { + return + } + + var hiddenCommentIDs []int64 + for commentLineNum, comments := range lineComments { + if commentLineNum < 0 { + // ATTENTION: BLOB-EXCERPT-COMMENT-RIGHT: skip left-side, unchanged lines always use "right (proposed)" side for comments + continue + } + lineNum := int(commentLineNum) + isEndOfFileExpansion := line.SectionInfo.RightHunkSize == 0 + inRange := lineNum > line.SectionInfo.LastRightIdx && + (isEndOfFileExpansion && lineNum <= line.SectionInfo.RightIdx || + !isEndOfFileExpansion && lineNum < line.SectionInfo.RightIdx) + + if inRange { + for _, comment := range comments { + hiddenCommentIDs = append(hiddenCommentIDs, comment.ID) + } + } + } + line.SectionInfo.HiddenCommentIDs = hiddenCommentIDs } func getDiffLineSectionInfo(treePath, line string, lastLeftIdx, lastRightIdx int) *DiffLineSectionInfo { @@ -485,6 +545,8 @@ func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, c sort.SliceStable(line.Comments, func(i, j int) bool { return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix }) + // Mark expand buttons that have comments in hidden lines + FillHiddenCommentIDsForDiffLine(line, lineCommits) } } } @@ -1281,7 +1343,7 @@ type DiffShortStat struct { NumFiles, TotalAddition, TotalDeletion int } -func GetDiffShortStat(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, beforeCommitID, afterCommitID string) (*DiffShortStat, error) { +func GetDiffShortStat(ctx context.Context, repoStorage gitrepo.Repository, gitRepo *git.Repository, beforeCommitID, afterCommitID string) (*DiffShortStat, error) { afterCommit, err := gitRepo.GetCommit(afterCommitID) if err != nil { return nil, err @@ -1293,7 +1355,7 @@ func GetDiffShortStat(ctx context.Context, repo *repo_model.Repository, gitRepo } diff := &DiffShortStat{} - diff.NumFiles, diff.TotalAddition, diff.TotalDeletion, err = gitrepo.GetDiffShortStatByCmdArgs(ctx, repo, nil, actualBeforeCommitID.String(), afterCommitID) + diff.NumFiles, diff.TotalAddition, diff.TotalDeletion, err = gitrepo.GetDiffShortStatByCmdArgs(ctx, repoStorage, nil, actualBeforeCommitID.String(), afterCommitID) if err != nil { return nil, err } @@ -1386,6 +1448,75 @@ func CommentAsDiff(ctx context.Context, c *issues_model.Comment) (*Diff, error) return diff, nil } +// GeneratePatchForUnchangedLine creates a patch showing code context for an unchanged line +func GeneratePatchForUnchangedLine(gitRepo *git.Repository, commitID, treePath string, line int64, contextLines int) (string, error) { + commit, err := gitRepo.GetCommit(commitID) + if err != nil { + return "", fmt.Errorf("GetCommit: %w", err) + } + + entry, err := commit.GetTreeEntryByPath(treePath) + if err != nil { + return "", fmt.Errorf("GetTreeEntryByPath: %w", err) + } + + blob := entry.Blob() + dataRc, err := blob.DataAsync() + if err != nil { + return "", fmt.Errorf("DataAsync: %w", err) + } + defer dataRc.Close() + + return generatePatchForUnchangedLineFromReader(dataRc, treePath, line, contextLines) +} + +// generatePatchForUnchangedLineFromReader is the testable core logic that generates a patch from a reader +func generatePatchForUnchangedLineFromReader(reader io.Reader, treePath string, line int64, contextLines int) (string, error) { + // Calculate line range (commented line + lines above it) + commentLine := int(line) + if line < 0 { + commentLine = int(-line) + } + startLine := max(commentLine-contextLines, 1) + endLine := commentLine + + // Read only the needed lines efficiently + scanner := bufio.NewScanner(reader) + currentLine := 0 + var lines []string + for scanner.Scan() { + currentLine++ + if currentLine >= startLine && currentLine <= endLine { + lines = append(lines, scanner.Text()) + } + if currentLine > endLine { + break + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("scanner error: %w", err) + } + + if len(lines) == 0 { + return "", fmt.Errorf("no lines found in range %d-%d", startLine, endLine) + } + + // Generate synthetic patch + var patchBuilder strings.Builder + patchBuilder.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", treePath, treePath)) + patchBuilder.WriteString(fmt.Sprintf("--- a/%s\n", treePath)) + patchBuilder.WriteString(fmt.Sprintf("+++ b/%s\n", treePath)) + patchBuilder.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", startLine, len(lines), startLine, len(lines))) + + for _, lineContent := range lines { + patchBuilder.WriteString(" ") + patchBuilder.WriteString(lineContent) + patchBuilder.WriteString("\n") + } + + return patchBuilder.String(), nil +} + // CommentMustAsDiff executes AsDiff and logs the error instead of returning func CommentMustAsDiff(ctx context.Context, c *issues_model.Comment) *Diff { if c == nil { diff --git a/services/gitdiff/gitdiff_test.go b/services/gitdiff/gitdiff_test.go index 7b64b6b5f8..51fb9b58d6 100644 --- a/services/gitdiff/gitdiff_test.go +++ b/services/gitdiff/gitdiff_test.go @@ -640,3 +640,346 @@ func TestNoCrashes(t *testing.T) { ParsePatch(t.Context(), setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), "") } } + +func TestGeneratePatchForUnchangedLineFromReader(t *testing.T) { + tests := []struct { + name string + content string + treePath string + line int64 + contextLines int + want string + wantErr bool + }{ + { + name: "single line with context", + content: "line1\nline2\nline3\nline4\nline5\n", + treePath: "test.txt", + line: 3, + contextLines: 1, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -2,2 +2,2 @@ + line2 + line3 +`, + }, + { + name: "negative line number (left side)", + content: "line1\nline2\nline3\nline4\nline5\n", + treePath: "test.txt", + line: -3, + contextLines: 1, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -2,2 +2,2 @@ + line2 + line3 +`, + }, + { + name: "line near start of file", + content: "line1\nline2\nline3\n", + treePath: "test.txt", + line: 2, + contextLines: 5, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,2 @@ + line1 + line2 +`, + }, + { + name: "first line with context", + content: "line1\nline2\nline3\n", + treePath: "test.txt", + line: 1, + contextLines: 3, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -1,1 +1,1 @@ + line1 +`, + }, + { + name: "zero context lines", + content: "line1\nline2\nline3\n", + treePath: "test.txt", + line: 2, + contextLines: 0, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -2,1 +2,1 @@ + line2 +`, + }, + { + name: "multi-line context", + content: "package main\n\nfunc main() {\n fmt.Println(\"Hello\")\n}\n", + treePath: "main.go", + line: 4, + contextLines: 2, + want: `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -2,3 +2,3 @@ + + func main() { + fmt.Println("Hello") +`, + }, + { + name: "empty file", + content: "", + treePath: "empty.txt", + line: 1, + contextLines: 1, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := strings.NewReader(tt.content) + got, err := generatePatchForUnchangedLineFromReader(reader, tt.treePath, tt.line, tt.contextLines) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, strings.ReplaceAll(tt.want, "", " "), got) + } + }) + } +} + +func TestCalculateHiddenCommentIDsForLine(t *testing.T) { + tests := []struct { + name string + line *DiffLine + lineComments map[int64][]*issues_model.Comment + expected []int64 + }{ + { + name: "comments in hidden range", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 10, + RightIdx: 20, + }, + }, + lineComments: map[int64][]*issues_model.Comment{ + 15: {{ID: 100}, {ID: 101}}, + 12: {{ID: 102}}, + }, + expected: []int64{100, 101, 102}, + }, + { + name: "comments outside hidden range", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 10, + RightIdx: 20, + }, + }, + lineComments: map[int64][]*issues_model.Comment{ + 5: {{ID: 100}}, + 25: {{ID: 101}}, + }, + expected: nil, + }, + { + name: "negative line numbers (left side)", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 10, + RightIdx: 20, + }, + }, + lineComments: map[int64][]*issues_model.Comment{ + -15: {{ID: 100}}, // Left-side comment, should NOT be counted + 15: {{ID: 101}}, // Right-side comment, should be counted + }, + expected: []int64{101}, // Only right-side comment + }, + { + name: "boundary conditions - normal expansion (both boundaries exclusive)", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 10, + RightIdx: 20, + RightHunkSize: 5, // Normal case: next section has content + }, + }, + lineComments: map[int64][]*issues_model.Comment{ + 10: {{ID: 100}}, // at LastRightIdx (visible line), should NOT be included + 20: {{ID: 101}}, // at RightIdx (visible line), should NOT be included + 11: {{ID: 102}}, // just inside range, should be included + 19: {{ID: 103}}, // just inside range, should be included + }, + expected: []int64{102, 103}, + }, + { + name: "boundary conditions - end of file expansion (RightIdx inclusive)", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 54, + RightIdx: 70, + RightHunkSize: 0, // End of file: no more content after + }, + }, + lineComments: map[int64][]*issues_model.Comment{ + 54: {{ID: 54}}, // at LastRightIdx (visible line), should NOT be included + 70: {{ID: 70}}, // at RightIdx (last hidden line), SHOULD be included + 60: {{ID: 60}}, // inside range, should be included + }, + expected: []int64{60, 70}, // Lines 60 and 70 are hidden + }, + { + name: "real-world scenario - start of file with hunk", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 0, // No previous visible section + RightIdx: 26, // Line 26 is first visible line of hunk + RightHunkSize: 9, // Normal hunk with content + }, + }, + lineComments: map[int64][]*issues_model.Comment{ + 1: {{ID: 1}}, // Line 1 is hidden + 26: {{ID: 26}}, // Line 26 is visible (hunk start) - should NOT be hidden + 10: {{ID: 10}}, // Line 10 is hidden + 15: {{ID: 15}}, // Line 15 is hidden + }, + expected: []int64{1, 10, 15}, // Lines 1, 10, 15 are hidden; line 26 is visible + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + FillHiddenCommentIDsForDiffLine(tt.line, tt.lineComments) + assert.ElementsMatch(t, tt.expected, tt.line.SectionInfo.HiddenCommentIDs) + }) + } +} + +func TestDiffLine_RenderBlobExcerptButtons(t *testing.T) { + tests := []struct { + name string + line *DiffLine + fileNameHash string + data *DiffBlobExcerptData + expectContains []string + expectNotContain []string + }{ + { + name: "expand up button with hidden comments", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 0, + RightIdx: 26, + LeftIdx: 26, + LastLeftIdx: 0, + LeftHunkSize: 0, + RightHunkSize: 0, + HiddenCommentIDs: []int64{100}, + }, + }, + fileNameHash: "abc123", + data: &DiffBlobExcerptData{ + BaseLink: "/repo/blob_excerpt", + AfterCommitID: "commit123", + DiffStyle: "unified", + }, + expectContains: []string{ + "octicon-fold-up", + "direction=up", + "code-comment-more", + "1 hidden comment(s)", + }, + }, + { + name: "expand up and down buttons with pull request", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 10, + RightIdx: 50, + LeftIdx: 10, + LastLeftIdx: 5, + LeftHunkSize: 5, + RightHunkSize: 5, + HiddenCommentIDs: []int64{200, 201}, + }, + }, + fileNameHash: "def456", + data: &DiffBlobExcerptData{ + BaseLink: "/repo/blob_excerpt", + AfterCommitID: "commit456", + DiffStyle: "split", + PullIssueIndex: 42, + }, + expectContains: []string{ + "octicon-fold-down", + "octicon-fold-up", + "direction=down", + "direction=up", + `data-hidden-comment-ids=",200,201,"`, // use leading and trailing commas to ensure exact match by CSS selector `attr*=",id,"` + "pull_issue_index=42", + "2 hidden comment(s)", + }, + }, + { + name: "no hidden comments", + line: &DiffLine{ + Type: DiffLineSection, + SectionInfo: &DiffLineSectionInfo{ + LastRightIdx: 10, + RightIdx: 20, + LeftIdx: 10, + LastLeftIdx: 5, + LeftHunkSize: 5, + RightHunkSize: 5, + HiddenCommentIDs: nil, + }, + }, + fileNameHash: "ghi789", + data: &DiffBlobExcerptData{ + BaseLink: "/repo/blob_excerpt", + AfterCommitID: "commit789", + }, + expectContains: []string{ + "code-expander-button", + }, + expectNotContain: []string{ + "code-comment-more", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.line.RenderBlobExcerptButtons(tt.fileNameHash, tt.data) + resultStr := string(result) + + for _, expected := range tt.expectContains { + assert.Contains(t, resultStr, expected, "Expected to contain: %s", expected) + } + + for _, notExpected := range tt.expectNotContain { + assert.NotContains(t, resultStr, notExpected, "Expected NOT to contain: %s", notExpected) + } + }) + } +} diff --git a/services/pull/review.go b/services/pull/review.go index 357a816593..3977e351ca 100644 --- a/services/pull/review.go +++ b/services/pull/review.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/gitdiff" notify_service "code.gitea.io/gitea/services/notify" ) @@ -283,6 +284,15 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo log.Error("Error whilst generating patch: %v", err) return nil, err } + + // If patch is still empty (unchanged line), generate code context + if patch == "" && commitID != "" { + patch, err = gitdiff.GeneratePatchForUnchangedLine(gitRepo, commitID, treePath, line, setting.UI.CodeCommentLines) + if err != nil { + // Log the error but don't fail comment creation + log.Debug("Unable to generate patch for unchanged line (file=%s, line=%d, commit=%s): %v", treePath, line, commitID, err) + } + } } return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ Type: issues_model.CommentTypeCode, diff --git a/templates/repo/diff/blob_excerpt.tmpl b/templates/repo/diff/blob_excerpt.tmpl index 4089d8fb33..c9aac6d61d 100644 --- a/templates/repo/diff/blob_excerpt.tmpl +++ b/templates/repo/diff/blob_excerpt.tmpl @@ -1,28 +1,10 @@ -{{$blobExcerptLink := print $.RepoLink (Iif $.PageIsWiki "/wiki" "") "/blob_excerpt/" (PathEscape $.AfterCommitID) (QueryBuild "?" "anchor" $.Anchor)}} +{{$diffBlobExcerptData := .DiffBlobExcerptData}} +{{$canCreateComment := and ctx.RootData.SignedUserID $diffBlobExcerptData.PullIssueIndex}} {{if $.IsSplitStyle}} {{range $k, $line := $.section.Lines}} - + {{if eq .GetType 4}} - {{$expandDirection := $line.GetExpandDirection}} - -
    - {{if or (eq $expandDirection 3) (eq $expandDirection 5)}} - - {{end}} - {{if or (eq $expandDirection 3) (eq $expandDirection 4)}} - - {{end}} - {{if eq $expandDirection 2}} - - {{end}} -
    - + {{$line.RenderBlobExcerptButtons $.FileNameHash $diffBlobExcerptData}} {{- $inlineDiff := $.section.GetComputedInlineDiffFor $line ctx.Locale -}} {{- template "repo/diff/section_code" dict "diff" $inlineDiff -}} @@ -33,6 +15,12 @@ {{if and $line.LeftIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} {{if $line.LeftIdx}}{{end}} + {{/* ATTENTION: BLOB-EXCERPT-COMMENT-RIGHT: here it intentially use "right" side to comment, because the backend code depends on the assumption that the comment only happens on right side*/}} + {{- if and $canCreateComment $line.RightIdx -}} + + {{- end -}} {{- if $line.LeftIdx -}} {{- template "repo/diff/section_code" dict "diff" $inlineDiff -}} {{- else -}} @@ -43,6 +31,11 @@ {{if and $line.RightIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} {{if $line.RightIdx}}{{end}} + {{- if and $canCreateComment $line.RightIdx -}} + + {{- end -}} {{- if $line.RightIdx -}} {{- template "repo/diff/section_code" dict "diff" $inlineDiff -}} {{- else -}} @@ -51,31 +44,26 @@ {{end}} + {{if $line.Comments}} + + + {{if eq $line.GetCommentSide "previous"}} + {{template "repo/diff/conversation" dict "." $ "comments" $line.Comments}} + {{end}} + + + {{if eq $line.GetCommentSide "proposed"}} + {{template "repo/diff/conversation" dict "." $ "comments" $line.Comments}} + {{end}} + + + {{end}} {{end}} {{else}} {{range $k, $line := $.section.Lines}} - + {{if eq .GetType 4}} - {{$expandDirection := $line.GetExpandDirection}} - -
    - {{if or (eq $expandDirection 3) (eq $expandDirection 5)}} - - {{end}} - {{if or (eq $expandDirection 3) (eq $expandDirection 4)}} - - {{end}} - {{if eq $expandDirection 2}} - - {{end}} -
    - + {{$line.RenderBlobExcerptButtons $.FileNameHash $diffBlobExcerptData}} {{else}} @@ -83,7 +71,21 @@ {{$inlineDiff := $.section.GetComputedInlineDiffFor $line ctx.Locale}} {{if $inlineDiff.EscapeStatus.Escaped}}{{end}} - {{$inlineDiff.Content}} + + {{- if and $canCreateComment -}} + + {{- end -}} + {{$inlineDiff.Content}} + + {{if $line.Comments}} + + + {{template "repo/diff/conversation" dict "." $ "comments" $line.Comments}} + + + {{end}} {{end}} {{end}} diff --git a/templates/repo/diff/section_split.tmpl b/templates/repo/diff/section_split.tmpl index 9953db5eb2..ab23b1b934 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -1,5 +1,5 @@ {{$file := .file}} -{{$blobExcerptLink := print (or ctx.RootData.CommitRepoLink ctx.RootData.RepoLink) (Iif $.root.PageIsWiki "/wiki" "") "/blob_excerpt/" (PathEscape $.root.AfterCommitID) "?"}} +{{$diffBlobExcerptData := $.root.DiffBlobExcerptData}} @@ -16,26 +16,8 @@ {{if or (ne .GetType 2) (not $hasmatch)}} {{if eq .GetType 4}} - {{$expandDirection := $line.GetExpandDirection}} - -
    - {{if or (eq $expandDirection 3) (eq $expandDirection 5)}} - - {{end}} - {{if or (eq $expandDirection 3) (eq $expandDirection 4)}} - - {{end}} - {{if eq $expandDirection 2}} - - {{end}} -
    - {{$inlineDiff := $section.GetComputedInlineDiffFor $line ctx.Locale}} + {{$inlineDiff := $section.GetComputedInlineDiffFor $line ctx.Locale}} + {{$line.RenderBlobExcerptButtons $file.NameHash $diffBlobExcerptData}} {{if $inlineDiff.EscapeStatus.Escaped}}{{end}} {{template "repo/diff/section_code" dict "diff" $inlineDiff}} {{else if and (eq .GetType 3) $hasmatch}}{{/* DEL */}} diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl index cb612bc27c..908b14656e 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -1,7 +1,6 @@ {{$file := .file}} -{{$repoLink := or ctx.RootData.CommitRepoLink ctx.RootData.RepoLink}} -{{$afterCommitID := or $.root.AfterCommitID "no-after-commit-id"}}{{/* this tmpl is also used by the PR Conversation page, so the "AfterCommitID" may not exist */}} -{{$blobExcerptLink := print $repoLink (Iif $.root.PageIsWiki "/wiki" "") "/blob_excerpt/" (PathEscape $afterCommitID) "?"}} +{{/* this tmpl is also used by the PR Conversation page, so the "AfterCommitID" and "DiffBlobExcerptData" may not exist */}} +{{$diffBlobExcerptData := $.root.DiffBlobExcerptData}} @@ -14,26 +13,7 @@ {{if eq .GetType 4}} {{if $.root.AfterCommitID}} - {{$expandDirection := $line.GetExpandDirection}} - -
    - {{if or (eq $expandDirection 3) (eq $expandDirection 5)}} - - {{end}} - {{if or (eq $expandDirection 3) (eq $expandDirection 4)}} - - {{end}} - {{if eq $expandDirection 2}} - - {{end}} -
    - + {{$line.RenderBlobExcerptButtons $file.NameHash $diffBlobExcerptData}} {{else}} {{/* for code file preview page or comment diffs on pull comment pages, do not show the expansion arrows */}} diff --git a/templates/repo/issue/view_content/conversation.tmpl b/templates/repo/issue/view_content/conversation.tmpl index 189b9d6259..01261b0a56 100644 --- a/templates/repo/issue/view_content/conversation.tmpl +++ b/templates/repo/issue/view_content/conversation.tmpl @@ -1,3 +1,8 @@ +{{/* FIXME: DIFF-CONVERSATION-DATA: in the future this template should be refactor to avoid called by {{... "." $}} +At the moment, two kinds of request handler call this template: +* ExcerptBlob -> blob_excerpt.tmpl -> this +* Other compare and diff pages -> ... -> {section_unified.tmpl|section_split.tmpl} -> this) +The variables in "ctx.Data" are different in each case, making this template fragile, hard to read and maintain. */}} {{if len .comments}} {{$comment := index .comments 0}} {{$invalid := $comment.Invalidated}} diff --git a/web_src/css/review.css b/web_src/css/review.css index 23383c051c..39916d1bd8 100644 --- a/web_src/css/review.css +++ b/web_src/css/review.css @@ -115,6 +115,23 @@ color: var(--color-text); } +.code-expander-buttons { + position: relative; +} + +.code-expander-buttons .code-comment-more { + position: absolute; + line-height: 12px; + padding: 2px 4px; + font-size: 10px; + background-color: var(--color-primary); + color: var(--color-primary-contrast); + border-radius: 8px !important; + left: -12px; + top: 6px; + text-align: center; +} + .code-expander-button { border: none; color: var(--color-text-light); @@ -127,8 +144,8 @@ flex: 1; } -/* expand direction 3 is both ways with two buttons */ -.code-expander-buttons[data-expand-direction="3"] .code-expander-button { +/* expand direction "updown" is both ways with two buttons */ +.code-expander-buttons[data-expand-direction="updown"] .code-expander-button { height: 18px; } diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index bde7ec0324..24d937a252 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -9,7 +9,7 @@ import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce import {POST, GET} from '../modules/fetch.ts'; import {createTippy} from '../modules/tippy.ts'; import {invertFileFolding} from './file-fold.ts'; -import {parseDom} from '../utils.ts'; +import {parseDom, sleep} from '../utils.ts'; import {registerGlobalSelectorFunc} from '../modules/observer.ts'; const {i18n} = window.config; @@ -218,21 +218,46 @@ function initRepoDiffShowMore() { }); } -async function loadUntilFound() { - const hashTargetSelector = window.location.hash; - if (!hashTargetSelector.startsWith('#diff-') && !hashTargetSelector.startsWith('#issuecomment-')) { - return; - } +async function onLocationHashChange() { + // try to scroll to the target element by the current hash + const currentHash = window.location.hash; + if (!currentHash.startsWith('#diff-') && !currentHash.startsWith('#issuecomment-')) return; - while (true) { + // avoid reentrance when we are changing the hash to scroll and trigger ":target" selection + const attrAutoScrollRunning = 'data-auto-scroll-running'; + if (document.body.hasAttribute(attrAutoScrollRunning)) return; + + const targetElementId = currentHash.substring(1); + while (currentHash === window.location.hash) { // use getElementById to avoid querySelector throws an error when the hash is invalid // eslint-disable-next-line unicorn/prefer-query-selector - const targetElement = document.getElementById(hashTargetSelector.substring(1)); + const targetElement = document.getElementById(targetElementId); if (targetElement) { + // need to change hash to re-trigger ":target" CSS selector, let's manually scroll to it targetElement.scrollIntoView(); + document.body.setAttribute(attrAutoScrollRunning, 'true'); + window.location.hash = ''; + window.location.hash = currentHash; + setTimeout(() => document.body.removeAttribute(attrAutoScrollRunning), 0); return; } + // If looking for a hidden comment, try to expand the section that contains it + const issueCommentPrefix = '#issuecomment-'; + if (currentHash.startsWith(issueCommentPrefix)) { + const commentId = currentHash.substring(issueCommentPrefix.length); + const expandButton = document.querySelector(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`); + if (expandButton) { + // avoid infinite loop, do not re-click the button if already clicked + const attrAutoLoadClicked = 'data-auto-load-clicked'; + if (expandButton.hasAttribute(attrAutoLoadClicked)) return; + expandButton.setAttribute(attrAutoLoadClicked, 'true'); + expandButton.click(); + await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future + continue; // Try again to find the element + } + } + // the button will be refreshed after each "load more", so query it every time const showMoreButton = document.querySelector('#diff-show-more-files'); if (!showMoreButton) { @@ -246,8 +271,8 @@ async function loadUntilFound() { } function initRepoDiffHashChangeListener() { - window.addEventListener('hashchange', loadUntilFound); - loadUntilFound(); + window.addEventListener('hashchange', onLocationHashChange); + onLocationHashChange(); } export function initRepoDiffView() { From 66ee8f3553c0a4a6620c244e248f906c749dc085 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 20 Oct 2025 04:06:45 +0800 Subject: [PATCH 03/29] Avoid emoji mismatch and allow to only enable chosen emojis (#35692) Fix #23635 --- custom/conf/app.example.ini | 4 ++ modules/emoji/emoji.go | 132 ++++++++++++++++++----------------- modules/emoji/emoji_test.go | 25 +++++-- modules/markup/html_emoji.go | 34 +++++---- modules/markup/html_test.go | 9 +-- modules/setting/ui.go | 3 + 6 files changed, 115 insertions(+), 92 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index aa2fcee765..4a6356ec50 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1343,6 +1343,10 @@ LEVEL = Info ;; Dont mistake it for Reactions. ;CUSTOM_EMOJIS = gitea, codeberg, gitlab, git, github, gogs ;; +;; Comma separated list of enabled emojis, for example: smile, thumbsup, thumbsdown +;; Leave it empty to enable all emojis. +;ENABLED_EMOJIS = +;; ;; Whether the full name of the users should be shown where possible. If the full name isn't set, the username will be used. ;DEFAULT_SHOW_FULL_NAME = false ;; diff --git a/modules/emoji/emoji.go b/modules/emoji/emoji.go index 3d4ef8599b..891a0b9ab3 100644 --- a/modules/emoji/emoji.go +++ b/modules/emoji/emoji.go @@ -8,7 +8,9 @@ import ( "io" "sort" "strings" - "sync" + "sync/atomic" + + "code.gitea.io/gitea/modules/setting" ) // Gemoji is a set of emoji data. @@ -23,74 +25,78 @@ type Emoji struct { SkinTones bool } -var ( - // codeMap provides a map of the emoji unicode code to its emoji data. - codeMap map[string]int +type globalVarsStruct struct { + codeMap map[string]int // emoji unicode code to its emoji data. + aliasMap map[string]int // the alias to its emoji data. + emptyReplacer *strings.Replacer // string replacer for emoji codes, used for finding emoji positions. + codeReplacer *strings.Replacer // string replacer for emoji codes. + aliasReplacer *strings.Replacer // string replacer for emoji aliases. +} - // aliasMap provides a map of the alias to its emoji data. - aliasMap map[string]int +var globalVarsStore atomic.Pointer[globalVarsStruct] - // emptyReplacer is the string replacer for emoji codes. - emptyReplacer *strings.Replacer +func globalVars() *globalVarsStruct { + vars := globalVarsStore.Load() + if vars != nil { + return vars + } + // although there can be concurrent calls, the result should be the same, and there is no performance problem + vars = &globalVarsStruct{} + vars.codeMap = make(map[string]int, len(GemojiData)) + vars.aliasMap = make(map[string]int, len(GemojiData)) - // codeReplacer is the string replacer for emoji codes. - codeReplacer *strings.Replacer + // process emoji codes and aliases + codePairs := make([]string, 0) + emptyPairs := make([]string, 0) + aliasPairs := make([]string, 0) - // aliasReplacer is the string replacer for emoji aliases. - aliasReplacer *strings.Replacer + // sort from largest to small so we match combined emoji first + sort.Slice(GemojiData, func(i, j int) bool { + return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji) + }) - once sync.Once -) - -func loadMap() { - once.Do(func() { - // initialize - codeMap = make(map[string]int, len(GemojiData)) - aliasMap = make(map[string]int, len(GemojiData)) - - // process emoji codes and aliases - codePairs := make([]string, 0) - emptyPairs := make([]string, 0) - aliasPairs := make([]string, 0) - - // sort from largest to small so we match combined emoji first - sort.Slice(GemojiData, func(i, j int) bool { - return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji) - }) - - for i, e := range GemojiData { - if e.Emoji == "" || len(e.Aliases) == 0 { - continue - } - - // setup codes - codeMap[e.Emoji] = i - codePairs = append(codePairs, e.Emoji, ":"+e.Aliases[0]+":") - emptyPairs = append(emptyPairs, e.Emoji, e.Emoji) - - // setup aliases - for _, a := range e.Aliases { - if a == "" { - continue - } - - aliasMap[a] = i - aliasPairs = append(aliasPairs, ":"+a+":", e.Emoji) - } + for idx, emoji := range GemojiData { + if emoji.Emoji == "" || len(emoji.Aliases) == 0 { + continue } - // create replacers - emptyReplacer = strings.NewReplacer(emptyPairs...) - codeReplacer = strings.NewReplacer(codePairs...) - aliasReplacer = strings.NewReplacer(aliasPairs...) - }) + // process aliases + firstAlias := "" + for _, alias := range emoji.Aliases { + if alias == "" { + continue + } + enabled := len(setting.UI.EnabledEmojisSet) == 0 || setting.UI.EnabledEmojisSet.Contains(alias) + if !enabled { + continue + } + if firstAlias == "" { + firstAlias = alias + } + vars.aliasMap[alias] = idx + aliasPairs = append(aliasPairs, ":"+alias+":", emoji.Emoji) + } + + // process emoji code + if firstAlias != "" { + vars.codeMap[emoji.Emoji] = idx + codePairs = append(codePairs, emoji.Emoji, ":"+emoji.Aliases[0]+":") + emptyPairs = append(emptyPairs, emoji.Emoji, emoji.Emoji) + } + } + + // create replacers + vars.emptyReplacer = strings.NewReplacer(emptyPairs...) + vars.codeReplacer = strings.NewReplacer(codePairs...) + vars.aliasReplacer = strings.NewReplacer(aliasPairs...) + globalVarsStore.Store(vars) + return vars } // FromCode retrieves the emoji data based on the provided unicode code (ie, // "\u2618" will return the Gemoji data for "shamrock"). func FromCode(code string) *Emoji { - loadMap() - i, ok := codeMap[code] + i, ok := globalVars().codeMap[code] if !ok { return nil } @@ -102,12 +108,11 @@ func FromCode(code string) *Emoji { // "alias" or ":alias:" (ie, "shamrock" or ":shamrock:" will return the Gemoji // data for "shamrock"). func FromAlias(alias string) *Emoji { - loadMap() if strings.HasPrefix(alias, ":") && strings.HasSuffix(alias, ":") { alias = alias[1 : len(alias)-1] } - i, ok := aliasMap[alias] + i, ok := globalVars().aliasMap[alias] if !ok { return nil } @@ -119,15 +124,13 @@ func FromAlias(alias string) *Emoji { // alias (in the form of ":alias:") (ie, "\u2618" will be converted to // ":shamrock:"). func ReplaceCodes(s string) string { - loadMap() - return codeReplacer.Replace(s) + return globalVars().codeReplacer.Replace(s) } // ReplaceAliases replaces all aliases of the form ":alias:" with its // corresponding unicode value. func ReplaceAliases(s string) string { - loadMap() - return aliasReplacer.Replace(s) + return globalVars().aliasReplacer.Replace(s) } type rememberSecondWriteWriter struct { @@ -163,7 +166,6 @@ func (n *rememberSecondWriteWriter) WriteString(s string) (int, error) { // FindEmojiSubmatchIndex returns index pair of longest emoji in a string func FindEmojiSubmatchIndex(s string) []int { - loadMap() secondWriteWriter := rememberSecondWriteWriter{} // A faster and clean implementation would copy the trie tree formation in strings.NewReplacer but @@ -175,7 +177,7 @@ func FindEmojiSubmatchIndex(s string) []int { // Therefore we can simply take the index of the second write as our first emoji // // FIXME: just copy the trie implementation from strings.NewReplacer - _, _ = emptyReplacer.WriteString(&secondWriteWriter, s) + _, _ = globalVars().emptyReplacer.WriteString(&secondWriteWriter, s) // if we wrote less than twice then we never "replaced" if secondWriteWriter.writecount < 2 { diff --git a/modules/emoji/emoji_test.go b/modules/emoji/emoji_test.go index fbf80fe41a..607299cdc1 100644 --- a/modules/emoji/emoji_test.go +++ b/modules/emoji/emoji_test.go @@ -7,14 +7,13 @@ package emoji import ( "testing" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "github.com/stretchr/testify/assert" ) -func TestDumpInfo(t *testing.T) { - t.Logf("codes: %d", len(codeMap)) - t.Logf("aliases: %d", len(aliasMap)) -} - func TestLookup(t *testing.T) { a := FromCode("\U0001f37a") b := FromCode("🍺") @@ -24,7 +23,6 @@ func TestLookup(t *testing.T) { assert.Equal(t, a, b) assert.Equal(t, b, c) assert.Equal(t, c, d) - assert.Equal(t, a, d) m := FromCode("\U0001f44d") n := FromAlias(":thumbsup:") @@ -32,7 +30,20 @@ func TestLookup(t *testing.T) { assert.Equal(t, m, n) assert.Equal(t, m, o) - assert.Equal(t, n, o) + + defer test.MockVariableValue(&setting.UI.EnabledEmojisSet, container.SetOf("thumbsup"))() + defer globalVarsStore.Store(nil) + globalVarsStore.Store(nil) + a = FromCode("\U0001f37a") + c = FromAlias(":beer:") + m = FromCode("\U0001f44d") + n = FromAlias(":thumbsup:") + o = FromAlias("+1") + assert.Nil(t, a) + assert.Nil(t, c) + assert.NotNil(t, m) + assert.NotNil(t, n) + assert.Nil(t, o) } func TestReplacers(t *testing.T) { diff --git a/modules/markup/html_emoji.go b/modules/markup/html_emoji.go index c638065425..91ba26c676 100644 --- a/modules/markup/html_emoji.go +++ b/modules/markup/html_emoji.go @@ -5,6 +5,7 @@ package markup import ( "strings" + "unicode" "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/setting" @@ -66,26 +67,31 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { } m[0] += start m[1] += start - start = m[1] alias := node.Data[m[0]:m[1]] - alias = strings.ReplaceAll(alias, ":", "") - converted := emoji.FromAlias(alias) - if converted == nil { - // check if this is a custom reaction - if _, exist := setting.UI.CustomEmojisMap[alias]; exist { - replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) - node = node.NextSibling.NextSibling - start = 0 - continue - } + + var nextChar byte + if m[1] < len(node.Data) { + nextChar = node.Data[m[1]] + } + if nextChar == ':' || unicode.IsLetter(rune(nextChar)) || unicode.IsDigit(rune(nextChar)) { continue } - replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) - node = node.NextSibling.NextSibling - start = 0 + alias = strings.Trim(alias, ":") + converted := emoji.FromAlias(alias) + if converted != nil { + // standard emoji + replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) + node = node.NextSibling.NextSibling + start = 0 // restart searching start since node has changed + } else if _, exist := setting.UI.CustomEmojisMap[alias]; exist { + // custom reaction + replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) + node = node.NextSibling.NextSibling + start = 0 // restart searching start since node has changed + } } } diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index 5fdbf43f7c..08b050baae 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -357,12 +357,9 @@ func TestRender_emoji(t *testing.T) { `

    😎🤪🔐🤑

    `) // should match nothing - test( - "2001:0db8:85a3:0000:0000:8a2e:0370:7334", - `

    2001:0db8:85a3:0000:0000:8a2e:0370:7334

    `) - test( - ":not exist:", - `

    :not exist:

    `) + test(":100:200", `

    :100:200

    `) + test("std::thread::something", `

    std::thread::something

    `) + test(":not exist:", `

    :not exist:

    `) } func TestRender_ShortLinks(t *testing.T) { diff --git a/modules/setting/ui.go b/modules/setting/ui.go index 3d9c916bf7..13cb0f5c66 100644 --- a/modules/setting/ui.go +++ b/modules/setting/ui.go @@ -33,6 +33,8 @@ var UI = struct { ReactionsLookup container.Set[string] `ini:"-"` CustomEmojis []string CustomEmojisMap map[string]string `ini:"-"` + EnabledEmojis []string + EnabledEmojisSet container.Set[string] `ini:"-"` SearchRepoDescription bool OnlyShowRelevantRepos bool ExploreDefaultSort string `ini:"EXPLORE_PAGING_DEFAULT_SORT"` @@ -169,4 +171,5 @@ func loadUIFrom(rootCfg ConfigProvider) { for _, emoji := range UI.CustomEmojis { UI.CustomEmojisMap[emoji] = ":" + emoji + ":" } + UI.EnabledEmojisSet = container.SetOf(UI.EnabledEmojis...) } From 897e48dde331bf5896d345e5e902aa5ab398c5b1 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Mon, 20 Oct 2025 04:46:37 -0600 Subject: [PATCH 04/29] Add quick approve button on PR page (#35678) This PR adds a quick approve button on PR page to allow reviewers to approve all pending checks. Only users with write permission to the Actions unit can approve. --------- Signed-off-by: Zettat123 Co-authored-by: wxiaoguang --- models/git/commit_status.go | 58 +++++-- options/locale/locale_en-US.ini | 4 + routers/web/repo/actions/view.go | 92 ++++++++--- routers/web/repo/issue_view.go | 3 + routers/web/repo/pull.go | 32 +++- routers/web/web.go | 1 + services/actions/commit_status.go | 28 ++++ .../issue/view_content/pull_merge_box.tmpl | 3 +- templates/repo/pulls/status.tmpl | 39 +++-- tests/integration/actions_approve_test.go | 146 ++++++++++++++++++ 10 files changed, 358 insertions(+), 48 deletions(-) create mode 100644 tests/integration/actions_approve_test.go diff --git a/models/git/commit_status.go b/models/git/commit_status.go index e255bca5d0..2ae5937a3d 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -30,17 +30,21 @@ import ( // CommitStatus holds a single Status of a single Commit type CommitStatus struct { - ID int64 `xorm:"pk autoincr"` - Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` - RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` - Repo *repo_model.Repository `xorm:"-"` - State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` - SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` - TargetURL string `xorm:"TEXT"` - Description string `xorm:"TEXT"` - ContextHash string `xorm:"VARCHAR(64) index"` - Context string `xorm:"TEXT"` - Creator *user_model.User `xorm:"-"` + ID int64 `xorm:"pk autoincr"` + Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` + RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` + Repo *repo_model.Repository `xorm:"-"` + State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` + SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` + + // TargetURL points to the commit status page reported by a CI system + // If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}" + TargetURL string `xorm:"TEXT"` + + Description string `xorm:"TEXT"` + ContextHash string `xorm:"VARCHAR(64) index"` + Context string `xorm:"TEXT"` + Creator *user_model.User `xorm:"-"` CreatorID int64 CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` @@ -211,21 +215,45 @@ func (status *CommitStatus) LocaleString(lang translation.Locale) string { // HideActionsURL set `TargetURL` to an empty string if the status comes from Gitea Actions func (status *CommitStatus) HideActionsURL(ctx context.Context) { + if _, ok := status.cutTargetURLGiteaActionsPrefix(ctx); ok { + status.TargetURL = "" + } +} + +func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context) (string, bool) { if status.RepoID == 0 { - return + return "", false } if status.Repo == nil { if err := status.loadRepository(ctx); err != nil { log.Error("loadRepository: %v", err) - return + return "", false } } prefix := status.Repo.Link() + "/actions" - if strings.HasPrefix(status.TargetURL, prefix) { - status.TargetURL = "" + return strings.CutPrefix(status.TargetURL, prefix) +} + +// ParseGiteaActionsTargetURL parses the commit status target URL as Gitea Actions link +func (status *CommitStatus) ParseGiteaActionsTargetURL(ctx context.Context) (runID, jobID int64, ok bool) { + s, ok := status.cutTargetURLGiteaActionsPrefix(ctx) + if !ok { + return 0, 0, false } + + parts := strings.Split(s, "/") // expect: /runs/{runID}/jobs/{jobID} + if len(parts) < 5 || parts[1] != "runs" || parts[3] != "jobs" { + return 0, 0, false + } + + runID, err1 := strconv.ParseInt(parts[2], 10, 64) + jobID, err2 := strconv.ParseInt(parts[4], 10, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return runID, jobID, true } // CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 03017ce674..46fdf06022 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1969,6 +1969,9 @@ pulls.status_checks_requested = Required pulls.status_checks_details = Details pulls.status_checks_hide_all = Hide all checks pulls.status_checks_show_all = Show all checks +pulls.status_checks_approve_all = Approve all workflows +pulls.status_checks_need_approvals = %d workflow awaiting approval +pulls.status_checks_need_approvals_helper = The workflow will only run after approval from the repository maintainer. pulls.update_branch = Update branch by merge pulls.update_branch_rebase = Update branch by rebase pulls.update_branch_success = Branch update was successful @@ -3890,6 +3893,7 @@ workflow.has_workflow_dispatch = This workflow has a workflow_dispatch event tri 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. +approve_all_success = All workflow runs are approved successfully. variables = Variables variables.management = Variables Management diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 4250e6ff77..013dab4acf 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -606,33 +606,53 @@ func Cancel(ctx *context_module.Context) { func Approve(ctx *context_module.Context) { runIndex := getRunIndex(ctx) - current, jobs := getRunJobs(ctx, runIndex, -1) + approveRuns(ctx, []int64{runIndex}) if ctx.Written() { return } - run := current.Run - doer := ctx.Doer - var updatedJobs []*actions_model.ActionRunJob + ctx.JSONOK() +} + +func approveRuns(ctx *context_module.Context, runIndexes []int64) { + doer := ctx.Doer + repo := ctx.Repo.Repository + + updatedJobs := make([]*actions_model.ActionRunJob, 0) + runMap := make(map[int64]*actions_model.ActionRun, len(runIndexes)) + runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIndexes)) err := db.WithTx(ctx, func(ctx context.Context) (err error) { - run.NeedApproval = false - run.ApprovedBy = doer.ID - if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { - return err - } - for _, job := range jobs { - job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) + for _, runIndex := range runIndexes { + run, err := actions_model.GetRunByIndex(ctx, repo.ID, runIndex) if err != nil { return err } - if job.Status == actions_model.StatusWaiting { - n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + runMap[run.ID] = run + run.Repo = repo + run.NeedApproval = false + run.ApprovedBy = doer.ID + if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { + return err + } + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return err + } + runJobs[run.ID] = jobs + for _, job := range jobs { + job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) if err != nil { return err } - if n > 0 { - updatedJobs = append(updatedJobs, job) + if job.Status == actions_model.StatusWaiting { + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + if n > 0 { + updatedJobs = append(updatedJobs, job) + } } } } @@ -643,7 +663,9 @@ func Approve(ctx *context_module.Context) { return } - actions_service.CreateCommitStatusForRunJobs(ctx, current.Run, jobs...) + for runID, run := range runMap { + actions_service.CreateCommitStatusForRunJobs(ctx, run, runJobs[runID]...) + } if len(updatedJobs) > 0 { job := updatedJobs[0] @@ -654,8 +676,6 @@ func Approve(ctx *context_module.Context) { _ = job.LoadAttributes(ctx) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } - - ctx.JSONOK() } func Delete(ctx *context_module.Context) { @@ -818,6 +838,42 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } } +func ApproveAllChecks(ctx *context_module.Context) { + repo := ctx.Repo.Repository + commitID := ctx.FormString("commit_id") + + commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return + } + runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses) + if err != nil { + ctx.ServerError("GetRunsFromCommitStatuses", err) + return + } + + runIndexes := make([]int64, 0, len(runs)) + for _, run := range runs { + if run.NeedApproval { + runIndexes = append(runIndexes, run.Index) + } + } + + if len(runIndexes) == 0 { + ctx.JSONOK() + return + } + + approveRuns(ctx, runIndexes) + if ctx.Written() { + return + } + + ctx.Flash.Success(ctx.Tr("actions.approve_all_success")) + ctx.JSONOK() +} + func DisableWorkflowFile(ctx *context_module.Context) { disableOrEnableWorkflowFile(ctx, false) } diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index d9f6c33e3f..5866ddc402 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -437,6 +437,9 @@ func ViewIssue(ctx *context.Context) { func ViewPullMergeBox(ctx *context.Context) { issue := prepareIssueViewLoad(ctx) + if ctx.Written() { + return + } if !issue.IsPull { ctx.NotFound(nil) return diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index cfe9a7ae02..c8da025b15 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -38,6 +38,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" shared_user "code.gitea.io/gitea/routers/web/shared/user" + actions_service "code.gitea.io/gitea/services/actions" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/context" @@ -311,6 +312,14 @@ func prepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue) return compareInfo } +type pullCommitStatusCheckData struct { + MissingRequiredChecks []string // list of missing required checks + IsContextRequired func(string) bool // function to check whether a context is required + RequireApprovalRunCount int // number of workflow runs that require approval + CanApprove bool // whether the user can approve workflow runs + ApproveLink string // link to approve all checks +} + // prepareViewPullInfo show meta information for a pull request preview page func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_service.CompareInfo { ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes @@ -456,6 +465,11 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_ return nil } + statusCheckData := &pullCommitStatusCheckData{ + ApproveLink: fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", repo.Link(), sha), + } + ctx.Data["StatusCheckData"] = statusCheckData + commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) @@ -465,6 +479,20 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_ git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) } + runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses) + if err != nil { + ctx.ServerError("GetRunsFromCommitStatuses", err) + return nil + } + for _, run := range runs { + if run.NeedApproval { + statusCheckData.RequireApprovalRunCount++ + } + } + if statusCheckData.RequireApprovalRunCount > 0 { + statusCheckData.CanApprove = ctx.Repo.CanWrite(unit.TypeActions) + } + if len(commitStatuses) > 0 { ctx.Data["LatestCommitStatuses"] = commitStatuses ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) @@ -486,9 +514,9 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_ missingRequiredChecks = append(missingRequiredChecks, requiredContext) } } - ctx.Data["MissingRequiredChecks"] = missingRequiredChecks + statusCheckData.MissingRequiredChecks = missingRequiredChecks - ctx.Data["is_context_required"] = func(context string) bool { + statusCheckData.IsContextRequired = func(context string) bool { for _, c := range pb.StatusCheckContexts { if c == context { return true diff --git a/routers/web/web.go b/routers/web/web.go index 5ee211b576..9b3cfb6d16 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1459,6 +1459,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) m.Post("/run", reqRepoActionsWriter, actions.Run) m.Get("/workflow-dispatch-inputs", reqRepoActionsWriter, actions.WorkflowDispatchInputs) + m.Post("/approve-all-checks", reqRepoActionsWriter, actions.ApproveAllChecks) m.Group("/runs/{run}", func() { m.Combo(""). diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index d3f2b0f3cc..089dfeb634 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -18,6 +18,7 @@ import ( actions_module "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/commitstatus" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" @@ -52,6 +53,33 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action } } +func GetRunsFromCommitStatuses(ctx context.Context, statuses []*git_model.CommitStatus) ([]*actions_model.ActionRun, error) { + runMap := make(map[int64]*actions_model.ActionRun) + for _, status := range statuses { + runIndex, _, ok := status.ParseGiteaActionsTargetURL(ctx) + if !ok { + continue + } + _, ok = runMap[runIndex] + if !ok { + run, err := actions_model.GetRunByIndex(ctx, status.RepoID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + // the run may be deleted manually, just skip it + continue + } + return nil, fmt.Errorf("GetRunByIndex: %w", err) + } + runMap[runIndex] = run + } + } + runs := make([]*actions_model.ActionRun, 0, len(runMap)) + for _, run := range runMap { + runs = append(runs, run) + } + return runs, nil +} + func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, commitID string, _ error) { switch run.Event { case webhook_module.HookEventPush: diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl index b0ac24c9f6..c0d717e854 100644 --- a/templates/repo/issue/view_content/pull_merge_box.tmpl +++ b/templates/repo/issue/view_content/pull_merge_box.tmpl @@ -31,9 +31,8 @@ {{template "repo/pulls/status" (dict "CommitStatus" .LatestCommitStatus "CommitStatuses" .LatestCommitStatuses - "MissingRequiredChecks" .MissingRequiredChecks "ShowHideChecks" true - "is_context_required" .is_context_required + "StatusCheckData" .StatusCheckData )}} {{end}} diff --git a/templates/repo/pulls/status.tmpl b/templates/repo/pulls/status.tmpl index 96030f9422..f3c1973c2b 100644 --- a/templates/repo/pulls/status.tmpl +++ b/templates/repo/pulls/status.tmpl @@ -1,11 +1,10 @@ {{/* Template Attributes: * CommitStatus: summary of all commit status state * CommitStatuses: all commit status elements -* MissingRequiredChecks: commit check contexts that are required by branch protection but not present * ShowHideChecks: whether use a button to show/hide the checks -* is_context_required: Used in pull request commit status check table +* StatusCheckData: additional status check data, see backend pullCommitStatusCheckData struct */}} - +{{$statusCheckData := .StatusCheckData}} {{if .CommitStatus}}
    @@ -33,25 +32,43 @@ {{end}}
    + {{if and $statusCheckData $statusCheckData.RequireApprovalRunCount}} +
    +
    + + {{ctx.Locale.Tr "repo.pulls.status_checks_need_approvals" $statusCheckData.RequireApprovalRunCount}} + +

    {{ctx.Locale.Tr "repo.pulls.status_checks_need_approvals_helper"}}

    +
    + {{if $statusCheckData.CanApprove}} + + {{end}} +
    + {{end}} +
    {{range .CommitStatuses}}
    {{template "repo/commit_status" .}}
    {{.Context}} {{.Description}}
    - {{if $.is_context_required}} - {{if (call $.is_context_required .Context)}}
    {{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
    {{end}} + {{if and $statusCheckData $statusCheckData.IsContextRequired}} + {{if (call $statusCheckData.IsContextRequired .Context)}}
    {{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
    {{end}} {{end}} {{if .TargetURL}}{{ctx.Locale.Tr "repo.pulls.status_checks_details"}}{{end}}
    {{end}} - {{range .MissingRequiredChecks}} -
    - {{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} -
    {{.}}
    -
    {{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
    -
    + {{if $statusCheckData}} + {{range $statusCheckData.MissingRequiredChecks}} +
    + {{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} +
    {{.}}
    +
    {{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
    +
    + {{end}} {{end}}
    diff --git a/tests/integration/actions_approve_test.go b/tests/integration/actions_approve_test.go new file mode 100644 index 0000000000..04b8bcb715 --- /dev/null +++ b/tests/integration/actions_approve_test.go @@ -0,0 +1,146 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" +) + +func TestApproveAllRunsOnPullRequestPage(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // user2 is the owner of the base repo + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + // user4 is the owner of the fork repo + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user4Session := loginUser(t, user4.Name) + user4Token := getTokenForLoggedInUser(t, loginUser(t, user4.Name), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + apiBaseRepo := createActionsTestRepo(t, user2Token, "approve-all-runs", false) + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID}) + user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository) + defer doAPIDeleteRepository(user2APICtx)(t) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false) + + // init workflows + wf1TreePath := ".gitea/workflows/pull_1.yml" + wf1FileContent := `name: Pull 1 +on: pull_request +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - run: echo unit-test +` + opts1 := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create %s"+wf1TreePath, wf1FileContent) + createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wf1TreePath, opts1) + wf2TreePath := ".gitea/workflows/pull_2.yml" + wf2FileContent := `name: Pull 2 +on: pull_request +jobs: + integration-test: + runs-on: ubuntu-latest + steps: + - run: echo integration-test +` + opts2 := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create %s"+wf2TreePath, wf2FileContent) + createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wf2TreePath, opts2) + + // user4 forks the repo + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name), + &api.CreateForkOption{ + Name: util.ToPointer("approve-all-runs-fork"), + }).AddTokenAuth(user4Token) + resp := MakeRequest(t, req, http.StatusAccepted) + var apiForkRepo api.Repository + DecodeJSON(t, resp, &apiForkRepo) + forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID}) + user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository) + defer doAPIDeleteRepository(user4APICtx)(t) + + // user4 creates a pull request from branch "bugfix/user4" + doAPICreateFile(user4APICtx, "user4-fix.txt", &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + NewBranchName: "bugfix/user4", + Message: "create user4-fix.txt", + Author: api.Identity{ + Name: user4.Name, + Email: user4.Email, + }, + Committer: api.Identity{ + Name: user4.Name, + Email: user4.Email, + }, + Dates: api.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte("user4-fix")), + })(t) + apiPull, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":bugfix/user4")(t) + assert.NoError(t, err) + + // check runs + run1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, WorkflowID: "pull_1.yml"}) + assert.True(t, run1.NeedApproval) + assert.Equal(t, actions_model.StatusBlocked, run1.Status) + run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, WorkflowID: "pull_2.yml"}) + assert.True(t, run2.NeedApproval) + assert.Equal(t, actions_model.StatusBlocked, run2.Status) + + // user4 cannot see the approve button + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", baseRepo.OwnerName, baseRepo.Name, apiPull.Index)) + resp = user4Session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Zero(t, htmlDoc.doc.Find("#approve-status-checks button.link-action").Length()) + + // user2 can see the approve button + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", baseRepo.OwnerName, baseRepo.Name, apiPull.Index)) + resp = user2Session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + dataURL, exist := htmlDoc.doc.Find("#approve-status-checks button.link-action").Attr("data-url") + assert.True(t, exist) + assert.Equal(t, + fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", + baseRepo.Link(), apiPull.Head.Sha), + dataURL, + ) + + // user2 approves all runs + req = NewRequestWithValues(t, "POST", dataURL, + map[string]string{ + "_csrf": GetUserCSRFToken(t, user2Session), + }) + user2Session.MakeRequest(t, req, http.StatusOK) + + // check runs + run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run1.ID}) + assert.False(t, run1.NeedApproval) + assert.Equal(t, user2.ID, run1.ApprovedBy) + assert.Equal(t, actions_model.StatusWaiting, run1.Status) + run2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2.ID}) + assert.False(t, run2.NeedApproval) + assert.Equal(t, user2.ID, run2.ApprovedBy) + assert.Equal(t, actions_model.StatusWaiting, run2.Status) + }) +} From b2ee5be52e2c4327cc86f1805460e12e44a1ca0d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 21 Oct 2025 02:43:08 +0800 Subject: [PATCH 05/29] Refactor legacy code (#35708) And by the way, remove the legacy TODO, split large functions into small ones, and add more tests --- cmd/serv.go | 27 +-- models/asymkey/ssh_key.go | 7 - models/asymkey/ssh_key_authorized_keys.go | 73 ++++--- models/repo/upload.go | 11 +- modules/git/hook.go | 22 +- modules/setting/config_provider.go | 6 +- modules/util/path.go | 11 +- routers/private/key.go | 13 +- .../asymkey/ssh_key_authorized_principals.go | 14 +- services/doctor/authorizedkeys.go | 6 +- services/lfs/server.go | 33 ++- services/lfs/server_test.go | 51 +++++ services/repository/generate.go | 197 ++++++++++-------- services/repository/generate_test.go | 170 ++++++++++++--- tests/integration/cmd_keys_test.go | 2 +- 15 files changed, 407 insertions(+), 236 deletions(-) create mode 100644 services/lfs/server_test.go diff --git a/cmd/serv.go b/cmd/serv.go index 76d8c81544..72ca7c4a00 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -13,7 +13,6 @@ import ( "path/filepath" "strconv" "strings" - "time" "unicode" asymkey_model "code.gitea.io/gitea/models/asymkey" @@ -32,7 +31,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/lfs" - "github.com/golang-jwt/jwt/v5" "github.com/kballard/go-shellquote" "github.com/urfave/cli/v3" ) @@ -133,27 +131,6 @@ func getAccessMode(verb, lfsVerb string) perm.AccessMode { return perm.AccessModeNone } -func getLFSAuthToken(ctx context.Context, lfsVerb string, results *private.ServCommandResults) (string, error) { - now := time.Now() - claims := lfs.Claims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)), - NotBefore: jwt.NewNumericDate(now), - }, - RepoID: results.RepoID, - Op: lfsVerb, - UserID: results.UserID, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - // Sign and get the complete encoded token as a string using the secret - tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes) - if err != nil { - return "", fail(ctx, "Failed to sign JWT Token", "Failed to sign JWT token: %v", err) - } - return "Bearer " + tokenString, nil -} - func runServ(ctx context.Context, c *cli.Command) error { // FIXME: This needs to internationalised setup(ctx, c.Bool("debug")) @@ -283,7 +260,7 @@ func runServ(ctx context.Context, c *cli.Command) error { // LFS SSH protocol if verb == git.CmdVerbLfsTransfer { - token, err := getLFSAuthToken(ctx, lfsVerb, results) + token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID}) if err != nil { return err } @@ -294,7 +271,7 @@ func runServ(ctx context.Context, c *cli.Command) error { if verb == git.CmdVerbLfsAuthenticate { url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName)) - token, err := getLFSAuthToken(ctx, lfsVerb, results) + token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID}) if err != nil { return err } diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go index 87205f0651..d77b5d46a7 100644 --- a/models/asymkey/ssh_key.go +++ b/models/asymkey/ssh_key.go @@ -67,13 +67,6 @@ func (key *PublicKey) OmitEmail() string { return strings.Join(strings.Split(key.Content, " ")[:2], " ") } -// AuthorizedString returns formatted public key string for authorized_keys file. -// -// TODO: Consider dropping this function -func (key *PublicKey) AuthorizedString() string { - return AuthorizedStringForKey(key) -} - func addKey(ctx context.Context, key *PublicKey) (err error) { if len(key.Fingerprint) == 0 { key.Fingerprint, err = CalcFingerprint(key.Content) diff --git a/models/asymkey/ssh_key_authorized_keys.go b/models/asymkey/ssh_key_authorized_keys.go index 2e4cd62e5c..db4730f00a 100644 --- a/models/asymkey/ssh_key_authorized_keys.go +++ b/models/asymkey/ssh_key_authorized_keys.go @@ -17,29 +17,13 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + + "golang.org/x/crypto/ssh" ) -// _____ __ .__ .__ .___ -// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ -// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | -// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | -// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | -// \/ \/ \/ \/ \/ -// ____ __. -// | |/ _|____ ___.__. ______ -// | <_/ __ < | |/ ___/ -// | | \ ___/\___ |\___ \ -// |____|__ \___ > ____/____ > -// \/ \/\/ \/ -// -// This file contains functions for creating authorized_keys files -// -// There is a dependence on the database within RegeneratePublicKeys however most of these functions probably belong in a module - -const ( - tplCommentPrefix = `# gitea public key` - tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n" -) +// AuthorizedStringCommentPrefix is a magic tag +// some functions like RegeneratePublicKeys needs this tag to skip the keys generated by Gitea, while keep other keys +const AuthorizedStringCommentPrefix = `# gitea public key` var sshOpLocker sync.Mutex @@ -50,17 +34,45 @@ func WithSSHOpLocker(f func() error) error { } // AuthorizedStringForKey creates the authorized keys string appropriate for the provided key -func AuthorizedStringForKey(key *PublicKey) string { +func AuthorizedStringForKey(key *PublicKey) (string, error) { sb := &strings.Builder{} - _ = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sb, map[string]any{ + _, err := writeAuthorizedStringForKey(key, sb) + return sb.String(), err +} + +// WriteAuthorizedStringForValidKey writes the authorized key for the provided key. If the key is invalid, it does nothing. +func WriteAuthorizedStringForValidKey(key *PublicKey, w io.Writer) error { + validKey, err := writeAuthorizedStringForKey(key, w) + if !validKey { + log.Debug("WriteAuthorizedStringForValidKey: key %s is not valid: %v", key, err) + return nil + } + return err +} + +func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, err error) { + const tpl = AuthorizedStringCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s %s` + "\n" + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content)) + if err != nil { + return false, err + } + // now the key is valid, the code below could only return template/IO related errors + sbCmd := &strings.Builder{} + err = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sbCmd, map[string]any{ "AppPath": util.ShellEscape(setting.AppPath), "AppWorkPath": util.ShellEscape(setting.AppWorkPath), "CustomConf": util.ShellEscape(setting.CustomConf), "CustomPath": util.ShellEscape(setting.CustomPath), "Key": key, }) - - return fmt.Sprintf(tplPublicKey, util.ShellEscape(sb.String()), key.Content) + if err != nil { + return true, err + } + sshCommandEscaped := util.ShellEscape(sbCmd.String()) + sshKeyMarshalled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) + sshKeyComment := fmt.Sprintf("user-%d", key.OwnerID) + _, err = fmt.Fprintf(w, tpl, sshCommandEscaped, sshKeyMarshalled, sshKeyComment) + return true, err } // appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file. @@ -112,7 +124,7 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { if key.Type == KeyTypePrincipal { continue } - if _, err = f.WriteString(key.AuthorizedString()); err != nil { + if err = WriteAuthorizedStringForValidKey(key, f); err != nil { return err } } @@ -120,10 +132,9 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { } // RegeneratePublicKeys regenerates the authorized_keys file -func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { +func RegeneratePublicKeys(ctx context.Context, t io.Writer) error { if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { - _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) - return err + return WriteAuthorizedStringForValidKey(bean.(*PublicKey), t) }); err != nil { return err } @@ -144,11 +155,11 @@ func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, tplCommentPrefix) { + if strings.HasPrefix(line, AuthorizedStringCommentPrefix) { scanner.Scan() continue } - _, err = t.WriteString(line + "\n") + _, err = io.WriteString(t, line+"\n") if err != nil { return err } diff --git a/models/repo/upload.go b/models/repo/upload.go index f7d4749842..b9bda8fdbf 100644 --- a/models/repo/upload.go +++ b/models/repo/upload.go @@ -127,16 +127,9 @@ func DeleteUploads(ctx context.Context, uploads ...*Upload) (err error) { for _, upload := range uploads { localPath := upload.LocalPath() - isFile, err := util.IsFile(localPath) - if err != nil { - log.Error("Unable to check if %s is a file. Error: %v", localPath, err) - } - if !isFile { - continue - } - if err := util.Remove(localPath); err != nil { - return fmt.Errorf("remove upload: %w", err) + // just continue, don't fail the whole operation if a file is missing (removed by others) + log.Error("unable to remove upload file %s: %v", localPath, err) } } diff --git a/modules/git/hook.go b/modules/git/hook.go index 361aa53100..0e19387d97 100644 --- a/modules/git/hook.go +++ b/modules/git/hook.go @@ -47,30 +47,16 @@ func GetHook(repoPath, name string) (*Hook, error) { name: name, path: filepath.Join(repoPath, filepath.Join("hooks", name+".d", name)), } - isFile, err := util.IsFile(h.path) - if err != nil { - return nil, err - } - if isFile { - data, err := os.ReadFile(h.path) - if err != nil { - return nil, err - } + if data, err := os.ReadFile(h.path); err == nil { h.IsActive = true h.Content = string(data) return h, nil + } else if !os.IsNotExist(err) { + return nil, err } samplePath := filepath.Join(repoPath, "hooks", name+".sample") - isFile, err = util.IsFile(samplePath) - if err != nil { - return nil, err - } - if isFile { - data, err := os.ReadFile(samplePath) - if err != nil { - return nil, err - } + if data, err := os.ReadFile(samplePath); err == nil { h.Sample = string(data) } return h, nil diff --git a/modules/setting/config_provider.go b/modules/setting/config_provider.go index 09eaaefdaf..777885cb4f 100644 --- a/modules/setting/config_provider.go +++ b/modules/setting/config_provider.go @@ -202,11 +202,11 @@ func NewConfigProviderFromFile(file string) (ConfigProvider, error) { loadedFromEmpty := true if file != "" { - isFile, err := util.IsFile(file) + isExist, err := util.IsExist(file) if err != nil { - return nil, fmt.Errorf("unable to check if %q is a file. Error: %v", file, err) + return nil, fmt.Errorf("unable to check if %q exists: %v", file, err) } - if isFile { + if isExist { if err = cfg.Append(file); err != nil { return nil, fmt.Errorf("failed to load config file %q: %v", file, err) } diff --git a/modules/util/path.go b/modules/util/path.go index 0e56348978..0cb8ab7ece 100644 --- a/modules/util/path.go +++ b/modules/util/path.go @@ -115,15 +115,10 @@ func IsDir(dir string) (bool, error) { return false, err } -// IsFile returns true if given path is a file, -// or returns false when it's a directory or does not exist. -func IsFile(filePath string) (bool, error) { - f, err := os.Stat(filePath) +func IsRegularFile(filePath string) (bool, error) { + f, err := os.Lstat(filePath) if err == nil { - return !f.IsDir(), nil - } - if os.IsNotExist(err) { - return false, nil + return f.Mode().IsRegular(), nil } return false, err } diff --git a/routers/private/key.go b/routers/private/key.go index 9fd0a16c07..2be80477a9 100644 --- a/routers/private/key.go +++ b/routers/private/key.go @@ -45,7 +45,7 @@ func UpdatePublicKeyInRepo(ctx *context.PrivateContext) { ctx.PlainText(http.StatusOK, "success") } -// AuthorizedPublicKeyByContent searches content as prefix (leak e-mail part) +// AuthorizedPublicKeyByContent searches content as prefix (without comment part) // and returns public key found. func AuthorizedPublicKeyByContent(ctx *context.PrivateContext) { content := ctx.FormString("content") @@ -57,5 +57,14 @@ func AuthorizedPublicKeyByContent(ctx *context.PrivateContext) { }) return } - ctx.PlainText(http.StatusOK, publicKey.AuthorizedString()) + + authorizedString, err := asymkey_model.AuthorizedStringForKey(publicKey) + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + UserMsg: "invalid public key", + }) + return + } + ctx.PlainText(http.StatusOK, authorizedString) } diff --git a/services/asymkey/ssh_key_authorized_principals.go b/services/asymkey/ssh_key_authorized_principals.go index 2838bb5fc7..b06d8d2a40 100644 --- a/services/asymkey/ssh_key_authorized_principals.go +++ b/services/asymkey/ssh_key_authorized_principals.go @@ -25,10 +25,7 @@ import ( // There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys // The sshOpLocker is used from ssh_key_authorized_keys.go -const ( - authorizedPrincipalsFile = "authorized_principals" - tplCommentPrefix = `# gitea public key` -) +const authorizedPrincipalsFile = "authorized_principals" // RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again. // Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function @@ -90,10 +87,9 @@ func rewriteAllPrincipalKeys(ctx context.Context) error { return util.Rename(tmpPath, fPath) } -func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error { +func regeneratePrincipalKeys(ctx context.Context, t io.Writer) error { if err := db.GetEngine(ctx).Where("type = ?", asymkey_model.KeyTypePrincipal).Iterate(new(asymkey_model.PublicKey), func(idx int, bean any) (err error) { - _, err = t.WriteString((bean.(*asymkey_model.PublicKey)).AuthorizedString()) - return err + return asymkey_model.WriteAuthorizedStringForValidKey(bean.(*asymkey_model.PublicKey), t) }); err != nil { return err } @@ -114,11 +110,11 @@ func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error { scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, tplCommentPrefix) { + if strings.HasPrefix(line, asymkey_model.AuthorizedStringCommentPrefix) { scanner.Scan() continue } - _, err = t.WriteString(line + "\n") + _, err = io.WriteString(t, line+"\n") if err != nil { return err } diff --git a/services/doctor/authorizedkeys.go b/services/doctor/authorizedkeys.go index 46e7099dce..fe1dc639e8 100644 --- a/services/doctor/authorizedkeys.go +++ b/services/doctor/authorizedkeys.go @@ -20,8 +20,6 @@ import ( asymkey_service "code.gitea.io/gitea/services/asymkey" ) -const tplCommentPrefix = `# gitea public key` - func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) error { if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { return nil @@ -47,7 +45,7 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, tplCommentPrefix) { + if strings.HasPrefix(line, asymkey_model.AuthorizedStringCommentPrefix) { continue } linesInAuthorizedKeys.Add(line) @@ -67,7 +65,7 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e scanner = bufio.NewScanner(regenerated) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, tplCommentPrefix) { + if strings.HasPrefix(line, asymkey_model.AuthorizedStringCommentPrefix) { continue } if linesInAuthorizedKeys.Contains(line) { diff --git a/services/lfs/server.go b/services/lfs/server.go index 9f2e532f23..4a1e0aaf7d 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -18,6 +18,7 @@ import ( "regexp" "strconv" "strings" + "time" actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" @@ -54,6 +55,33 @@ type Claims struct { jwt.RegisteredClaims } +type AuthTokenOptions struct { + Op string + UserID int64 + RepoID int64 +} + +func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) { + now := time.Now() + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)), + NotBefore: jwt.NewNumericDate(now), + }, + RepoID: opts.RepoID, + Op: opts.Op, + UserID: opts.UserID, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign and get the complete encoded token as a string using the secret + tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes) + if err != nil { + return "", fmt.Errorf("failed to sign LFS JWT token: %w", err) + } + return "Bearer " + tokenString, nil +} + // DownloadLink builds a URL to download the object. func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string { return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid)) @@ -559,9 +587,6 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho } func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repository, mode perm_model.AccessMode) (*user_model.User, error) { - if !strings.Contains(tokenSHA, ".") { - return nil, nil - } token, err := jwt.ParseWithClaims(tokenSHA, &Claims{}, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) @@ -569,7 +594,7 @@ func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repo return setting.LFS.JWTSecretBytes, nil }) if err != nil { - return nil, nil + return nil, errors.New("invalid token") } claims, claimsOk := token.Claims.(*Claims) diff --git a/services/lfs/server_test.go b/services/lfs/server_test.go new file mode 100644 index 0000000000..e66e48cee8 --- /dev/null +++ b/services/lfs/server_test.go @@ -0,0 +1,51 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package lfs + +import ( + "strings" + "testing" + + perm_model "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func TestAuthenticate(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + token2, _ := GetLFSAuthTokenWithBearer(AuthTokenOptions{Op: "download", UserID: 2, RepoID: 1}) + _, token2, _ = strings.Cut(token2, " ") + ctx, _ := contexttest.MockContext(t, "/") + + t.Run("handleLFSToken", func(t *testing.T) { + u, err := handleLFSToken(ctx, "", repo1, perm_model.AccessModeRead) + require.Error(t, err) + assert.Nil(t, u) + + u, err = handleLFSToken(ctx, "invalid", repo1, perm_model.AccessModeRead) + require.Error(t, err) + assert.Nil(t, u) + + u, err = handleLFSToken(ctx, token2, repo1, perm_model.AccessModeRead) + require.NoError(t, err) + assert.EqualValues(t, 2, u.ID) + }) + + t.Run("authenticate", func(t *testing.T) { + const prefixBearer = "Bearer " + assert.False(t, authenticate(ctx, repo1, "", true, false)) + assert.False(t, authenticate(ctx, repo1, prefixBearer+"invalid", true, false)) + assert.True(t, authenticate(ctx, repo1, prefixBearer+token2, true, false)) + }) +} diff --git a/services/repository/generate.go b/services/repository/generate.go index 8e7f9a45b0..062c6f4fb1 100644 --- a/services/repository/generate.go +++ b/services/repository/generate.go @@ -13,6 +13,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" git_model "code.gitea.io/gitea/models/git" @@ -40,29 +41,41 @@ type expansion struct { Transformers []transformer } -var defaultTransformers = []transformer{ - {Name: "SNAKE", Transform: xstrings.ToSnakeCase}, - {Name: "KEBAB", Transform: xstrings.ToKebabCase}, - {Name: "CAMEL", Transform: xstrings.ToCamelCase}, - {Name: "PASCAL", Transform: xstrings.ToPascalCase}, - {Name: "LOWER", Transform: strings.ToLower}, - {Name: "UPPER", Transform: strings.ToUpper}, - {Name: "TITLE", Transform: util.ToTitleCase}, -} +var globalVars = sync.OnceValue(func() (ret struct { + defaultTransformers []transformer + fileNameSanitizeRegexp *regexp.Regexp +}, +) { + ret.defaultTransformers = []transformer{ + {Name: "SNAKE", Transform: xstrings.ToSnakeCase}, + {Name: "KEBAB", Transform: xstrings.ToKebabCase}, + {Name: "CAMEL", Transform: xstrings.ToCamelCase}, + {Name: "PASCAL", Transform: xstrings.ToPascalCase}, + {Name: "LOWER", Transform: strings.ToLower}, + {Name: "UPPER", Transform: strings.ToUpper}, + {Name: "TITLE", Transform: util.ToTitleCase}, + } -func generateExpansion(ctx context.Context, src string, templateRepo, generateRepo *repo_model.Repository, sanitizeFileName bool) string { + // invalid filename contents, based on https://github.com/sindresorhus/filename-reserved-regex + // "COM10" needs to be opened with UNC "\\.\COM10" on Windows, so itself is valid + ret.fileNameSanitizeRegexp = regexp.MustCompile(`(?i)[<>:"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`) + return ret +}) + +func generateExpansion(ctx context.Context, src string, templateRepo, generateRepo *repo_model.Repository) string { + transformers := globalVars().defaultTransformers year, month, day := time.Now().Date() expansions := []expansion{ {Name: "YEAR", Value: strconv.Itoa(year), Transformers: nil}, {Name: "MONTH", Value: fmt.Sprintf("%02d", int(month)), Transformers: nil}, - {Name: "MONTH_ENGLISH", Value: month.String(), Transformers: defaultTransformers}, + {Name: "MONTH_ENGLISH", Value: month.String(), Transformers: transformers}, {Name: "DAY", Value: fmt.Sprintf("%02d", day), Transformers: nil}, - {Name: "REPO_NAME", Value: generateRepo.Name, Transformers: defaultTransformers}, - {Name: "TEMPLATE_NAME", Value: templateRepo.Name, Transformers: defaultTransformers}, + {Name: "REPO_NAME", Value: generateRepo.Name, Transformers: transformers}, + {Name: "TEMPLATE_NAME", Value: templateRepo.Name, Transformers: transformers}, {Name: "REPO_DESCRIPTION", Value: generateRepo.Description, Transformers: nil}, {Name: "TEMPLATE_DESCRIPTION", Value: templateRepo.Description, Transformers: nil}, - {Name: "REPO_OWNER", Value: generateRepo.OwnerName, Transformers: defaultTransformers}, - {Name: "TEMPLATE_OWNER", Value: templateRepo.OwnerName, Transformers: defaultTransformers}, + {Name: "REPO_OWNER", Value: generateRepo.OwnerName, Transformers: transformers}, + {Name: "TEMPLATE_OWNER", Value: templateRepo.OwnerName, Transformers: transformers}, {Name: "REPO_LINK", Value: generateRepo.Link(), Transformers: nil}, {Name: "TEMPLATE_LINK", Value: templateRepo.Link(), Transformers: nil}, {Name: "REPO_HTTPS_URL", Value: generateRepo.CloneLinkGeneral(ctx).HTTPS, Transformers: nil}, @@ -80,32 +93,23 @@ func generateExpansion(ctx context.Context, src string, templateRepo, generateRe } return os.Expand(src, func(key string) string { - if expansion, ok := expansionMap[key]; ok { - if sanitizeFileName { - return fileNameSanitize(expansion) - } - return expansion + if val, ok := expansionMap[key]; ok { + return val } return key }) } -// GiteaTemplate holds information about a .gitea/template file -type GiteaTemplate struct { - Path string - Content []byte - - globs []glob.Glob +// giteaTemplateFileMatcher holds information about a .gitea/template file +type giteaTemplateFileMatcher struct { + LocalFullPath string + globs []glob.Glob } -// Globs parses the .gitea/template globs or returns them if they were already parsed -func (gt *GiteaTemplate) Globs() []glob.Glob { - if gt.globs != nil { - return gt.globs - } - +func newGiteaTemplateFileMatcher(fullPath string, content []byte) *giteaTemplateFileMatcher { + gt := &giteaTemplateFileMatcher{LocalFullPath: fullPath} gt.globs = make([]glob.Glob, 0) - scanner := bufio.NewScanner(bytes.NewReader(gt.Content)) + scanner := bufio.NewScanner(bytes.NewReader(content)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { @@ -113,73 +117,91 @@ func (gt *GiteaTemplate) Globs() []glob.Glob { } g, err := glob.Compile(line, '/') if err != nil { - log.Info("Invalid glob expression '%s' (skipped): %v", line, err) + log.Debug("Invalid glob expression '%s' (skipped): %v", line, err) continue } gt.globs = append(gt.globs, g) } - return gt.globs + return gt } -func readGiteaTemplateFile(tmpDir string) (*GiteaTemplate, error) { - gtPath := filepath.Join(tmpDir, ".gitea", "template") - if _, err := os.Stat(gtPath); os.IsNotExist(err) { +func (gt *giteaTemplateFileMatcher) HasRules() bool { + return len(gt.globs) != 0 +} + +func (gt *giteaTemplateFileMatcher) Match(s string) bool { + for _, g := range gt.globs { + if g.Match(s) { + return true + } + } + return false +} + +func readGiteaTemplateFile(tmpDir string) (*giteaTemplateFileMatcher, error) { + localPath := filepath.Join(tmpDir, ".gitea", "template") + if _, err := os.Stat(localPath); os.IsNotExist(err) { return nil, nil } else if err != nil { return nil, err } - content, err := os.ReadFile(gtPath) + content, err := os.ReadFile(localPath) if err != nil { return nil, err } - return &GiteaTemplate{Path: gtPath, Content: content}, nil + return newGiteaTemplateFileMatcher(localPath, content), nil } -func processGiteaTemplateFile(ctx context.Context, 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) +func substGiteaTemplateFile(ctx context.Context, tmpDir, tmpDirSubPath string, templateRepo, generateRepo *repo_model.Repository) error { + tmpFullPath := filepath.Join(tmpDir, tmpDirSubPath) + if ok, err := util.IsRegularFile(tmpFullPath); !ok { + return err } - if len(giteaTemplateFile.Globs()) == 0 { + + content, err := os.ReadFile(tmpFullPath) + if err != nil { + return err + } + if err := util.Remove(tmpFullPath); err != nil { + return err + } + + generatedContent := generateExpansion(ctx, string(content), templateRepo, generateRepo) + substSubPath := filepath.Clean(filePathSanitize(generateExpansion(ctx, tmpDirSubPath, templateRepo, generateRepo))) + newLocalPath := filepath.Join(tmpDir, substSubPath) + regular, err := util.IsRegularFile(newLocalPath) + if canWrite := regular || os.IsNotExist(err); !canWrite { + return nil + } + if err := os.MkdirAll(filepath.Dir(newLocalPath), 0o755); err != nil { + return err + } + return os.WriteFile(newLocalPath, []byte(generatedContent), 0o644) +} + +func processGiteaTemplateFile(ctx context.Context, tmpDir string, templateRepo, generateRepo *repo_model.Repository, fileMatcher *giteaTemplateFileMatcher) error { + if err := util.Remove(fileMatcher.LocalFullPath); err != nil { + return fmt.Errorf("unable to remove .gitea/template: %w", err) + } + if !fileMatcher.HasRules() { 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 { + + return filepath.WalkDir(tmpDir, func(fullPath 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 - } - - generatedContent := []byte(generateExpansion(ctx, string(content), templateRepo, generateRepo, false)) - if err := os.WriteFile(path, generatedContent, 0o644); err != nil { - return err - } - - substPath := filepath.FromSlash(filepath.Join(tmpDirSlash, generateExpansion(ctx, 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 - } + tmpDirSubPath, err := filepath.Rel(tmpDir, fullPath) + if err != nil { + return err + } + if fileMatcher.Match(filepath.ToSlash(tmpDirSubPath)) { + return substGiteaTemplateFile(ctx, tmpDir, tmpDirSubPath, templateRepo, generateRepo) } return nil }) // end: WalkDir @@ -219,13 +241,13 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r } // Variable expansion - giteaTemplateFile, err := readGiteaTemplateFile(tmpDir) + fileMatcher, err := readGiteaTemplateFile(tmpDir) if err != nil { return fmt.Errorf("readGiteaTemplateFile: %w", err) } - if giteaTemplateFile != nil { - err = processGiteaTemplateFile(ctx, tmpDir, templateRepo, generateRepo, giteaTemplateFile) + if fileMatcher != nil { + err = processGiteaTemplateFile(ctx, tmpDir, templateRepo, generateRepo, fileMatcher) if err != nil { return err } @@ -317,12 +339,17 @@ func (gro GenerateRepoOptions) IsValid() bool { gro.IssueLabels || gro.ProtectedBranch // or other items as they are added } -var fileNameSanitizeRegexp = regexp.MustCompile(`(?i)\.\.|[<>:\"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`) - -// Sanitize user input to valid OS filenames -// -// Based on https://github.com/sindresorhus/filename-reserved-regex -// Adds ".." to prevent directory traversal -func fileNameSanitize(s string) string { - return strings.TrimSpace(fileNameSanitizeRegexp.ReplaceAllString(s, "_")) +func filePathSanitize(s string) string { + fields := strings.Split(filepath.ToSlash(s), "/") + for i, field := range fields { + field = strings.TrimSpace(strings.TrimSpace(globalVars().fileNameSanitizeRegexp.ReplaceAllString(field, "_"))) + if strings.HasPrefix(field, "..") { + field = "__" + field[2:] + } + if strings.EqualFold(field, ".git") { + field = "_" + field[1:] + } + fields[i] = field + } + return filepath.FromSlash(strings.Join(fields, "/")) } diff --git a/services/repository/generate_test.go b/services/repository/generate_test.go index 1163c392c9..19b84c7bde 100644 --- a/services/repository/generate_test.go +++ b/services/repository/generate_test.go @@ -4,13 +4,18 @@ package repository import ( + "os" + "path/filepath" "testing" + repo_model "code.gitea.io/gitea/models/repo" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var giteaTemplate = []byte(` +func TestGiteaTemplate(t *testing.T) { + giteaTemplate := []byte(` # Header # All .go files @@ -23,48 +28,153 @@ text/*.txt **/modules/* `) -func TestGiteaTemplate(t *testing.T) { - gt := GiteaTemplate{Content: giteaTemplate} - assert.Len(t, gt.Globs(), 3) + gt := newGiteaTemplateFileMatcher("", giteaTemplate) + assert.Len(t, gt.globs, 3) tt := []struct { Path string Match bool }{ {Path: "main.go", Match: true}, - {Path: "a/b/c/d/e.go", Match: true}, - {Path: "main.txt", Match: false}, - {Path: "a/b.txt", Match: false}, + {Path: "sub/sub/foo.go", Match: true}, + + {Path: "a.txt", Match: false}, {Path: "text/a.txt", Match: true}, - {Path: "text/b.txt", Match: true}, - {Path: "text/c.json", Match: false}, + {Path: "sub/text/a.txt", Match: false}, + {Path: "text/a.json", Match: false}, + {Path: "a/b/c/modules/README.md", Match: true}, {Path: "a/b/c/modules/d/README.md", Match: false}, } for _, tc := range tt { - t.Run(tc.Path, func(t *testing.T) { - match := false - for _, g := range gt.Globs() { - if g.Match(tc.Path) { - match = true - break - } - } - assert.Equal(t, tc.Match, match) - }) + assert.Equal(t, tc.Match, gt.Match(tc.Path), "path: %s", tc.Path) } } -func TestFileNameSanitize(t *testing.T) { - assert.Equal(t, "test_CON", fileNameSanitize("test_CON")) - assert.Equal(t, "test CON", fileNameSanitize("test CON ")) - assert.Equal(t, "__traverse__", fileNameSanitize("../traverse/..")) - assert.Equal(t, "http___localhost_3003_user_test.git", fileNameSanitize("http://localhost:3003/user/test.git")) - assert.Equal(t, "_", fileNameSanitize("CON")) - assert.Equal(t, "_", fileNameSanitize("con")) - assert.Equal(t, "_", fileNameSanitize("\u0000")) - assert.Equal(t, "目标", fileNameSanitize("目标")) +func TestFilePathSanitize(t *testing.T) { + assert.Equal(t, "test_CON", filePathSanitize("test_CON")) + assert.Equal(t, "test CON", filePathSanitize("test CON ")) + assert.Equal(t, "__/traverse/__", filePathSanitize(".. /traverse/ ..")) + assert.Equal(t, "./__/a/_git/b_", filePathSanitize("./../a/.git/ b: ")) + assert.Equal(t, "_", filePathSanitize("CoN")) + assert.Equal(t, "_", filePathSanitize("LpT1")) + assert.Equal(t, "_", filePathSanitize("CoM1")) + assert.Equal(t, "_", filePathSanitize("\u0000")) + assert.Equal(t, "目标", filePathSanitize("目标")) + // unlike filepath.Clean, it only sanitizes, doesn't change the separator layout + assert.Equal(t, "", filePathSanitize("")) //nolint:testifylint // for easy reading + assert.Equal(t, ".", filePathSanitize(".")) + assert.Equal(t, "/", filePathSanitize("/")) +} + +func TestProcessGiteaTemplateFile(t *testing.T) { + tmpDir := filepath.Join(t.TempDir(), "gitea-template-test") + + assertFileContent := func(path, expected string) { + data, err := os.ReadFile(filepath.Join(tmpDir, path)) + if expected == "" { + assert.ErrorIs(t, err, os.ErrNotExist) + return + } + require.NoError(t, err) + assert.Equal(t, expected, string(data), "file content mismatch for %s", path) + } + + assertSymLink := func(path, expected string) { + link, err := os.Readlink(filepath.Join(tmpDir, path)) + if expected == "" { + assert.ErrorIs(t, err, os.ErrNotExist) + return + } + require.NoError(t, err) + assert.Equal(t, expected, link, "symlink target mismatch for %s", path) + } + + require.NoError(t, os.MkdirAll(tmpDir+"/.gitea", 0o755)) + require.NoError(t, os.WriteFile(tmpDir+"/.gitea/template", []byte("*\ninclude/**"), 0o644)) + require.NoError(t, os.MkdirAll(tmpDir+"/sub", 0o755)) + require.NoError(t, os.MkdirAll(tmpDir+"/include/foo/bar", 0o755)) + + require.NoError(t, os.WriteFile(tmpDir+"/sub/link-target", []byte("link target content from ${TEMPLATE_NAME}"), 0o644)) + require.NoError(t, os.WriteFile(tmpDir+"/include/foo/bar/test.txt", []byte("include subdir ${TEMPLATE_NAME}"), 0o644)) + + // case-1 + { + require.NoError(t, os.WriteFile(tmpDir+"/normal", []byte("normal content"), 0o644)) + require.NoError(t, os.WriteFile(tmpDir+"/template", []byte("template from ${TEMPLATE_NAME}"), 0o644)) + } + + // case-2 + { + require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/link")) + } + + // case-3 + { + require.NoError(t, os.WriteFile(tmpDir+"/subst-${REPO_NAME}", []byte("dummy subst repo name"), 0o644)) + } + + // case-4 + assertSubstTemplateName := func(normalContent, toLinkContent, fromLinkContent string) { + assertFileContent("subst-${TEMPLATE_NAME}-normal", normalContent) + assertFileContent("subst-${TEMPLATE_NAME}-to-link", toLinkContent) + assertFileContent("subst-${TEMPLATE_NAME}-from-link", fromLinkContent) + } + { + // will succeed + require.NoError(t, os.WriteFile(tmpDir+"/subst-${TEMPLATE_NAME}-normal", []byte("dummy subst template name normal"), 0o644)) + // will skil if the path subst result is a link + require.NoError(t, os.WriteFile(tmpDir+"/subst-${TEMPLATE_NAME}-to-link", []byte("dummy subst template name to link"), 0o644)) + require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/subst-TemplateRepoName-to-link")) + // will be skipped since the source is a symlink + require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/subst-${TEMPLATE_NAME}-from-link")) + // pre-check + assertSubstTemplateName("dummy subst template name normal", "dummy subst template name to link", "link target content from ${TEMPLATE_NAME}") + } + + // process the template files + { + templateRepo := &repo_model.Repository{Name: "TemplateRepoName"} + generatedRepo := &repo_model.Repository{Name: "/../.gIt/name"} + fileMatcher, _ := readGiteaTemplateFile(tmpDir) + err := processGiteaTemplateFile(t.Context(), tmpDir, templateRepo, generatedRepo, fileMatcher) + require.NoError(t, err) + assertFileContent("include/foo/bar/test.txt", "include subdir TemplateRepoName") + } + + // the lin target should never be modified, and since it is in a subdirectory, it is not affected by the template either + assertFileContent("sub/link-target", "link target content from ${TEMPLATE_NAME}") + + // case-1 + { + assertFileContent("no-such", "") + assertFileContent("normal", "normal content") + assertFileContent("template", "template from TemplateRepoName") + } + + // case-2 + { + // symlink with templates should be preserved (not read or write) + assertSymLink("link", tmpDir+"/sub/link-target") + } + + // case-3 + { + assertFileContent("subst-${REPO_NAME}", "") + assertFileContent("subst-/__/_gIt/name", "dummy subst repo name") + } + + // case-4 + { + // the paths with templates should have been removed, subst to a regular file, succeed, the link is preserved + assertSubstTemplateName("", "", "link target content from ${TEMPLATE_NAME}") + assertFileContent("subst-TemplateRepoName-normal", "dummy subst template name normal") + // subst to a link, skip, and the target is unchanged + assertSymLink("subst-TemplateRepoName-to-link", tmpDir+"/sub/link-target") + // subst from a link, skip, and the target is unchanged + assertSymLink("subst-${TEMPLATE_NAME}-from-link", tmpDir+"/sub/link-target") + } } func TestTransformers(t *testing.T) { @@ -82,9 +192,9 @@ func TestTransformers(t *testing.T) { } input := "Abc_Def-XYZ" - assert.Len(t, defaultTransformers, len(cases)) + assert.Len(t, globalVars().defaultTransformers, len(cases)) for i, c := range cases { - tf := defaultTransformers[i] + tf := globalVars().defaultTransformers[i] require.Equal(t, c.name, tf.Name) assert.Equal(t, c.expected, tf.Transform(input), "case %s", c.name) } diff --git a/tests/integration/cmd_keys_test.go b/tests/integration/cmd_keys_test.go index 3878302ef0..be226a01ec 100644 --- a/tests/integration/cmd_keys_test.go +++ b/tests/integration/cmd_keys_test.go @@ -30,7 +30,7 @@ func Test_CmdKeys(t *testing.T) { "with_key", []string{"keys", "-e", "git", "-u", "git", "-t", "ssh-rsa", "-k", "AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM="}, false, - "# gitea public key\ncommand=\"" + setting.AppPath + " --config=" + util.ShellEscape(setting.CustomConf) + " serv key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM= user2@localhost\n", + "# gitea public key\ncommand=\"" + setting.AppPath + " --config=" + util.ShellEscape(setting.CustomConf) + " serv key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM= user-2\n", }, {"invalid", []string{"keys", "--not-a-flag=git"}, true, "Incorrect Usage: flag provided but not defined: -not-a-flag\n\n"}, } From a2eea2fb2ece46b9bba6a2c8c4ad9550eca47527 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 21 Oct 2025 13:19:29 +0800 Subject: [PATCH 06/29] Fix various trivial problems (#35714) --- services/automergequeue/automergequeue.go | 3 ++- web_src/css/repo.css | 12 ++++++++++-- web_src/js/components/RepoActionView.vue | 3 ++- web_src/js/components/RepoCodeFrequency.vue | 2 +- web_src/js/components/RepoContributors.vue | 2 +- web_src/js/components/RepoRecentCommits.vue | 2 +- 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/services/automergequeue/automergequeue.go b/services/automergequeue/automergequeue.go index fa9c04da87..e8cc4512a7 100644 --- a/services/automergequeue/automergequeue.go +++ b/services/automergequeue/automergequeue.go @@ -5,6 +5,7 @@ package automergequeue import ( "context" + "errors" "fmt" issues_model "code.gitea.io/gitea/models/issues" @@ -17,7 +18,7 @@ var AutoMergeQueue *queue.WorkerPoolQueue[string] var AddToQueue = func(pr *issues_model.PullRequest, sha string) { log.Trace("Adding pullID: %d to the pull requests patch checking queue with sha %s", pr.ID, sha) - if err := AutoMergeQueue.Push(fmt.Sprintf("%d_%s", pr.ID, sha)); err != nil { + if err := AutoMergeQueue.Push(fmt.Sprintf("%d_%s", pr.ID, sha)); err != nil && !errors.Is(err, queue.ErrAlreadyInQueue) { log.Error("Error adding pullID: %d to the pull requests patch checking queue %v", pr.ID, err) } } diff --git a/web_src/css/repo.css b/web_src/css/repo.css index c70937147a..070623d24e 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -532,6 +532,10 @@ td .commit-summary { color: var(--color-text-light); } +.repository.view.issue .comment-list .timeline-item .comment-text-line .ui.label { + line-height: 1.5; /* label has background, so it can't use parent's line-height */ +} + .repository.view.issue .comment-list .timeline-item .comment-text-line a { color: inherit; } @@ -654,8 +658,8 @@ td .commit-summary { .repository.view.issue .comment-list .code-comment .comment-header { background: transparent; - border-bottom: 0 !important; - padding: 0 !important; + border-bottom: 0; + padding: 0; } .repository.view.issue .comment-list .code-comment .comment-content { @@ -1311,6 +1315,10 @@ td .commit-summary { gap: 0.25em; } +.comment-header.avatar-content-left-arrow::after { + border-right-color: var(--color-box-header); +} + .comment-header.arrow-top::before, .comment-header.arrow-top::after { transform: rotate(90deg); diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 6e6733c7d0..24c0f5300c 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -565,7 +565,7 @@ export default defineComponent({