From 912afcaa51608b652133d0d12b949f1aa957da66 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 23:22:32 +0800 Subject: [PATCH 01/25] refactor(waitgroup): replace Add/Done goroutines with WaitGroup.Go (#37764) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: wxiaoguang <2114189+wxiaoguang@users.noreply.github.com> --- modules/ssh/ssh.go | 11 ++++------- modules/templates/scopedtmpl/scopedtmpl_test.go | 11 ++++------- tests/integration/api_issue_test.go | 9 +++------ tests/integration/api_packages_container_test.go | 8 ++------ tests/integration/api_packages_maven_test.go | 6 ++---- tests/integration/git_misc_test.go | 11 ++++------- tests/integration/repo_commits_test.go | 8 +++----- 7 files changed, 22 insertions(+), 42 deletions(-) diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go index 3fea4851c7..2cb9ab8996 100644 --- a/modules/ssh/ssh.go +++ b/modules/ssh/ssh.go @@ -155,7 +155,6 @@ func sessionHandler(session ssh.Session) { process.SetSysProcAttribute(cmd) wg := &sync.WaitGroup{} - wg.Add(2) if err = cmd.Start(); err != nil { log.Error("SSH: Start: %v", err) @@ -169,21 +168,19 @@ func sessionHandler(session ssh.Session) { } }() - go func() { - defer wg.Done() + wg.Go(func() { defer stdout.Close() if _, err := io.Copy(session, stdout); err != nil { log.Error("Failed to write stdout to session. %s", err) } - }() + }) - go func() { - defer wg.Done() + wg.Go(func() { defer stderr.Close() if _, err := io.Copy(session.Stderr(), stderr); err != nil { log.Error("Failed to write stderr to session. %s", err) } - }() + }) // Ensure all the output has been written before we wait on the command // to exit. diff --git a/modules/templates/scopedtmpl/scopedtmpl_test.go b/modules/templates/scopedtmpl/scopedtmpl_test.go index 774b8c7d42..dbf11e97c2 100644 --- a/modules/templates/scopedtmpl/scopedtmpl_test.go +++ b/modules/templates/scopedtmpl/scopedtmpl_test.go @@ -54,17 +54,14 @@ func TestScopedTemplateSetFuncMap(t *testing.T) { out1 := bytes.Buffer{} out2 := bytes.Buffer{} wg := sync.WaitGroup{} - wg.Add(2) - go func() { + wg.Go(func() { err := ts.newExecutor(funcMap1).Execute(&out1, nil) assert.NoError(t, err) - wg.Done() - }() - go func() { + }) + wg.Go(func() { err := ts.newExecutor(funcMap2).Execute(&out2, nil) assert.NoError(t, err) - wg.Done() - }() + }) wg.Wait() assert.Equal(t, "base1\ntest1\nbase1\ntest1", out1.String()) assert.Equal(t, "base2\ntest2\nbase2\ntest2", out2.String()) diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index f6a7a4f574..8f20814251 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -171,9 +171,8 @@ func testAPICreateIssueParallel(t *testing.T) { var wg sync.WaitGroup for i := range 10 { - wg.Add(1) - go func(parentT *testing.T, i int) { - parentT.Run(fmt.Sprintf("ParallelCreateIssue_%d", i), func(t *testing.T) { + wg.Go(func() { + t.Run(fmt.Sprintf("ParallelCreateIssue_%d", i), func(t *testing.T) { newTitle := title + strconv.Itoa(i) newBody := body + strconv.Itoa(i) req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ @@ -192,10 +191,8 @@ func testAPICreateIssueParallel(t *testing.T) { Content: newBody, Title: newTitle, }) - - wg.Done() }) - }(t, i) + }) } wg.Wait() } diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go index 93dd100680..c327f76305 100644 --- a/tests/integration/api_packages_container_test.go +++ b/tests/integration/api_packages_container_test.go @@ -766,20 +766,16 @@ func TestPackageContainer(t *testing.T) { var wg sync.WaitGroup for i := range 10 { - wg.Add(1) - content := []byte{byte(i)} digest := fmt.Sprintf("sha256:%x", sha256.Sum256(content)) - go func() { - defer wg.Done() - + wg.Go(func() { req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, digest), bytes.NewReader(content)). AddTokenAuth(userToken) resp := MakeRequest(t, req, http.StatusCreated) assert.Equal(t, digest, resp.Header().Get("Docker-Content-Digest")) - }() + }) } wg.Wait() }) diff --git a/tests/integration/api_packages_maven_test.go b/tests/integration/api_packages_maven_test.go index 468b32f2b8..bad74dc597 100644 --- a/tests/integration/api_packages_maven_test.go +++ b/tests/integration/api_packages_maven_test.go @@ -321,11 +321,9 @@ func TestPackageMavenConcurrent(t *testing.T) { var wg sync.WaitGroup for i := range 10 { - wg.Add(1) - go func(i int) { + wg.Go(func() { putFile(t, fmt.Sprintf("/%s/%s.jar", packageVersion, strconv.Itoa(i)), "test", http.StatusCreated) - wg.Done() - }(i) + }) } wg.Wait() }) diff --git a/tests/integration/git_misc_test.go b/tests/integration/git_misc_test.go index c830086e3f..71e35cceab 100644 --- a/tests/integration/git_misc_test.go +++ b/tests/integration/git_misc_test.go @@ -63,17 +63,14 @@ func TestDataAsyncDoubleRead_Issue29101(t *testing.T) { var data1, data2 []byte wg := sync.WaitGroup{} - wg.Add(2) - go func() { + wg.Go(func() { data1, _ = io.ReadAll(r1) assert.NoError(t, err) - wg.Done() - }() - go func() { + }) + wg.Go(func() { data2, _ = io.ReadAll(r2) assert.NoError(t, err) - wg.Done() - }() + }) wg.Wait() assert.Equal(t, testContent, data1) assert.Equal(t, testContent, data2) diff --git a/tests/integration/repo_commits_test.go b/tests/integration/repo_commits_test.go index 3bcea83f43..dbaabc380b 100644 --- a/tests/integration/repo_commits_test.go +++ b/tests/integration/repo_commits_test.go @@ -180,14 +180,12 @@ func TestRepoCommitsStatusParallel(t *testing.T) { var wg sync.WaitGroup for i := range 10 { - wg.Add(1) - go func(parentT *testing.T, i int) { - parentT.Run(fmt.Sprintf("ParallelCreateStatus_%d", i), func(t *testing.T) { + wg.Go(func() { + t.Run(fmt.Sprintf("ParallelCreateStatus_%d", i), func(t *testing.T) { ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository) doAPICreateCommitStatusTest(ctx, path.Base(commitURL), commitstatus.CommitStatusPending, "testci")(t) - wg.Done() }) - }(t, i) + }) } wg.Wait() } From c37b5241d795ea5e43f828468c413444ec4a2db8 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 18 May 2026 23:47:24 +0800 Subject: [PATCH 02/25] chore: fix tests (#37760) Signed-off-by: wxiaoguang --- go.mod | 3 - go.sum | 7 - tests/integration/api_repo_file_get_test.go | 4 +- tests/integration/git_general_test.go | 50 ++++--- tests/integration/integration_test.go | 138 ++------------------ 5 files changed, 34 insertions(+), 168 deletions(-) diff --git a/go.mod b/go.mod index 4c75bbf35a..cd95a5b5dd 100644 --- a/go.mod +++ b/go.mod @@ -104,7 +104,6 @@ require ( github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.6.1 github.com/wneessen/go-mail v0.7.3 - github.com/xeipuuv/gojsonschema v1.2.0 github.com/yohcop/openid-go v1.0.1 github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc @@ -272,8 +271,6 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/zeebo/blake3 v0.2.4 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect diff --git a/go.sum b/go.sum index b2beb0d3af..5923df0ea9 100644 --- a/go.sum +++ b/go.sum @@ -731,13 +731,6 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= diff --git a/tests/integration/api_repo_file_get_test.go b/tests/integration/api_repo_file_get_test.go index ec50cf52f4..0f9e57cee4 100644 --- a/tests/integration/api_repo_file_get_test.go +++ b/tests/integration/api_repo_file_get_test.go @@ -42,8 +42,8 @@ func TestAPIGetRawFileOrLFS(t *testing.T) { lfs := lfsCommitAndPushTest(t, dstPath, testFileSizeSmall)[0] reqLFS := NewRequest(t, "GET", "/api/v1/repos/user2/repo-lfs-test/media/"+lfs).AddTokenAuth(httpContext.Token) - respLFS := MakeRequestNilResponseRecorder(t, reqLFS, http.StatusOK) - assert.Equal(t, testFileSizeSmall, respLFS.Length) + respLFS := MakeRequest(t, reqLFS, http.StatusOK) + assert.Equal(t, testFileSizeSmall, respLFS.Body.Len()) }) }) } diff --git a/tests/integration/git_general_test.go b/tests/integration/git_general_test.go index b82dd60021..9e43e8b5cd 100644 --- a/tests/integration/git_general_test.go +++ b/tests/integration/git_general_test.go @@ -5,7 +5,6 @@ package integration import ( "context" - "encoding/hex" "fmt" "io" mathRand "math/rand/v2" @@ -246,8 +245,8 @@ func rawTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS s // Request raw paths req := NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", little)) - resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) - assert.Equal(t, testFileSizeSmall, resp.Length) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, testFileSizeSmall, resp.Body.Len()) if setting.LFS.StartServer { req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", littleLFS)) @@ -261,8 +260,8 @@ func rawTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS s if !testing.Short() { req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", big)) - resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) - assert.Equal(t, testFileSizeLarge, resp.Length) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, testFileSizeLarge, resp.Body.Len()) if setting.LFS.StartServer { req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", bigLFS)) @@ -287,22 +286,22 @@ func mediaTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS // Request media paths req := NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", little)) - resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) - assert.Equal(t, testFileSizeSmall, resp.Length) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, testFileSizeSmall, resp.Body.Len()) req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", littleLFS)) - resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) - assert.Equal(t, testFileSizeSmall, resp.Length) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, testFileSizeSmall, resp.Body.Len()) if !testing.Short() { req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", big)) - resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) - assert.Equal(t, testFileSizeLarge, resp.Length) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, testFileSizeLarge, resp.Body.Len()) if setting.LFS.StartServer { req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", bigLFS)) - resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) - assert.Equal(t, testFileSizeLarge, resp.Length) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, testFileSizeLarge, resp.Body.Len()) } } }) @@ -560,13 +559,11 @@ func doMergeFork(ctx, baseCtx APITestContext, baseBranch, headBranch string) fun t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr)) // Then get the diff string - var diffHash string - var diffLength int + var diffContent string t.Run("GetDiff", func(t *testing.T) { req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d.diff", url.PathEscape(baseCtx.Username), url.PathEscape(baseCtx.Reponame), pr.Index)) - resp := ctx.Session.MakeRequestNilResponseHashSumRecorder(t, req, http.StatusOK) - diffHash = string(resp.Hash.Sum(nil)) - diffLength = resp.Length + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + diffContent = resp.Body.String() }) // Now: Merge the PR & make sure that doesn't break the PR page or change its diff @@ -578,17 +575,17 @@ func doMergeFork(ctx, baseCtx APITestContext, baseBranch, headBranch string) fun assert.NoError(t, err) assert.Equal(t, oldMergeBase, pr2.MergeBase) }) - t.Run("EnsurDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength)) + t.Run("EnsurDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffContent)) // Then: Delete the head branch & make sure that doesn't break the PR page or change its diff t.Run("DeleteHeadBranch", doBranchDelete(baseCtx, baseCtx.Username, baseCtx.Reponame, headBranch)) t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr)) - t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength)) + t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffContent)) // Delete the head repository & make sure that doesn't break the PR page or change its diff t.Run("DeleteHeadRepository", doAPIDeleteRepository(ctx)) t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr)) - t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength)) + t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffContent)) } } @@ -632,15 +629,12 @@ func doEnsureCanSeePull(ctx APITestContext, pr api.PullRequest) func(t *testing. } } -func doEnsureDiffNoChange(ctx APITestContext, pr api.PullRequest, diffHash string, diffLength int) func(t *testing.T) { +func doEnsureDiffNoChange(ctx APITestContext, pr api.PullRequest, diffContent string) func(t *testing.T) { return func(t *testing.T) { req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d.diff", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index)) - resp := ctx.Session.MakeRequestNilResponseHashSumRecorder(t, req, http.StatusOK) - actual := string(resp.Hash.Sum(nil)) - actualLength := resp.Length - - equal := diffHash == actual - assert.True(t, equal, "Unexpected change in the diff string: expected hash: %s size: %d but was actually: %s size: %d", hex.EncodeToString([]byte(diffHash)), diffLength, hex.EncodeToString([]byte(actual)), actualLength) + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + actual := resp.Body.String() + assert.Equal(t, diffContent, actual) } } diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 6cdb03cfd6..39de596de5 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -9,8 +9,6 @@ import ( "encoding/base64" "flag" "fmt" - "hash" - "hash/fnv" "io" "net/http" "net/http/cookiejar" @@ -38,49 +36,10 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/xeipuuv/gojsonschema" ) var testWebRoutes *web.Router -type NilResponseRecorder struct { - httptest.ResponseRecorder - Length int -} - -func (n *NilResponseRecorder) Write(b []byte) (int, error) { - n.Length += len(b) - return len(b), nil -} - -// NewRecorder returns an initialized ResponseRecorder. -func NewNilResponseRecorder() *NilResponseRecorder { - return &NilResponseRecorder{ - ResponseRecorder: *httptest.NewRecorder(), - } -} - -type NilResponseHashSumRecorder struct { - httptest.ResponseRecorder - Hash hash.Hash - Length int -} - -func (n *NilResponseHashSumRecorder) Write(b []byte) (int, error) { - _, _ = n.Hash.Write(b) - n.Length += len(b) - return len(b), nil -} - -// NewRecorder returns an initialized ResponseRecorder. -func NewNilResponseHashSumRecorder() *NilResponseHashSumRecorder { - return &NilResponseHashSumRecorder{ - Hash: fnv.New32(), - ResponseRecorder: *httptest.NewRecorder(), - } -} - func testMain(m *testing.M) int { defer log.GetManager().Close() @@ -177,42 +136,6 @@ func (s *TestSession) MakeRequest(t testing.TB, rw *RequestWrapper, expectedStat return resp } -func (s *TestSession) MakeRequestNilResponseRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseRecorder { - t.Helper() - req := rw.Request - baseURL, err := url.Parse(setting.AppURL) - assert.NoError(t, err) - for _, c := range s.jar.Cookies(baseURL) { - req.AddCookie(c) - } - resp := MakeRequestNilResponseRecorder(t, rw, expectedStatus) - - ch := http.Header{} - ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) - cr := http.Request{Header: ch} - s.jar.SetCookies(baseURL, cr.Cookies()) - - return resp -} - -func (s *TestSession) MakeRequestNilResponseHashSumRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseHashSumRecorder { - t.Helper() - req := rw.Request - baseURL, err := url.Parse(setting.AppURL) - assert.NoError(t, err) - for _, c := range s.jar.Cookies(baseURL) { - req.AddCookie(c) - } - resp := MakeRequestNilResponseHashSumRecorder(t, rw, expectedStatus) - - ch := http.Header{} - ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) - cr := http.Request{Header: ch} - s.jar.SetCookies(baseURL, cr.Cookies()) - - return resp -} - const userPassword = "password" func emptyTestSession(t testing.TB) *TestSession { @@ -268,7 +191,11 @@ func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth. req := NewRequestWithURLValues(t, "POST", "/user/settings/applications", urlValues) session.MakeRequest(t, req, http.StatusSeeOther) flashes := session.GetCookieFlashMessage() - return flashes.InfoMsg + assert.NotNil(t, flashes) + if flashes != nil { + return flashes.InfoMsg + } + return "" } type RequestWrapper struct { @@ -336,7 +263,7 @@ func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *Re t.Fatalf("invalid url str: %s", urlStr) } req, err := http.NewRequest(method, urlStr, body) - require.NoError(t, err) + assert.NoError(t, err) if req.URL.User != nil { req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(req.URL.User.String()))) } @@ -364,35 +291,8 @@ func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest if expectedStatus != NoExpectedStatus { if expectedStatus != recorder.Code { logUnexpectedResponse(t, recorder) - require.Equal(t, expectedStatus, recorder.Code, "Request: %s %s", req.Method, req.URL.String()) - } - } - return recorder -} - -func MakeRequestNilResponseRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseRecorder { - t.Helper() - req := rw.Request - recorder := NewNilResponseRecorder() - testWebRoutes.ServeHTTP(recorder, req) - if expectedStatus != NoExpectedStatus { - if !assert.Equal(t, expectedStatus, recorder.Code, - "Request: %s %s", req.Method, req.URL.String()) { - logUnexpectedResponse(t, &recorder.ResponseRecorder) - } - } - return recorder -} - -func MakeRequestNilResponseHashSumRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseHashSumRecorder { - t.Helper() - req := rw.Request - recorder := NewNilResponseHashSumRecorder() - testWebRoutes.ServeHTTP(recorder, req) - if expectedStatus != NoExpectedStatus { - if !assert.Equal(t, expectedStatus, recorder.Code, - "Request: %s %s", req.Method, req.URL.String()) { - logUnexpectedResponse(t, &recorder.ResponseRecorder) + // don't use "require" which exits the test case and makes "wait group" wait forever + assert.Equal(t, expectedStatus, recorder.Code, "Request: %s %s", req.Method, req.URL.String()) } } return recorder @@ -428,25 +328,7 @@ func DecodeJSON[T any](t testing.TB, resp *httptest.ResponseRecorder, v T) (ret // FIXME: JSON-KEY-CASE: for testing purpose only, because many structs don't provide `json` tags, they just use capitalized field names decoder := json.NewDecoderCaseInsensitive(resp.Body) - require.NoError(t, decoder.Decode(&v)) + // don't use "require" which exits the test case and makes "wait group" wait forever + assert.NoError(t, decoder.Decode(&v)) return v } - -func VerifyJSONSchema(t testing.TB, resp *httptest.ResponseRecorder, schemaFile string) { - t.Helper() - - schemaFilePath := filepath.Join(filepath.Dir(setting.AppPath), "tests", "integration", "schemas", schemaFile) - _, schemaFileErr := os.Stat(schemaFilePath) - assert.NoError(t, schemaFileErr) - - schema, schemaFileReadErr := os.ReadFile(schemaFilePath) - assert.NoError(t, schemaFileReadErr) - assert.NotEmpty(t, schema) - - nodeinfoSchema := gojsonschema.NewStringLoader(string(schema)) - nodeinfoString := gojsonschema.NewStringLoader(resp.Body.String()) - result, schemaValidationErr := gojsonschema.Validate(nodeinfoSchema, nodeinfoString) - assert.NoError(t, schemaValidationErr) - assert.Empty(t, result.Errors()) - assert.True(t, result.Valid()) -} From 985ca76db0869b82b0ed2f6e61bcfe1e720d4291 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 18 May 2026 18:21:46 +0200 Subject: [PATCH 03/25] ci: fix cache-related issues (#37761) Fixes two recurring CI failures: 1. `cache-seeder.yml` lint-backend missing a `make generate-go` before linting with `TAGS=bindata`, and `pull-compliance.yml` lint-on-demand failing its post-step pnpm cache save when no pnpm-using conditional step runs. 2. Drops `cache: pnpm` from lint-on-demand and adds `make generate-go` to cache-seeder's lint job. --- This PR was written with the help of Claude Opus 4.7 --------- Signed-off-by: silverwind Co-authored-by: Claude (Opus 4.7) --- .github/workflows/cache-seeder.yml | 3 +++ .github/workflows/pull-compliance.yml | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cache-seeder.yml b/.github/workflows/cache-seeder.yml index 733077cc80..317ddf1510 100644 --- a/.github/workflows/cache-seeder.yml +++ b/.github/workflows/cache-seeder.yml @@ -66,6 +66,9 @@ jobs: cache-name: ${{ matrix.job }} lint-cache: "true" - run: make deps-backend deps-tools + - run: make generate-go + env: + TAGS: ${{ matrix.tags }} - run: make ${{ matrix.target }} env: TAGS: ${{ matrix.tags }} diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index 095ee6271b..7f24d4c584 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -47,8 +47,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - run: make lint-spell From ff1b8b2b9276a8dc086f0cea6acdf964038fe407 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 19 May 2026 01:22:45 +0800 Subject: [PATCH 04/25] chore: make DefaultTitleSource default to auto to match GitHub (#37767) It is a changed (breaking) behavior introduced in 1.26, no need to have such a breaking change. --- custom/conf/app.example.ini | 2 +- modules/setting/repository.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 2498050f5d..3dab03d788 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1172,7 +1172,7 @@ LEVEL = Info ;; Default source for the pull request title when opening a new PR. ;; "first-commit" uses the oldest commit's summary. ;; "auto" uses commit's summary if the PR only has one commit, normalizes the branch name if multiple commits. -;DEFAULT_TITLE_SOURCE = first-commit +;DEFAULT_TITLE_SOURCE = auto ;; ;; Delay mergeable check until page view or API access, for pull requests that have not been updated in the specified days when their base branches get updated. ;; Use "-1" to always check all pull requests (old behavior). Use "0" to always delay the checks. diff --git a/modules/setting/repository.go b/modules/setting/repository.go index a8bc91c089..9ae64fcd94 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -237,7 +237,7 @@ var ( AddCoCommitterTrailers: true, RetargetChildrenOnMerge: true, DelayCheckForInactiveDays: 7, - DefaultTitleSource: RepoPRTitleSourceFirstCommit, + DefaultTitleSource: RepoPRTitleSourceAuto, }, // Issue settings From 81b544c27954fcf37c95b8f7638a78bfda01e9cc Mon Sep 17 00:00:00 2001 From: Giteabot Date: Mon, 18 May 2026 10:52:59 -0700 Subject: [PATCH 05/25] fix(deps): update module google.golang.org/grpc to v1.81.1 (#37762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [google.golang.org/grpc](https://redirect.github.com/grpc/grpc-go) | `v1.81.0` → `v1.81.1` | ![age](https://developer.mend.io/api/mc/badges/age/go/google.golang.org%2fgrpc/v1.81.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/google.golang.org%2fgrpc/v1.81.0/v1.81.1?slim=true) | --- ### Release Notes
grpc/grpc-go (google.golang.org/grpc) ### [`v1.81.1`](https://redirect.github.com/grpc/grpc-go/releases/tag/v1.81.1): Release 1.81.1 [Compare Source](https://redirect.github.com/grpc/grpc-go/compare/v1.81.0...v1.81.1) ### Security - xds/rbac: Fix a potential authorization bypass caused by incorrectly falling through URI/DNS SANs to Subject Distinguished Name (DN) when matching the authenticated principal name. With this fix, only the first non-empty identity source will be used, as per [gRFC A41](https://redirect.github.com/grpc/proposal/blob/master/A41-xds-rbac.md). ([#​9111](https://redirect.github.com/grpc/grpc-go/issues/9111)) - Special Thanks: [@​al4an444](https://redirect.github.com/al4an444) ### Bug Fixes - otel: Segregate client and server RPC information used for metrics and traces, to avoid one overwriting the other. ([#​9081](https://redirect.github.com/grpc/grpc-go/issues/9081))
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - Only on Monday (`* * * * 1`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://redirect.github.com/renovatebot/renovate). --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cd95a5b5dd..7456bb49f8 100644 --- a/go.mod +++ b/go.mod @@ -116,7 +116,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/sys v0.44.0 golang.org/x/text v0.37.0 - google.golang.org/grpc v1.81.0 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 gopkg.in/ini.v1 v1.67.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 5923df0ea9..00e8a328db 100644 --- a/go.sum +++ b/go.sum @@ -915,8 +915,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401020348-3a24fdc17823 h1:YedBIttDguBl/zy2wJauEUm+DZZg4UXseWj0g/3N+yo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401020348-3a24fdc17823/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= From f2a1271f164569264c378fad720b0c000fff3336 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 18 May 2026 11:36:42 -0700 Subject: [PATCH 06/25] fix: Unify public-only token filtering in API queries and repo access checks (#37118) This PR closes remaining `public-only` token gaps in the API by making the restriction apply consistently across repository, organization, activity, notification, and authenticated `/api/v1/user/...` routes. Previously, `public-only` tokens were still able to: - receive private results from some list/search/self endpoints, - access repository data through ID-based lookups, - and reach several authenticated self routes that should remain unavailable for public-only access. This change treats `public-only` as a cross-cutting visibility boundary: - list/search endpoints now filter private resources consistently, - repository lookups enforce the same restriction even when addressed indirectly, - and self routes that inherently expose or mutate private account state now reject `public-only` tokens. --- Generated by a coding agent with Codex 5.2 --------- Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) --- models/activities/action.go | 6 + models/organization/org_list.go | 6 + models/repo/repo_list.go | 7 + models/repo/user_repo.go | 12 ++ models/user/search.go | 6 + routers/api/v1/api.go | 139 ++++++++------ routers/api/v1/org/org.go | 11 +- routers/api/v1/repo/issue.go | 3 +- routers/api/v1/repo/repo.go | 9 +- routers/api/v1/user/repo.go | 11 +- routers/api/v1/user/star.go | 7 +- routers/api/v1/user/user.go | 13 +- routers/api/v1/user/watch.go | 7 +- services/context/api.go | 7 + tests/integration/api_issue_test.go | 2 +- tests/integration/api_notification_test.go | 20 ++ tests/integration/api_public_only_test.go | 107 +++++++++++ tests/integration/api_repo_branch_test.go | 10 +- tests/integration/api_user_orgs_test.go | 41 ++++ .../integration/api_user_public_only_test.go | 177 ++++++++++++++++++ tests/integration/api_user_star_test.go | 22 +++ tests/integration/api_user_watch_test.go | 25 +++ 22 files changed, 561 insertions(+), 87 deletions(-) create mode 100644 tests/integration/api_public_only_test.go create mode 100644 tests/integration/api_user_public_only_test.go diff --git a/models/activities/action.go b/models/activities/action.go index 4ffdca842a..97388402d4 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -436,6 +436,12 @@ type GetFeedsOptions struct { DontCount bool // do counting in GetFeeds } +func (opts *GetFeedsOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.IncludePrivate = false + } +} + // ActivityReadable return whether doer can read activities of user func ActivityReadable(user, doer *user_model.User) bool { return !user.KeepActivityPrivate || diff --git a/models/organization/org_list.go b/models/organization/org_list.go index f37961b5f6..136417d932 100644 --- a/models/organization/org_list.go +++ b/models/organization/org_list.go @@ -54,6 +54,12 @@ type FindOrgOptions struct { IncludeVisibility structs.VisibleType } +func (opts *FindOrgOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.IncludeVisibility = structs.VisibleTypePublic + } +} + func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder { cond := builder.Eq{"uid": userID} if !includePrivate { diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index e927174a55..25dee1bf0e 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -212,6 +212,13 @@ type SearchRepoOptions struct { OnlyShowRelevant bool } +func (opts *SearchRepoOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.Private = false + opts.AllLimited = false + } +} + // UserOwnedRepoCond returns user ownered repositories func UserOwnedRepoCond(userID int64) builder.Cond { return builder.Eq{ diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index 28ae83a095..e4b0a1b067 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -24,6 +24,12 @@ type StarredReposOptions struct { IncludePrivate bool } +func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.IncludePrivate = false + } +} + func (opts *StarredReposOptions) ToConds() builder.Cond { var cond builder.Cond = builder.Eq{ "star.uid": opts.StarrerID, @@ -62,6 +68,12 @@ type WatchedReposOptions struct { IncludePrivate bool } +func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.IncludePrivate = false + } +} + func (opts *WatchedReposOptions) ToConds() builder.Cond { var cond builder.Cond = builder.Eq{ "watch.user_id": opts.WatcherID, diff --git a/models/user/search.go b/models/user/search.go index 36551b1913..ac9cb8ff99 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -58,6 +58,12 @@ type SearchUserOptions struct { IncludeReserved bool } +func (opts *SearchUserOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.Visible = []structs.VisibleType{structs.VisibleTypePublic} + } +} + func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session { var cond builder.Cond cond = builder.In("type", opts.Types) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..e84b07ba47 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -212,6 +212,11 @@ func repoAssignment() func(ctx *context.APIContext) { ctx.APIErrorNotFound() return } + + if !ctx.TokenCanAccessRepo(repo) { + ctx.APIErrorNotFound() + return + } } } @@ -249,51 +254,66 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) { return } - // public Only permission check - switch { - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public repos") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public issues") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization): - if ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") - return - } - if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser): - if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public users") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub): - if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage): - if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { - ctx.APIError(http.StatusForbidden, "token scope is limited to public packages") - return + for _, category := range requiredScopeCategories { + switch category { + case auth_model.AccessTokenScopeCategoryRepository: + if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) { + ctx.APIError(http.StatusForbidden, "token scope is limited to public repos") + return + } + case auth_model.AccessTokenScopeCategoryIssue: + if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) { + ctx.APIError(http.StatusForbidden, "token scope is limited to public issues") + return + } + case auth_model.AccessTokenScopeCategoryOrganization: + orgPrivate := ctx.Org.Organization != nil && !ctx.Org.Organization.Visibility.IsPublic() + userOrgPrivate := ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && !ctx.ContextUser.Visibility.IsPublic() + if orgPrivate || userOrgPrivate { + ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") + return + } + case auth_model.AccessTokenScopeCategoryUser: + if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && !ctx.ContextUser.Visibility.IsPublic() { + ctx.APIError(http.StatusForbidden, "token scope is limited to public users") + return + } + case auth_model.AccessTokenScopeCategoryActivityPub: + if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && !ctx.ContextUser.Visibility.IsPublic() { + ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub") + return + } + case auth_model.AccessTokenScopeCategoryNotification: + if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) { + ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications") + return + } + case auth_model.AccessTokenScopeCategoryPackage: + if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { + ctx.APIError(http.StatusForbidden, "token scope is limited to public packages") + return + } } } } } +func rejectPublicOnly() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.PublicOnly { + return + } + + ctx.APIError(http.StatusForbidden, "this endpoint is not available for public-only tokens") + } +} + +func contextAuthenticatedUser() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + ctx.ContextUser = ctx.Doer + } +} + // if a token is being used for auth, we check that it contains the required scope // if a token is not being used, reqToken will enforce other sign in methods func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) { @@ -957,6 +977,8 @@ func Routes() *web.Router { }) // Notifications (requires 'notifications' scope) + // The notifications API is not available for public-only tokens because a user's notifications mix + // public and private repository events in the same mailbox. m.Group("/notifications", func() { m.Combo(""). Get(reqToken(), notify.ListNotifications). @@ -965,7 +987,7 @@ func Routes() *web.Router { m.Combo("/threads/{id}"). Get(reqToken(), notify.GetThread). Patch(reqToken(), notify.ReadThread) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification), rejectPublicOnly()) // Users (requires user scope) m.Group("/users", func() { @@ -1013,8 +1035,9 @@ func Routes() *web.Router { m.Group("/settings", func() { m.Get("", user.GetUserSettings) m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings) - }, reqToken()) - m.Combo("/emails"). + }, rejectPublicOnly()) + // Email addresses are always private account data. + m.Combo("/emails", rejectPublicOnly()). Get(user.ListEmails). Post(bind(api.CreateEmailOption{}), user.AddEmail). Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail) @@ -1046,7 +1069,7 @@ func Routes() *web.Router { m.Get("/runs", reqToken(), user.ListWorkflowRuns) m.Get("/jobs", reqToken(), user.ListWorkflowJobs) - }) + }, rejectPublicOnly()) m.Get("/followers", user.ListMyFollowers) m.Group("/following", func() { @@ -1064,7 +1087,7 @@ func Routes() *web.Router { Post(bind(api.CreateKeyOption{}), user.CreatePublicKey) m.Combo("/{id}").Get(user.GetPublicKey). Delete(user.DeletePublicKey) - }) + }, rejectPublicOnly()) // (admin:application scope) m.Group("/applications", func() { @@ -1075,7 +1098,7 @@ func Routes() *web.Router { Delete(user.DeleteOauth2Application). Patch(bind(api.CreateOAuth2ApplicationOptions{}), user.UpdateOauth2Application). Get(user.GetOauth2Application) - }) + }, rejectPublicOnly()) // (admin:gpg_key scope) m.Group("/gpg_keys", func() { @@ -1083,13 +1106,13 @@ func Routes() *web.Router { Post(bind(api.CreateGPGKeyOption{}), user.CreateGPGKey) m.Combo("/{id}").Get(user.GetGPGKey). Delete(user.DeleteGPGKey) - }) - m.Get("/gpg_key_token", user.GetVerificationToken) - m.Post("/gpg_key_verify", bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey) + }, rejectPublicOnly()) + m.Get("/gpg_key_token", rejectPublicOnly(), user.GetVerificationToken) + m.Post("/gpg_key_verify", rejectPublicOnly(), bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey) // (repo scope) m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos). - Post(bind(api.CreateRepoOption{}), repo.Create) + Post(rejectPublicOnly(), bind(api.CreateRepoOption{}), repo.Create) // (repo scope) m.Group("/starred", func() { @@ -1100,22 +1123,22 @@ func Routes() *web.Router { m.Delete("", user.Unstar) }, repoAssignment(), checkTokenPublicOnly()) }, reqStarsEnabled(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) - m.Get("/times", repo.ListMyTrackedTimes) - m.Get("/stopwatches", repo.GetStopwatches) + m.Get("/times", rejectPublicOnly(), repo.ListMyTrackedTimes) + m.Get("/stopwatches", rejectPublicOnly(), repo.GetStopwatches) m.Get("/subscriptions", user.GetMyWatchedRepos) - m.Get("/teams", org.ListUserTeams) + m.Get("/teams", rejectPublicOnly(), org.ListUserTeams) m.Group("/hooks", func() { m.Combo("").Get(user.ListHooks). Post(bind(api.CreateHookOption{}), user.CreateHook) m.Combo("/{id}").Get(user.GetHook). Patch(bind(api.EditHookOption{}), user.EditHook). Delete(user.DeleteHook) - }, reqWebhooksEnabled()) + }, reqWebhooksEnabled(), rejectPublicOnly()) m.Group("/avatar", func() { m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) m.Delete("", user.DeleteAvatar) - }) + }, rejectPublicOnly()) m.Group("/blocks", func() { m.Get("", user.ListBlocks) @@ -1124,8 +1147,8 @@ func Routes() *web.Router { m.Put("", user.BlockUser) m.Delete("", user.UnblockUser) }, context.UserAssignmentAPI(), checkTokenPublicOnly()) - }) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) + }, rejectPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextAuthenticatedUser(), checkTokenPublicOnly()) // Repositories (requires repo scope, org scope) m.Post("/org/{org}/repos", @@ -1601,7 +1624,7 @@ func Routes() *web.Router { }, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) // Organizations - m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs) + m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), checkTokenPublicOnly(), org.ListMyOrgs) m.Group("/users/{username}/orgs", func() { m.Get("", reqToken(), org.ListUserOrgs) m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 2df871a0aa..d34462540d 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -40,6 +40,7 @@ func listUserOrgs(ctx *context.APIContext, u *user_model.User) { UserID: u.ID, IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, u), } + opts.ApplyPublicOnly(ctx.PublicOnly) orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts) if err != nil { ctx.APIErrorInternal(err) @@ -199,7 +200,7 @@ func GetAll(ctx *context.APIContext) { // "$ref": "#/responses/OrganizationList" vMode := []api.VisibleType{api.VisibleTypePublic} - if ctx.IsSigned && !ctx.PublicOnly { + if ctx.IsSigned { vMode = append(vMode, api.VisibleTypeLimited) if ctx.Doer.IsAdmin { vMode = append(vMode, api.VisibleTypePrivate) @@ -208,13 +209,16 @@ func GetAll(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ + searchOpts := user_model.SearchUserOptions{ Actor: ctx.Doer, ListOptions: listOptions, Types: []user_model.UserType{user_model.UserTypeOrganization}, OrderBy: db.SearchOrderByAlphabetically, Visible: vMode, - }) + } + searchOpts.ApplyPublicOnly(ctx.PublicOnly) + + publicOrgs, maxResults, err := user_model.SearchUsers(ctx, searchOpts) if err != nil { ctx.APIErrorInternal(err) return @@ -494,6 +498,7 @@ func ListOrgActivityFeeds(ctx *context.APIContext) { Date: ctx.FormString("date"), ListOptions: listOptions, } + opts.ApplyPublicOnly(ctx.PublicOnly) feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 39ca7fb77e..64692e0c22 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -47,9 +47,10 @@ func buildSearchIssuesRepoIDs(ctx *context.APIContext) (repoIDs []int64, allPubl Actor: ctx.Doer, } if ctx.IsSigned { - opts.Private = !ctx.PublicOnly + opts.Private = true opts.AllLimited = true } + opts.ApplyPublicOnly(ctx.PublicOnly) if ctx.FormString("owner") != "" { owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) if err != nil { diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 12e525464c..83c5b02e49 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -134,9 +134,6 @@ func Search(ctx *context.APIContext) { // "$ref": "#/responses/validationError" private := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")) - if ctx.PublicOnly { - private = false - } opts := repo_model.SearchRepoOptions{ ListOptions: utils.GetListOptions(ctx), @@ -152,6 +149,7 @@ func Search(ctx *context.APIContext) { StarredByID: ctx.FormInt64("starredBy"), IncludeDescription: ctx.FormBool("includeDesc"), } + opts.ApplyPublicOnly(ctx.PublicOnly) if ctx.FormString("template") != "" { opts.Template = optional.Some(ctx.FormBool("template")) @@ -557,6 +555,10 @@ func GetByID(ctx *context.APIContext) { } return } + if !ctx.TokenCanAccessRepo(repo) { + ctx.APIErrorNotFound() + return + } permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer) if err != nil { @@ -1309,6 +1311,7 @@ func ListRepoActivityFeeds(ctx *context.APIContext) { Date: ctx.FormString("date"), ListOptions: listOptions, } + opts.ApplyPublicOnly(ctx.PublicOnly) feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go index a664888dbf..0e0b6b431b 100644 --- a/routers/api/v1/user/repo.go +++ b/routers/api/v1/user/repo.go @@ -19,12 +19,15 @@ import ( func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) { opts := utils.GetListOptions(ctx) - repos, count, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ + searchOpts := repo_model.SearchRepoOptions{ Actor: u, Private: private, ListOptions: opts, OrderBy: "id ASC", - }) + } + searchOpts.ApplyPublicOnly(ctx.PublicOnly) + + repos, count, err := repo_model.GetUserRepositories(ctx, searchOpts) if err != nil { ctx.APIErrorInternal(err) return @@ -79,8 +82,7 @@ func ListUserRepos(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - private := ctx.IsSigned - listUserRepos(ctx, ctx.ContextUser, private) + listUserRepos(ctx, ctx.ContextUser, ctx.IsSigned) } // ListMyRepos - list the repositories you own or have access to. @@ -110,6 +112,7 @@ func ListMyRepos(ctx *context.APIContext) { Private: ctx.IsSigned, IncludeDescription: true, } + opts.ApplyPublicOnly(ctx.PublicOnly) repos, count, err := repo_model.SearchRepository(ctx, opts) if err != nil { diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index 50a54b2683..fb2a324eed 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -20,11 +20,14 @@ import ( // getStarredRepos returns the repos that the user with the specified userID has // starred func getStarredRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, error) { - starredRepos, err := repo_model.GetStarredRepos(ctx, &repo_model.StarredReposOptions{ + opts := &repo_model.StarredReposOptions{ ListOptions: utils.GetListOptions(ctx), StarrerID: user.ID, IncludePrivate: private, - }) + } + opts.ApplyPublicOnly(ctx.PublicOnly) + + starredRepos, err := repo_model.GetStarredRepos(ctx, opts) if err != nil { return nil, err } diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 005770c571..539e6d7f6a 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -9,7 +9,6 @@ import ( activities_model "code.gitea.io/gitea/models/activities" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" @@ -69,19 +68,16 @@ func Search(ctx *context.APIContext) { maxResults = 1 users = []*user_model.User{user_model.NewActionsUser()} default: - var visible []structs.VisibleType - if ctx.PublicOnly { - visible = []structs.VisibleType{structs.VisibleTypePublic} - } - users, maxResults, err = user_model.SearchUsers(ctx, user_model.SearchUserOptions{ + opts := user_model.SearchUserOptions{ Actor: ctx.Doer, Keyword: ctx.FormTrim("q"), UID: uid, Types: []user_model.UserType{user_model.UserTypeIndividual}, SearchByEmail: true, - Visible: visible, ListOptions: listOptions, - }) + } + opts.ApplyPublicOnly(ctx.PublicOnly) + users, maxResults, err = user_model.SearchUsers(ctx, opts) if err != nil { ctx.JSON(http.StatusInternalServerError, map[string]any{ "ok": false, @@ -214,6 +210,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) { Date: ctx.FormString("date"), ListOptions: listOptions, } + opts.ApplyPublicOnly(ctx.PublicOnly) feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go index 9c11d5ca35..1773193f06 100644 --- a/routers/api/v1/user/watch.go +++ b/routers/api/v1/user/watch.go @@ -18,11 +18,14 @@ import ( // getWatchedRepos returns the repos that the user with the specified userID is watching func getWatchedRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, int64, error) { - watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, &repo_model.WatchedReposOptions{ + opts := &repo_model.WatchedReposOptions{ ListOptions: utils.GetListOptions(ctx), WatcherID: user.ID, IncludePrivate: private, - }) + } + opts.ApplyPublicOnly(ctx.PublicOnly) + + watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, opts) if err != nil { return nil, 0, err } diff --git a/services/context/api.go b/services/context/api.go index 3f9f3e1cdd..5b3a35a7fa 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" @@ -47,6 +48,12 @@ type APIContext struct { PublicOnly bool // Whether the request is for a public endpoint } +// TokenCanAccessRepo reports whether the current API token is allowed to access the repository. +// A public-only token cannot reach a private repo; any other token is unrestricted by this check. +func (ctx *APIContext) TokenCanAccessRepo(repo *repo_model.Repository) bool { + return repo == nil || !ctx.PublicOnly || !repo.IsPrivate +} + func init() { web.RegisterResponseStatusProvider[*APIContext](func(req *http.Request) web_types.ResponseStatusProvider { return req.Context().Value(apiContextKey).(*APIContext) diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 8f20814251..b8728ef09f 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -108,7 +108,7 @@ func testAPIListIssuesPublicOnly(t *testing.T) { publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly) req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken) - MakeRequest(t, req, http.StatusForbidden) + MakeRequest(t, req, http.StatusNotFound) } func testAPICreateIssue(t *testing.T) { diff --git a/tests/integration/api_notification_test.go b/tests/integration/api_notification_test.go index 275521572d..4b3147ec01 100644 --- a/tests/integration/api_notification_test.go +++ b/tests/integration/api_notification_test.go @@ -209,3 +209,23 @@ func TestAPINotificationPUT(t *testing.T) { assert.True(t, apiNL[0].Unread) assert.False(t, apiNL[0].Pinned) } + +func TestAPINotificationPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5}) + + token := getUserToken(t, user2.Name, auth_model.AccessTokenScopeReadNotification, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/notifications"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + req = NewRequest(t, "GET", "/api/v1/notifications/new"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", thread5.ID)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} diff --git a/tests/integration/api_public_only_test.go b/tests/integration/api_public_only_test.go new file mode 100644 index 0000000000..40cda8cbaf --- /dev/null +++ b/tests/integration/api_public_only_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUserReposPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/repos"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var repos []api.Repository + DecodeJSON(t, resp, &repos) + assert.NotEmpty(t, repos) + for _, repo := range repos { + assert.False(t, repo.Private) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") + + req = NewRequest(t, "GET", "/api/v1/users/user2/repos"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repos) + assert.NotEmpty(t, repos) + for _, repo := range repos { + assert.False(t, repo.Private) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") +} + +func repoNames(repos []api.Repository) []string { + names := make([]string, 0, len(repos)) + for _, repo := range repos { + names = append(names, repo.FullName) + } + return names +} + +func TestAPIRepoByIDPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/repositories/1"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/api/v1/repositories/2"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIActivityFeedsPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser) + req := NewRequest(t, "GET", "/api/v1/users/user2/activities/feeds"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var activities []api.Activity + DecodeJSON(t, resp, &activities) + assert.NotEmpty(t, activities) + + publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly) + req = NewRequest(t, "GET", "/api/v1/users/user2/activities/feeds"). + AddTokenAuth(publicToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assertPublicActivitiesOnly(t, activities) + + orgToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization) + req = NewRequest(t, "GET", "/api/v1/orgs/org3/activities/feeds"). + AddTokenAuth(orgToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assert.NotEmpty(t, activities) + + publicOrgToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly) + req = NewRequest(t, "GET", "/api/v1/orgs/org3/activities/feeds"). + AddTokenAuth(publicOrgToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assertPublicActivitiesOnly(t, activities) +} + +func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) { + t.Helper() + + for _, activity := range activities { + assert.False(t, activity.IsPrivate) + if activity.Repo != nil { + assert.False(t, activity.Repo.Private) + } + } +} diff --git a/tests/integration/api_repo_branch_test.go b/tests/integration/api_repo_branch_test.go index 2438db72c5..c5822456e8 100644 --- a/tests/integration/api_repo_branch_test.go +++ b/tests/integration/api_repo_branch_test.go @@ -29,10 +29,10 @@ func TestAPIRepoBranchesPlain(t *testing.T) { user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) session := loginUser(t, user1.LowerName) - // public only token should be forbidden + // public-only token cannot see a private repo publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteRepository) link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches", repo3.Name)) // a plain repo - MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken), http.StatusNotFound) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) @@ -46,7 +46,7 @@ func TestAPIRepoBranchesPlain(t *testing.T) { assert.Equal(t, "master", branches[1].Name) link2, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch", repo3.Name)) - MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(publicOnlyToken), http.StatusNotFound) resp = MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(token), http.StatusOK) bs, err = io.ReadAll(resp.Body) @@ -55,7 +55,7 @@ func TestAPIRepoBranchesPlain(t *testing.T) { assert.NoError(t, json.Unmarshal(bs, &branch)) assert.Equal(t, "test_branch", branch.Name) - MakeRequest(t, NewRequest(t, "POST", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "POST", link.String()).AddTokenAuth(publicOnlyToken), http.StatusNotFound) req := NewRequest(t, "POST", link.String()).AddTokenAuth(token) req.Header.Add("Content-Type", "application/json") @@ -81,7 +81,7 @@ func TestAPIRepoBranchesPlain(t *testing.T) { link3, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch2", repo3.Name)) MakeRequest(t, NewRequest(t, "DELETE", link3.String()), http.StatusNotFound) - MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(publicOnlyToken), http.StatusNotFound) MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(token), http.StatusNoContent) assert.NoError(t, err) diff --git a/tests/integration/api_user_orgs_test.go b/tests/integration/api_user_orgs_test.go index 3e8e17c28a..30e2578152 100644 --- a/tests/integration/api_user_orgs_test.go +++ b/tests/integration/api_user_orgs_test.go @@ -154,3 +154,44 @@ func TestMyOrgs(t *testing.T) { }, }, orgs) } + +func TestMyOrgsPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + token := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/orgs"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var orgs []*api.Organization + DecodeJSON(t, resp, &orgs) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org17"}) + + assert.Equal(t, []*api.Organization{ + { + ID: 17, + Name: org17.Name, + UserName: org17.Name, + FullName: org17.FullName, + Email: org17.Email, + AvatarURL: org17.AvatarLink(t.Context()), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + { + ID: 3, + Name: org3.Name, + UserName: org3.Name, + FullName: org3.FullName, + Email: org3.Email, + AvatarURL: org3.AvatarLink(t.Context()), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + }, orgs) +} diff --git a/tests/integration/api_user_public_only_test.go b/tests/integration/api_user_public_only_test.go new file mode 100644 index 0000000000..5404477cda --- /dev/null +++ b/tests/integration/api_user_public_only_test.go @@ -0,0 +1,177 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "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/tests" + + "github.com/stretchr/testify/require" +) + +func TestAPIPublicOnlySelfUserRoutes(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user31"}) + require.True(t, privateUser.Visibility.IsPrivate()) + + privateSession := loginUser(t, privateUser.Name) + privateReadUserToken := getTokenForLoggedInUser(t, privateSession, + auth_model.AccessTokenScopePublicOnly, + auth_model.AccessTokenScopeReadUser, + ) + privateWriteUserToken := getTokenForLoggedInUser(t, privateSession, + auth_model.AccessTokenScopePublicOnly, + auth_model.AccessTokenScopeWriteUser, + ) + + t.Run("PrivateProfileForbidden", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user31").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + }) + + t.Run("PrivateSensitiveSelfRoutesForbidden", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + hideEmail := true + settingsReq := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{ + HideEmail: &hideEmail, + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, settingsReq, http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/emails").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + emailReq := NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &api.CreateEmailOption{ + Emails: []string{"user31-public-only@example.com"}, + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, emailReq, http.StatusForbidden) + + keyReq := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", api.CreateKeyOption{ + Title: "public-only-private-key", + Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, keyReq, http.StatusForbidden) + + oauthReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &api.CreateOAuth2ApplicationOptions{ + Name: "public-only-private-oauth-app", + RedirectURIs: []string{"https://example.com/callback"}, + ConfidentialClient: true, + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, oauthReq, http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/gpg_keys").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + gpgKeyReq := NewRequestWithJSON(t, "POST", "/api/v1/user/gpg_keys", &api.CreateGPGKeyOption{ + ArmoredKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----\ncomment\n-----END PGP PUBLIC KEY BLOCK-----", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, gpgKeyReq, http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/gpg_key_token").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + gpgVerifyReq := NewRequestWithJSON(t, "POST", "/api/v1/user/gpg_key_verify", &api.VerifyGPGKeyOption{ + KeyID: "deadbeef", + Signature: "invalid-signature", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, gpgVerifyReq, http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/actions/variables").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", "/api/v1/user/actions/secrets/PRIVATE_SECRET").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + variableReq := NewRequestWithJSON(t, "POST", "/api/v1/user/actions/variables/PRIVATE_VAR", api.CreateVariableOption{ + Value: "private-value", + Description: "must stay private", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, variableReq, http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "POST", "/api/v1/user/actions/runners/registration-token").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/hooks").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + hookReq := NewRequestWithJSON(t, "POST", "/api/v1/user/hooks", api.CreateHookOption{ + Type: "gitea", + Config: api.CreateHookOptionConfig{ + "content_type": "json", + "url": "http://example.com/", + }, + Name: "public-only-private-hook", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, hookReq, http.StatusForbidden) + + avatarReq := NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &api.UpdateUserAvatarOption{ + Image: "aGVsbG8=", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, avatarReq, http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", "/api/v1/user/avatar").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/times").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/stopwatches").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/subscriptions").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/teams").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/blocks").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "PUT", "/api/v1/user/blocks/user2").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "PUT", "/api/v1/user/following/user2").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", "/api/v1/user/following/user2").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + }) + + t.Run("PublicRepoRoutesFilterAndRejectMutations", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + publicSession := loginUser(t, "user2") + fullWriteRepoToken := getTokenForLoggedInUser(t, publicSession, + auth_model.AccessTokenScopeWriteUser, + auth_model.AccessTokenScopeWriteRepository, + ) + publicOnlyReadRepoToken := getTokenForLoggedInUser(t, publicSession, + auth_model.AccessTokenScopePublicOnly, + auth_model.AccessTokenScopeReadUser, + auth_model.AccessTokenScopeReadRepository, + ) + publicOnlyWriteRepoToken := getTokenForLoggedInUser(t, publicSession, + auth_model.AccessTokenScopePublicOnly, + auth_model.AccessTokenScopeWriteUser, + auth_model.AccessTokenScopeWriteRepository, + ) + + publicRepoName := "public-only-visible-self-repo" + privateRepoName := "public-only-hidden-self-repo" + + resp := MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: publicRepoName, + Private: false, + }).AddTokenAuth(fullWriteRepoToken), http.StatusCreated) + publicRepo := DecodeJSON(t, resp, &api.Repository{}) + require.Equal(t, "user2/"+publicRepoName, publicRepo.FullName) + + resp = MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: privateRepoName, + Private: true, + }).AddTokenAuth(fullWriteRepoToken), http.StatusCreated) + privateRepo := DecodeJSON(t, resp, &api.Repository{}) + require.Equal(t, "user2/"+privateRepoName, privateRepo.FullName) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/"+privateRepoName).AddTokenAuth(publicOnlyReadRepoToken), http.StatusNotFound) + + resp = MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/repos").AddTokenAuth(publicOnlyReadRepoToken), http.StatusOK) + repos := DecodeJSON(t, resp, []api.Repository{}) + + foundPublicRepo := false + for _, repo := range repos { + require.NotEqual(t, privateRepo.FullName, repo.FullName) + if repo.FullName == publicRepo.FullName { + foundPublicRepo = true + } + } + require.True(t, foundPublicRepo) + + MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: "public-only-rejected-self-repo", + Private: false, + }).AddTokenAuth(publicOnlyWriteRepoToken), http.StatusForbidden) + }) +} diff --git a/tests/integration/api_user_star_test.go b/tests/integration/api_user_star_test.go index 65422a4f98..f6b43da4c4 100644 --- a/tests/integration/api_user_star_test.go +++ b/tests/integration/api_user_star_test.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAPIStar(t *testing.T) { @@ -153,3 +154,24 @@ func TestAPIStarDisabled(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) }) } + +func TestAPIStarPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/starred"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + repos := DecodeJSON(t, resp, []api.Repository{}) + if assert.Len(t, repos, 1) { + assert.Equal(t, "user5/repo4", repos[0].FullName) + } + + req = NewRequest(t, "GET", "/api/v1/users/user2/starred"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + repos = DecodeJSON(t, resp, []api.Repository{}) + require.Len(t, repos, 1) + assert.Equal(t, "user5/repo4", repos[0].FullName) +} diff --git a/tests/integration/api_user_watch_test.go b/tests/integration/api_user_watch_test.go index 002b8a10e6..6916c5aa59 100644 --- a/tests/integration/api_user_watch_test.go +++ b/tests/integration/api_user_watch_test.go @@ -92,3 +92,28 @@ func TestAPIWatch(t *testing.T) { MakeRequest(t, req, http.StatusNoContent) }) } + +func TestAPIWatchPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + writeRepoToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadUser) + publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository) + + MakeRequest(t, NewRequest(t, "PUT", "/api/v1/repos/user2/repo1/subscription").AddTokenAuth(writeRepoToken), http.StatusOK) + MakeRequest(t, NewRequest(t, "PUT", "/api/v1/repos/user2/repo2/subscription").AddTokenAuth(writeRepoToken), http.StatusOK) + + resp := MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/subscriptions").AddTokenAuth(publicOnlyToken), http.StatusOK) + repos := DecodeJSON(t, resp, []api.Repository{}) + for _, r := range repos { + assert.False(t, r.Private, "private repo %s leaked via /user/subscriptions", r.FullName) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") + + resp = MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user1/subscriptions").AddTokenAuth(publicOnlyToken), http.StatusOK) + repos = DecodeJSON(t, resp, []api.Repository{}) + for _, r := range repos { + assert.False(t, r.Private, "private repo %s leaked via /users/{username}/subscriptions", r.FullName) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") +} From 78d744aa011ad44be77c8bcfd305b5b8b7ada952 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Tue, 19 May 2026 01:13:51 +0000 Subject: [PATCH 07/25] [skip ci] Updated translations via Crowdin --- options/locale/locale_fr-FR.json | 28 +++++++++++++++++++++------- options/locale/locale_ga-IE.json | 11 ++++++++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/options/locale/locale_fr-FR.json b/options/locale/locale_fr-FR.json index aa295c98a2..f07668d90d 100644 --- a/options/locale/locale_fr-FR.json +++ b/options/locale/locale_fr-FR.json @@ -313,7 +313,6 @@ "install.admin_email": "Courriel", "install.install_btn_confirm": "Installer Gitea", "install.test_git_failed": "Le test de la commande \"git\" a échoué : %v", - "install.sqlite3_not_available": "Cette version de Gitea ne supporte pas SQLite3. Veuillez télécharger la version binaire officielle de %s (pas la version 'gobuild').", "install.invalid_db_setting": "Les paramètres de la base de données sont invalides : %v", "install.invalid_db_table": "La table \"%s\" de la base de données est invalide : %v", "install.invalid_repo_path": "Le chemin racine du dépôt est invalide : %v", @@ -1306,7 +1305,7 @@ "repo.editor.upload_file_is_locked": "Le fichier \"%s\" est verrouillé par %s.", "repo.editor.upload_files_to_dir": "Téléverser les fichiers vers \"%s\"", "repo.editor.cannot_commit_to_protected_branch": "Impossible de créer une révision sur la branche protégée \"%s\".", - "repo.editor.no_commit_to_branch": "Impossible d'enregistrer la révision directement sur la branche parce que :", + "repo.editor.no_commit_to_branch": "Impossible de réviser cette branche car :", "repo.editor.user_no_push_to_branch": "L'utilisateur ne peut pas pousser vers la branche", "repo.editor.require_signed_commit": "Cette branche nécessite une révision signée", "repo.editor.cherry_pick": "Picorer %s vers:", @@ -1322,7 +1321,7 @@ "repo.commits.desc": "Naviguer dans l'historique des modifications.", "repo.commits.commits": "Révisions", "repo.commits.no_commits": "Pas de révisions en commun. \"%s\" et \"%s\" ont des historiques entièrement différents.", - "repo.commits.nothing_to_compare": "Ces branches sont égales.", + "repo.commits.nothing_to_compare": "Ces révisions sont équivalentes.", "repo.commits.search.tooltip": "Vous pouvez utiliser les mots-clés \"author:\", \"committer:\", \"after:\", ou \"before:\" pour filtrer votre recherche, ex.: \"revert author:Alice before:2019-01-13\".", "repo.commits.search_branch": "Cette branche", "repo.commits.search_all": "Toutes les branches", @@ -1784,9 +1783,9 @@ "repo.pulls.select_commit_hold_shift_for_range": "Maintenir Maj et cliquer sur des révisions pour sélectionner un ensemble de révisions.", "repo.pulls.review_only_possible_for_full_diff": "Une évaluation n'est possible que lorsque vous affichez le différentiel complet.", "repo.pulls.filter_changes_by_commit": "Filtrer par révision", - "repo.pulls.nothing_to_compare": "Ces branches sont identiques. Il n’y a pas besoin de créer une demande d'ajout.", + "repo.pulls.nothing_to_compare": "Ces branches ou étiquettes sont équivalentes, il n’est pas nécessaire de créer une demande d’ajout.", "repo.pulls.no_common_history": "Ces branches ne partagent pas de base de fusion commune. Sélectionnez une autre base ou branche de comparaison.", - "repo.pulls.nothing_to_compare_have_tag": "Les branches/étiquettes sélectionnées sont équivalentes.", + "repo.pulls.nothing_to_compare_have_tag": "Ces branches ou étiquettes sont équivalentes.", "repo.pulls.nothing_to_compare_and_allow_empty_pr": "Ces branches sont égales. Cette demande d'ajout sera vide.", "repo.pulls.has_pull_request": "'Il existe déjà une demande d'ajout entre ces deux branches : %[2]s#%[3]d'", "repo.pulls.create": "Créer une demande d'ajout", @@ -1820,6 +1819,7 @@ "repo.pulls.required_status_check_failed": "Certains contrôles requis n'ont pas réussi.", "repo.pulls.required_status_check_missing": "Certains contrôles requis sont manquants.", "repo.pulls.required_status_check_administrator": "En tant qu'administrateur, vous pouvez toujours fusionner cette requête de pull.", + "repo.pulls.required_status_check_bypass_allowlist": "Vous êtes autorisé à contourner les règles de protection pour cette fusion.", "repo.pulls.blocked_by_approvals": "Cette demande d’ajout n’est pas suffisamment approuvée. %d approbations obtenues sur %d.", "repo.pulls.blocked_by_approvals_whitelisted": "Cette demande d’ajout n’a pas encore assez d’approbations. %d sur %d approbations de la part des utilisateurs ou équipes sur la liste autorisée.", "repo.pulls.blocked_by_rejection": "Cette demande d’ajout nécessite des corrections sollicitées par un évaluateur officiel.", @@ -2139,7 +2139,9 @@ "repo.settings.pulls_desc": "Activer les demandes de fusion", "repo.settings.pulls.ignore_whitespace": "Ignorer les espaces lors des conflits", "repo.settings.pulls.enable_autodetect_manual_merge": "Activer la détection automatique de la fusion manuelle (Remarque : dans certains cas particuliers, des erreurs de détection peuvent se produire)", + "repo.settings.pulls.allow_merge_update": "Activer la mise à jour d’une demande d’ajout par rebase", "repo.settings.pulls.allow_rebase_update": "Activer la mise à jour de demande d'ajout par rebase", + "repo.settings.pulls.default_update_style": "Style de mise à jour de la branche par défaut", "repo.settings.pulls.default_target_branch": "Branche cible par défaut pour les nouvelles demandes d’ajout", "repo.settings.pulls.default_target_branch_default": "Branche par défaut (%s)", "repo.settings.pulls.default_delete_branch_after_merge": "Supprimer la branche après la fusion par default", @@ -2414,6 +2416,11 @@ "repo.settings.protect_merge_whitelist_committers_desc": "N’autoriser que les utilisateurs et les équipes listés à appliquer les demandes de fusion sur cette branche.", "repo.settings.protect_merge_whitelist_users": "Utilisateurs autorisés à fusionner :", "repo.settings.protect_merge_whitelist_teams": "Équipes autorisées à fusionner :", + "repo.settings.protect_bypass_allowlist": "Contourner la protection de la branche", + "repo.settings.protect_enable_bypass_allowlist": "Autoriser des utilisateurs et des équipes à contourner les restrictions de branche", + "repo.settings.protect_enable_bypass_allowlist_desc": "Les utilisateurs ou équipes autorisés peuvent fusionner ou pousser des changements nonobstant les règles d’approbations, de vérifications de statut et les protections fichiers.", + "repo.settings.protect_bypass_allowlist_users": "Liste d’utilisateurs autorisés à contourner les protections :", + "repo.settings.protect_bypass_allowlist_teams": "Liste d’équipes autorisées à contourner les protections :", "repo.settings.protect_check_status_contexts": "Activer le Contrôle Qualité", "repo.settings.protect_status_check_patterns": "Motifs de vérification des statuts :", "repo.settings.protect_status_check_patterns_desc": "Entrez des motifs pour spécifier quelles vérifications doivent réussir avant que des branches puissent être fusionnées. Un motif par ligne. Un motif ne peut être vide.", @@ -2455,7 +2462,7 @@ "repo.settings.block_outdated_branch": "Bloquer la fusion si la demande d'ajout est obsolète", "repo.settings.block_outdated_branch_desc": "La fusion ne sera pas possible lorsque la branche principale est derrière la branche de base.", "repo.settings.block_admin_merge_override": "Les administrateurs doivent respecter les règles de protection des branches", - "repo.settings.block_admin_merge_override_desc": "Les administrateurs doivent respecter les règles de protection des branches et ne peuvent pas les contourner.", + "repo.settings.block_admin_merge_override_desc": "Les administrateurs sont également soumis aux règles de protection des branches. En activant la liste d’autorisation de contournement, les utilisateurs et équipes qui y figurent peuvent toujours contourner ces règles.", "repo.settings.default_branch_desc": "Sélectionnez une branche par défaut pour les révisions.", "repo.settings.default_target_branch_desc": "Les demandes d’ajout peuvent utiliser une branche cible différente, telle que définie dans la section Demandes d’ajouts des Paramètres avancés du dépôt.", "repo.settings.merge_style_desc": "Styles de fusion", @@ -3623,7 +3630,13 @@ "packages.terraform.delete.latest": "La dernière version d’un état Terraform ne peut pas être supprimée.", "packages.vagrant.install": "Pour ajouter une machine Vagrant, exécutez la commande suivante :", "packages.settings.link": "Lier ce paquet à un dépôt", - "packages.settings.link.description": "Si vous associez un paquet à un dépôt, le paquet sera inclus dans sa liste des paquets. Seul les dépôts d’un même propriétaire peuvent être associés. Laisser ce champ vide supprimera le lien.", + "packages.settings.link.description": "Si vous liez un paquet à dépôt, il sera visible dans sa liste des paquets.", + "packages.settings.link.notice1": "Seuls les dépôts du même propriétaire peuvent être liés.", + "packages.settings.link.notice2": "Lier un dépôt ne modifie pas la visibilité du paquet.", + "packages.settings.link.notice3": "Laisser le champ vide supprimera le lien.", + "packages.settings.visibility": "Visibilité du paquet", + "packages.settings.visibility.inherit": "La visibilité du paquet est héritée depuis le propriétaire et ne peut pas être modifiée ici. Pour la modifier, mettez à jour les paramètres de visibilité de l’utilisateur ou de l'organisation propriétaire de ce paquet.", + "packages.settings.visibility.button": "Modifier la visibilité du propriétaire", "packages.settings.link.select": "Sélectionner un dépôt", "packages.settings.link.button": "Actualiser le lien du dépôt", "packages.settings.link.success": "Le lien du dépôt a été mis à jour avec succès.", @@ -3697,6 +3710,7 @@ "actions.status.success": "Succès", "actions.status.failure": "Échec", "actions.status.cancelled": "Annulé", + "actions.status.cancelling": "Annulation", "actions.status.skipped": "Ignoré", "actions.status.blocked": "Bloqué", "actions.runners": "Exécuteurs", diff --git a/options/locale/locale_ga-IE.json b/options/locale/locale_ga-IE.json index d648a83fa5..43f83c3423 100644 --- a/options/locale/locale_ga-IE.json +++ b/options/locale/locale_ga-IE.json @@ -1819,6 +1819,7 @@ "repo.pulls.required_status_check_failed": "Níor éirigh le roinnt seiceálacha riachtanacha.", "repo.pulls.required_status_check_missing": "Tá roinnt seiceanna riachtanacha ar iarraidh.", "repo.pulls.required_status_check_administrator": "Mar riarthóir, féadfaidh tú an t-iarratas tarraingthe seo a chumasc fós.", + "repo.pulls.required_status_check_bypass_allowlist": "Ceadaítear duit rialacha cosanta brainse a sheachbhóthar don chumasc seo.", "repo.pulls.blocked_by_approvals": "Níl go leor ceadaithe ag an iarraidh tarraingthe seo fós. Deonaíodh %d den fhaomhadh %d.", "repo.pulls.blocked_by_approvals_whitelisted": "Níl go leor ceaduithe riachtanacha ag an iarratas tarraingte seo go fóill. %d de %d faomhadh tugtha ó úsáideoirí nó foirne ar an liosta ceadaithe.", "repo.pulls.blocked_by_rejection": "Tá athruithe ag athbhreithneoir oifigiúil ag an iarratas tarraingthe seo.", @@ -2138,7 +2139,9 @@ "repo.settings.pulls_desc": "Cumasaigh Iarratais Tarraingthe Stóras", "repo.settings.pulls.ignore_whitespace": "Déan neamhaird de spás bán le haghaidh coinbhleachtaí", "repo.settings.pulls.enable_autodetect_manual_merge": "Cumasaigh cumasc láimhe autodetector (Nóta: I roinnt cásanna speisialta, is féidir míbhreithiúnais tarlú)", + "repo.settings.pulls.allow_merge_update": "Cumasaigh brainse iarratais tarraingthe a nuashonrú trí chumasc", "repo.settings.pulls.allow_rebase_update": "Cumasaigh brainse iarratais tarraingthe a nuashonrú trí athbhunú", + "repo.settings.pulls.default_update_style": "Stíl réamhshocraithe nuashonraithe brainse", "repo.settings.pulls.default_target_branch": "Brainse sprice réamhshocraithe le haghaidh iarratais tarraingthe nua", "repo.settings.pulls.default_target_branch_default": "Brainse réamhshocraithe (%s)", "repo.settings.pulls.default_delete_branch_after_merge": "Scrios brainse an iarratais tarraingthe tar éis cumasc de réir réamhshocraithe", @@ -2413,6 +2416,11 @@ "repo.settings.protect_merge_whitelist_committers_desc": "Ní lig ach d'úsáideoirí nó d'fhoirne liostaithe iarratais tarraingthe isteach sa bhrainse seo a chumasc.", "repo.settings.protect_merge_whitelist_users": "Úsáideoirí ar an Liosta Ceadaithe le haghaidh cumasc:", "repo.settings.protect_merge_whitelist_teams": "Foirne ar an Liosta Ceadaithe le haghaidh cumasc:", + "repo.settings.protect_bypass_allowlist": "Cosaint brainse seachbhóthair", + "repo.settings.protect_enable_bypass_allowlist": "Ceadaigh d’úsáideoirí nó d’fhoirne roghnaithe cosaint brainse a sheachbhóthar", + "repo.settings.protect_enable_bypass_allowlist_desc": "Is féidir le húsáideoirí nó foirne atá ar an liosta ceadaithe cumasc nó brú fiú nuair a chuirfeadh ceaduithe riachtanacha, seiceálacha stádais nó rialacha comhad cosanta bac orthu murach sin.", + "repo.settings.protect_bypass_allowlist_users": "Úsáideoirí ar an liosta ceadanna chun cosaint a sheachbhóthar:", + "repo.settings.protect_bypass_allowlist_teams": "Foirne ar an liosta ceadaithe chun cosaint a sheachbhóthar:", "repo.settings.protect_check_status_contexts": "Cumasaigh Seiceáil Stádas", "repo.settings.protect_status_check_patterns": "Patrúin seiceála stádais:", "repo.settings.protect_status_check_patterns_desc": "Iontráil patrúin chun a shonrú cé na seiceálacha stádais a chaithfidh pas a fháil sular féidir brainsí a chumasc le brainse a chomhoibríonn leis an riail seo. Sonraíonn gach líne patrún. Ní féidir patrúin a bheith folamh.", @@ -2454,7 +2462,7 @@ "repo.settings.block_outdated_branch": "Cuir bac ar chumasc má tá an t-iarratas tarraingthe as dáta", "repo.settings.block_outdated_branch_desc": "Ní bheidh cumasc indéanta nuair a bhíonn ceannbhrainse taobh thiar de bhronnbhrainse.", "repo.settings.block_admin_merge_override": "Ní mór do riarthóirí rialacha cosanta brainse a leanúint", - "repo.settings.block_admin_merge_override_desc": "Ní mór do riarthóirí rialacha cosanta brainse a leanúint agus ní féidir leo iad a sheachaint.", + "repo.settings.block_admin_merge_override_desc": "Ní mór do riarthóirí rialacha cosanta brainse a leanúint agus ní féidir leo dul timpeall orthu. Is féidir le húsáideoirí nó foirne sa liosta ceadanna seachbhóthair na rialacha seo a sheachbhóthar fós má tá an liosta ceadanna seachbhóthair cumasaithe.", "repo.settings.default_branch_desc": "Roghnaigh brainse réamhshocraithe le haghaidh tiomnuithe cóid.", "repo.settings.default_target_branch_desc": "Is féidir le hiarratais tarraingthe brainse sprice réamhshocraithe difriúil a úsáid má tá sé socraithe sa rannán Iarratais Tarraingthe de na Socruithe Ardleibhéil Stórála.", "repo.settings.merge_style_desc": "Stíleanna Cumaisc", @@ -3702,6 +3710,7 @@ "actions.status.success": "Rath", "actions.status.failure": "Teip", "actions.status.cancelled": "Cealaíodh", + "actions.status.cancelling": "Ag cealú", "actions.status.skipped": "Scipeáilte", "actions.status.blocked": "Blocáilte", "actions.runners": "Reathaitheoirí", From a1de9e57c2e165e75a6f8573fb7029236f5d8ac9 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 18 May 2026 21:08:57 -0700 Subject: [PATCH 08/25] ci: split giteabot workflow (#37770) ## What This PR updates the giteabot workflows to use the newer action version that supports selecting individual checks, and splits the workflow into two separate jobs: - `giteabot backport` runs only the `backport` check on pushes to `main` - `giteabot` handles the remaining bot tasks on PR-related events, scheduled runs, and manual dispatch ## Why Previously, the single workflow handled both backporting and the other maintenance tasks together. With the new giteabot action supporting configurable checks, splitting the workflow makes the triggers clearer and avoids running non-backport maintenance on every push to `main`. ## Changes - upgrade `go-gitea/giteabot` to a revision that supports the `checks` input - move the `main` branch `push` trigger into a dedicated backport workflow - keep non-backport automation in the existing workflow - add a `workflow_dispatch` input so non-backport checks can be selected manually when needed --- Helped by a coding agent with Codex 5.4 --------- Co-authored-by: Nicolas --- .github/workflows/giteabot-backport.yml | 26 +++++++++++++++++++++++++ .github/workflows/giteabot.yml | 19 ++++++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/giteabot-backport.yml diff --git a/.github/workflows/giteabot-backport.yml b/.github/workflows/giteabot-backport.yml new file mode 100644 index 0000000000..be4b3d42a1 --- /dev/null +++ b/.github/workflows/giteabot-backport.yml @@ -0,0 +1,26 @@ +name: giteabot backport + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + giteabot: + if: github.repository == 'go-gitea/gitea' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: go-gitea/giteabot@40d7c74f93d479578978c4ef47a655a467b8dab1 # Add config options (#5) + with: + github_token: ${{ secrets.GITEABOT_TOKEN }} + gitea_fork: giteabot/gitea + checks: backport diff --git a/.github/workflows/giteabot.yml b/.github/workflows/giteabot.yml index dad7a19fdb..b9e3cc1651 100644 --- a/.github/workflows/giteabot.yml +++ b/.github/workflows/giteabot.yml @@ -1,9 +1,6 @@ name: giteabot on: - push: - branches: - - main # pull_request_target gives this workflow access to GITEABOT_TOKEN on PRs from # forks, which the bot needs to write labels, statuses and comments. Safe here # because the job only runs a pinned action and never checks out PR HEAD. @@ -24,9 +21,17 @@ on: schedule: - cron: "15 3 * * *" workflow_dispatch: + inputs: + checks: + description: Comma-separated list of non-backport checks to run + required: false + default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions permissions: contents: read + issues: write + pull-requests: write + statuses: write concurrency: group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }} @@ -38,7 +43,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: go-gitea/giteabot@8996d0b0e6c4ab066e3adcaf2c49b5d4cd15d7af # v1.0.1 + # pull_request_review runs without repository secrets on fork PRs, so fall + # back to the workflow token for the non-backport checks handled here. + - uses: go-gitea/giteabot@40d7c74f93d479578978c4ef47a655a467b8dab1 # Add config options (#5) with: - github_token: ${{ secrets.GITEABOT_TOKEN }} - gitea_fork: giteabot/gitea + github_token: ${{ secrets.GITEABOT_TOKEN || github.token }} + checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }} From 0b7fc8a57963a7450277e2eaa2542ba6aaefb9e9 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Mon, 18 May 2026 21:37:42 -0700 Subject: [PATCH 09/25] fix(deps): update module gitlab.com/gitlab-org/api/client-go/v2 to v2.26.0 (#37771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [gitlab.com/gitlab-org/api/client-go/v2](https://gitlab.com/gitlab-org/api/client-go) | `v2.25.0` → `v2.26.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/gitlab.com%2fgitlab-org%2fapi%2fclient-go%2fv2/v2.26.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/gitlab.com%2fgitlab-org%2fapi%2fclient-go%2fv2/v2.25.0/v2.26.0?slim=true) | --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7456bb49f8..dd5cf51ff3 100644 --- a/go.mod +++ b/go.mod @@ -107,7 +107,7 @@ require ( github.com/yohcop/openid-go v1.0.1 github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc - gitlab.com/gitlab-org/api/client-go/v2 v2.25.0 + gitlab.com/gitlab-org/api/client-go/v2 v2.26.0 go.yaml.in/yaml/v4 v4.0.0-rc.3 golang.org/x/crypto v0.51.0 golang.org/x/image v0.40.0 diff --git a/go.sum b/go.sum index 00e8a328db..d04ec0effa 100644 --- a/go.sum +++ b/go.sum @@ -753,8 +753,8 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -gitlab.com/gitlab-org/api/client-go/v2 v2.25.0 h1:ATTBB0Iiup5SRox2IPNSkkrGy/Any7FWBL1BOpZrpCU= -gitlab.com/gitlab-org/api/client-go/v2 v2.25.0/go.mod h1:OSJITkIrT0UuA3JCucEK9UEGcC1PWBkQg5WW6W4nWuo= +gitlab.com/gitlab-org/api/client-go/v2 v2.26.0 h1:1E35d1GRLb22Yq7Jr4gWptjW1+Vg7ZDGogK/SZ6ijV8= +gitlab.com/gitlab-org/api/client-go/v2 v2.26.0/go.mod h1:wx4w52UDENs3jolsL9cYT4byPNN9Pc6UpfGtCcHEIWY= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= From 343eaa894094ed1a3cc4de61ce4a4b13f5a76900 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Mon, 18 May 2026 23:28:06 -0700 Subject: [PATCH 10/25] fix(deps): update npm dependencies (#37768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [katex](https://katex.org) ([source](https://redirect.github.com/KaTeX/KaTeX)) | [`0.16.45` → `0.16.46`](https://renovatebot.com/diffs/npm/katex/0.16.45/0.16.46) | ![age](https://developer.mend.io/api/mc/badges/age/npm/katex/0.16.46?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/katex/0.16.45/0.16.46?slim=true) | | [vue-tsc](https://redirect.github.com/vuejs/language-tools) ([source](https://redirect.github.com/vuejs/language-tools/tree/HEAD/packages/tsc)) | [`3.2.8` → `3.2.9`](https://renovatebot.com/diffs/npm/vue-tsc/3.2.8/3.2.9) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-tsc/3.2.9?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-tsc/3.2.8/3.2.9?slim=true) | --- package.json | 4 ++-- pnpm-lock.yaml | 40 ++++++++++++++++++++-------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index da6b830f3d..4e9ea58c83 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "idiomorph": "0.7.4", "jquery": "4.0.0", "js-yaml": "4.1.1", - "katex": "0.16.45", + "katex": "0.16.46", "mermaid": "11.15.0", "online-3d-viewer": "0.18.0", "pdfobject": "2.3.1", @@ -123,6 +123,6 @@ "typescript-eslint": "8.59.3", "updates": "17.16.11", "vitest": "4.1.6", - "vue-tsc": "3.2.8" + "vue-tsc": "3.2.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bd670ca40..d882eac631 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,8 +166,8 @@ importers: specifier: 4.1.1 version: 4.1.1 katex: - specifier: 0.16.45 - version: 0.16.45 + specifier: 0.16.46 + version: 0.16.46 mermaid: specifier: 11.15.0 version: 11.15.0 @@ -374,8 +374,8 @@ importers: specifier: 4.1.6 version: 4.1.6(@types/node@25.7.0)(happy-dom@20.9.0)(jsdom@20.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(jiti@2.7.0)) vue-tsc: - specifier: 3.2.8 - version: 3.2.8(typescript@6.0.3) + specifier: 3.2.9 + version: 3.2.9(typescript@6.0.3) packages: @@ -1753,8 +1753,8 @@ packages: '@vue/compiler-ssr@3.5.34': resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} - '@vue/language-core@3.2.8': - resolution: {integrity: sha512-9OiSPQFiAAWNVnXb0d2dcTmcKnFQamhuNES6ayyISrb/mwPWVgoGdAqSfCWqKhQpa3D5gDTcYD+w7ObiheZ81g==} + '@vue/language-core@3.2.9': + resolution: {integrity: sha512-ie0ojt/0fU/GfIogh+zgHbaYRPlt9S+cLOxcWwF7nTSFh897BVgnFKL2byT4kpp1mlqYWZ2psGwSniyE2xsxYw==} '@vue/reactivity@3.5.34': resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} @@ -1800,8 +1800,8 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - alien-signals@3.1.2: - resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3101,8 +3101,8 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - katex@0.16.45: - resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + katex@0.16.46: + resolution: {integrity: sha512-WHy4Coo+bGZyH7NwJKHkS04YFsFcarWbAEOAC3EMndzdN6VSZqklLLIgfxzyaW9jDoeGYJX9SWbJPKpecox0Uw==} hasBin: true keyv@4.5.4: @@ -4258,8 +4258,8 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - vue-tsc@3.2.8: - resolution: {integrity: sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog==} + vue-tsc@3.2.9: + resolution: {integrity: sha512-qm8/nbo+9eZc1SCndm9wT+gq23pM+wRIdHY0wjm83B3lIginHTwcdrLUyTrKjDWXbMVNjKegNrnymhpdqnCL3A==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -5928,12 +5928,12 @@ snapshots: '@vue/compiler-dom': 3.5.34 '@vue/shared': 3.5.34 - '@vue/language-core@3.2.8': + '@vue/language-core@3.2.9': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.34 '@vue/shared': 3.5.34 - alien-signals: 3.1.2 + alien-signals: 3.2.1 muggle-string: 0.4.1 path-browserify: 1.0.1 picomatch: 4.0.4 @@ -5997,7 +5997,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alien-signals@3.1.2: {} + alien-signals@3.2.1: {} ansi-regex@5.0.1: {} @@ -7360,7 +7360,7 @@ snapshots: object.assign: '@nolyfill/object.assign@1.0.44' object.values: '@nolyfill/object.values@1.0.44' - katex@0.16.45: + katex@0.16.46: dependencies: commander: 8.3.0 @@ -7558,7 +7558,7 @@ snapshots: dayjs: 1.11.20 dompurify: 3.4.2 es-toolkit: 1.46.1 - katex: 0.16.45 + katex: 0.16.46 khroma: 2.1.0 marked: 16.4.2 roughjs: 4.6.6 @@ -7625,7 +7625,7 @@ snapshots: dependencies: '@types/katex': 0.16.8 devlop: 1.1.0 - katex: 0.16.45 + katex: 0.16.46 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 @@ -8606,10 +8606,10 @@ snapshots: transitivePeerDependencies: - supports-color - vue-tsc@3.2.8(typescript@6.0.3): + vue-tsc@3.2.9(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.2.8 + '@vue/language-core': 3.2.9 typescript: 6.0.3 vue@3.5.34(typescript@6.0.3): From 5ad70f79bac0a116204e314afa6e930062e11edc Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 19 May 2026 16:27:10 +0800 Subject: [PATCH 11/25] fix: package creation unique conflict (#37774) fix #30973 --- services/packages/packages.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/services/packages/packages.go b/services/packages/packages.go index 03b2803297..cf86592ca6 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -17,6 +17,7 @@ import ( packages_model "code.gitea.io/gitea/models/packages" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/globallock" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -78,8 +79,13 @@ func CreatePackageAndAddFile(ctx context.Context, pvci *PackageCreationInfo, pfc } // CreatePackageOrAddFileToExisting creates a package with a file or adds the file if the package exists already -func CreatePackageOrAddFileToExisting(ctx context.Context, pvci *PackageCreationInfo, pfci *PackageFileCreationInfo) (*packages_model.PackageVersion, *packages_model.PackageFile, error) { - return createPackageAndAddFile(ctx, pvci, pfci, true) +func CreatePackageOrAddFileToExisting(ctx context.Context, pvci *PackageCreationInfo, pfci *PackageFileCreationInfo) (pv *packages_model.PackageVersion, pf *packages_model.PackageFile, err error) { + lockKey := fmt.Sprintf("pkg-upsert-%v-%v-%v", pvci.PackageType, pvci.Name, pvci.Version) + err = globallock.LockAndDo(ctx, lockKey, func(ctx context.Context) error { + pv, pf, err = createPackageAndAddFile(ctx, pvci, pfci, true) + return err + }) + return pv, pf, err } func createPackageAndAddFile(ctx context.Context, pvci *PackageCreationInfo, pfci *PackageFileCreationInfo, allowDuplicate bool) (*packages_model.PackageVersion, *packages_model.PackageFile, error) { From dbf4828169fecd43bc688c1d893d3406354276cc Mon Sep 17 00:00:00 2001 From: Lavamini Inc Date: Tue, 19 May 2026 01:57:43 -0700 Subject: [PATCH 12/25] fix: add natural sort to sortTreeViewNodes (#37772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the sorting behavior of view-file-tree with repo-files-table. Attachment below: fix-bug-sort --------- Signed-off-by: wxiaoguang Co-authored-by: wxiaoguang Co-authored-by: Nicolas --- services/repository/files/tree.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/repository/files/tree.go b/services/repository/files/tree.go index fc36127338..393fa592fd 100644 --- a/services/repository/files/tree.go +++ b/services/repository/files/tree.go @@ -13,6 +13,7 @@ import ( "strings" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" @@ -164,7 +165,7 @@ func sortTreeViewNodes(nodes []*TreeViewNode) { if a != b { return a < b } - return nodes[i].EntryName < nodes[j].EntryName + return base.NaturalSortCompare(nodes[i].EntryName, nodes[j].EntryName) < 0 }) } From 171df0c9ffcec1b0839431e75f3b59e35d39ddca Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Tue, 19 May 2026 02:23:32 -0700 Subject: [PATCH 13/25] fix(permissions): Fix reading permission (#37769) --- routers/api/v1/api.go | 6 +++--- tests/integration/api_issue_config_test.go | 17 +++++++++++++++++ tests/integration/api_issue_templates_test.go | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e84b07ba47..8e78efae9f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1453,9 +1453,9 @@ func Routes() *web.Router { Delete(reqToken(), repo.DeleteTopic) }, reqAdmin()) }, reqAnyRepoReader()) - m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates) - m.Get("/issue_config", context.ReferencesGitRepo(), repo.GetIssueConfig) - m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig) + m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates) + m.Get("/issue_config", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueConfig) + m.Get("/issue_config/validate", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.ValidateIssueConfig) m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages) m.Get("/licenses", reqRepoReader(unit.TypeCode), repo.GetLicenses) m.Get("/activities/feeds", repo.ListRepoActivityFeeds) diff --git a/tests/integration/api_issue_config_test.go b/tests/integration/api_issue_config_test.go index 31dfa9ce92..06b972d7ee 100644 --- a/tests/integration/api_issue_config_test.go +++ b/tests/integration/api_issue_config_test.go @@ -8,6 +8,7 @@ import ( "net/http" "testing" + 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" @@ -176,3 +177,19 @@ func TestAPIRepoValidateIssueConfig(t *testing.T) { assert.NotEmpty(t, issueConfigValidation.Message) }) } + +func TestAPIRepoIssueConfigRequiresCodeUnit(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 24}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadRepository) + + for _, path := range []string{ + fmt.Sprintf("/api/v1/repos/%s/issue_config", repo.FullName()), + fmt.Sprintf("/api/v1/repos/%s/issue_config/validate", repo.FullName()), + } { + req := NewRequest(t, "GET", path).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + } +} diff --git a/tests/integration/api_issue_templates_test.go b/tests/integration/api_issue_templates_test.go index 452ff1c9d2..e9b28f65ec 100644 --- a/tests/integration/api_issue_templates_test.go +++ b/tests/integration/api_issue_templates_test.go @@ -8,10 +8,12 @@ import ( "net/url" "testing" + 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/tests" "github.com/stretchr/testify/assert" ) @@ -49,3 +51,19 @@ about: bar assert.Equal(t, "error occurs when parsing issue template: count=2", resp.Header().Get("X-Gitea-Warning")) }) } + +func TestAPIIssueTemplateRequiresCodeUnit(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 24}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadRepository) + issueTemplatesURL := "/api/v1/repos/" + repo.FullName() + "/issue_templates" + languagesURL := "/api/v1/repos/" + repo.FullName() + "/languages" + + req := NewRequest(t, "GET", issueTemplatesURL).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + req = NewRequest(t, "GET", languagesURL).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} From 621aa67e7d42a11305a437e189606400c58d4306 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 19 May 2026 15:09:56 +0200 Subject: [PATCH 14/25] fix(markup): wrap indented code blocks for the code-copy button (#37748) Indented (4-space) code blocks were emitted by goldmark's default renderer as plain `
` without the `code-block-container`
wrapper that the JS `initMarkupCodeCopy` keys on. As a result, only
fenced code blocks received the copy button. Register
`ast.KindCodeBlock` with a renderer that produces the same wrapper as
the highlighting renderer so both syntaxes get the button.

Extends `TestMarkdownFencedCodeBlock` to assert the wrapper is emitted
for indented blocks (and that HTML inside is escaped).

Co-authored-by: Claude (Opus 4.7) 
Co-authored-by: wxiaoguang 
---
 modules/markup/markdown/goldmark.go      | 21 +++++++++++++++++++++
 modules/markup/markdown/markdown_test.go |  4 +++-
 2 files changed, 24 insertions(+), 1 deletion(-)

diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go
index 4a560517f2..b2b16f5d9f 100644
--- a/modules/markup/markdown/goldmark.go
+++ b/modules/markup/markdown/goldmark.go
@@ -119,12 +119,33 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
 	reg.Register(KindDetails, r.renderDetails)
 	reg.Register(KindSummary, r.renderSummary)
 	reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
+	reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
 	reg.Register(KindAttention, r.renderAttention)
 	reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
 	reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
 	reg.Register(KindRawHTML, r.renderRawHTML)
 }
 
+// renderCodeBlock wraps indented code blocks like the fenced renderer
+func (r *HTMLRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+	if entering {
+		opening := r.renderInternal.ProtectSafeAttrs(`
`)
+		if _, err := w.WriteString(string(opening)); err != nil {
+			return ast.WalkStop, err
+		}
+		lines := n.Lines()
+		for i := 0; i < lines.Len(); i++ {
+			line := lines.At(i)
+			r.Writer.RawWrite(w, line.Value(source))
+		}
+	} else {
+		if _, err := w.WriteString("
"); err != nil { + return ast.WalkStop, err + } + } + return ast.WalkContinue, nil +} + func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { n := node.(*ast.Document) diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index 2f14a0fae9..c759dbf8b1 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -601,7 +601,7 @@ func TestMarkdownUlDir(t *testing.T) { `, string(result)) } -func TestMarkdownFencedCodeBlock(t *testing.T) { +func TestMarkdownCodeBlock(t *testing.T) { testRender := func(input, expected string) { buffer, err := markdown.RenderString(markup.NewTestRenderContext(), input) assert.NoError(t, err) @@ -618,4 +618,6 @@ func TestMarkdownFencedCodeBlock(t *testing.T) { testRender("```js:app.ts\ncode\n```", jsCommon) testRender("```js,ignore\ncode\n```", jsCommon) testRender("```js ignore\ncode\n```", jsCommon) + testRender(" code\n", prefix+`code`+nl+``+suffix) + testRender(" \n", prefix+`<script>alert(1)</script>`+nl+``+suffix) } From 7e436972f985610a3269c71cfbfb7ec56faf71ad Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 16:08:08 +0000 Subject: [PATCH 15/25] fix(markup): make RenderString never fail (#37779) Fix #37778 --------- Signed-off-by: wxiaoguang Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: wxiaoguang <2114189+wxiaoguang@users.noreply.github.com> Co-authored-by: wxiaoguang Co-authored-by: Nicolas --- models/renderhelper/main_test.go | 7 +++++++ models/renderhelper/repo_comment_test.go | 9 ++++----- models/renderhelper/repo_file_test.go | 13 ++++++------- models/renderhelper/repo_wiki_test.go | 7 +++---- models/renderhelper/simple_document_test.go | 3 +-- modules/markup/html_codepreview_test.go | 2 +- modules/markup/html_test.go | 16 +++++++++++----- modules/markup/markdown/markdown.go | 5 ++++- modules/markup/render.go | 9 --------- 9 files changed, 37 insertions(+), 34 deletions(-) diff --git a/models/renderhelper/main_test.go b/models/renderhelper/main_test.go index 331450172a..311207e855 100644 --- a/models/renderhelper/main_test.go +++ b/models/renderhelper/main_test.go @@ -5,12 +5,19 @@ package renderhelper import ( "context" + "strings" "testing" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/markup" ) +func testRenderString(ctx *markup.RenderContext, content string) (string, error) { + var buf strings.Builder + err := markup.Render(ctx, strings.NewReader(content), &buf) + return buf.String(), err +} + func TestMain(m *testing.M) { unittest.MainTest(m, &unittest.TestOptions{ FixtureFiles: []string{"repository.yml", "user.yml"}, diff --git a/models/renderhelper/repo_comment_test.go b/models/renderhelper/repo_comment_test.go index 1443f8b3c0..e181a5bdb5 100644 --- a/models/renderhelper/repo_comment_test.go +++ b/models/renderhelper/repo_comment_test.go @@ -8,7 +8,6 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "github.com/stretchr/testify/assert" @@ -21,7 +20,7 @@ func TestRepoComment(t *testing.T) { t.Run("AutoLink", func(t *testing.T) { rctx := NewRenderContextRepoComment(t.Context(), repo1).WithMarkupType(markdown.MarkupName) - rendered, err := markup.RenderString(rctx, ` + rendered, err := testRenderString(rctx, ` 65f1bf27bc3bf70f64657658635e66094edbcb4d #1 @user2 @@ -39,7 +38,7 @@ func TestRepoComment(t *testing.T) { // It is Gitea's old behavior, the relative path is resolved to the repo path // It is different from GitHub, GitHub resolves relative links to current page's path - rendered, err := markup.RenderString(rctx, ` + rendered, err := testRenderString(rctx, ` [/test](/test) [./test](./test) ![/image](/image) @@ -59,7 +58,7 @@ func TestRepoComment(t *testing.T) { WithMarkupType(markdown.MarkupName) // the ref path is only used to render commit message: a commit message is rendered at the commit page with its commit ID path - rendered, err := markup.RenderString(rctx, ` + rendered, err := testRenderString(rctx, ` [/test](/test) [./test](./test) ![/image](/image) @@ -75,7 +74,7 @@ func TestRepoComment(t *testing.T) { t.Run("NoRepo", func(t *testing.T) { rctx := NewRenderContextRepoComment(t.Context(), nil).WithMarkupType(markdown.MarkupName) - rendered, err := markup.RenderString(rctx, "any") + rendered, err := testRenderString(rctx, "any") assert.NoError(t, err) assert.Equal(t, "

any

\n", rendered) }) diff --git a/models/renderhelper/repo_file_test.go b/models/renderhelper/repo_file_test.go index 72d98efc66..cb2852b3e3 100644 --- a/models/renderhelper/repo_file_test.go +++ b/models/renderhelper/repo_file_test.go @@ -8,7 +8,6 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" _ "code.gitea.io/gitea/modules/markup/orgmode" @@ -22,7 +21,7 @@ func TestRepoFile(t *testing.T) { t.Run("AutoLink", func(t *testing.T) { rctx := NewRenderContextRepoFile(t.Context(), repo1).WithMarkupType(markdown.MarkupName) - rendered, err := markup.RenderString(rctx, ` + rendered, err := testRenderString(rctx, ` 65f1bf27bc3bf70f64657658635e66094edbcb4d #1 @user2 @@ -38,7 +37,7 @@ func TestRepoFile(t *testing.T) { t.Run("AbsoluteAndRelative", func(t *testing.T) { rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}). WithMarkupType(markdown.MarkupName) - rendered, err := markup.RenderString(rctx, ` + rendered, err := testRenderString(rctx, ` [/test](/test) [./test](./test) ![/image](/image) @@ -56,7 +55,7 @@ func TestRepoFile(t *testing.T) { t.Run("WithCurrentRefSubURL", func(t *testing.T) { rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "/commit/1234"}). WithMarkupType(markdown.MarkupName) - rendered, err := markup.RenderString(rctx, ` + rendered, err := testRenderString(rctx, ` [/test](/test) ![/image](/image) `) @@ -72,7 +71,7 @@ func TestRepoFile(t *testing.T) { CurrentTreePath: "my-dir", }). WithMarkupType(markdown.MarkupName) - rendered, err := markup.RenderString(rctx, ` + rendered, err := testRenderString(rctx, `