mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-24 13:25:01 +09:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afdbd9b7c5 | |||
| 64d12024d6 | |||
| 6cc1ee9424 | |||
| 5d7768f34c | |||
| 55a6cfe79b | |||
| 1f643072c1 | |||
| 0280455356 | |||
| a8e465e893 | |||
| fc9dfe0e56 | |||
| 0916039c2a | |||
| 291f6cbd3a | |||
| f536bcd508 | |||
| fc4296a21a | |||
| 657ea10cf1 | |||
| ef096b0f90 | |||
| 7bd55deab3 | |||
| e4b7120bc2 | |||
| f0fd185f14 | |||
| adfa535dc2 | |||
| e6691b0e8d | |||
| 82613a40a0 | |||
| ba5117e4e4 |
@@ -4,6 +4,28 @@ This changelog goes through the changes that have been made in each release
|
|||||||
without substantial changes to our git log; to see the highlights of what has
|
without substantial changes to our git log; to see the highlights of what has
|
||||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||||
|
|
||||||
|
## [1.26.1](https://github.com/go-gitea/gitea/releases/tag/v1.26.1) - 2026-04-21
|
||||||
|
|
||||||
|
* BUGFIXES
|
||||||
|
* Add event.schedule context for schedule actions task (#37320) (#37348)
|
||||||
|
* Fix an issue where changing an organization's visibility caused problems when users had forked its repositories. (#37324) (#37344)
|
||||||
|
* Use modern "git update-index --cacheinfo" syntax to support more file names (#37338) (#37343)
|
||||||
|
* Fix URL related escaping for oauth2 (#37334) (#37340)
|
||||||
|
* When the requested arch rpm is missing fall back to noarch (#37236) (#37339)
|
||||||
|
* Fix actions concurrency groups cross-branch leak (#37311) (#37331)
|
||||||
|
* Fix bug when accessing user badges (#37321) (#37329)
|
||||||
|
* Fix AppFullLink (#37325) (#37328)
|
||||||
|
* Fix container auth for public instance (#37290) (#37294)
|
||||||
|
* Enhance GetActionWorkflow to support fallback references (#37189) (#37283)
|
||||||
|
* Fix vite manifest update masking build errors (#37279) (#37310)
|
||||||
|
* Fix Mermaid diagrams failing when node labels contain line breaks (#37296) (#37299)
|
||||||
|
* Use TriggerEvent instead of Event in workflow runs API response for scheduled runs (#37288) #37360
|
||||||
|
* Add URL to Learn more about blocking a user. (#37355) #37367
|
||||||
|
* Fix button layout shift when collapsing file tree in editor (#37363) #37375
|
||||||
|
* Fix org team assignee/reviewer lookups for team member permissions (#37365) #37391
|
||||||
|
* Fix repo init README EOL (#37388) #37399
|
||||||
|
* Fix: dump with default zip type produces uncompressed zip (#37401)#37402
|
||||||
|
|
||||||
## [1.26.0](https://github.com/go-gitea/gitea/releases/tag/v1.26.0) - 2026-04-17
|
## [1.26.0](https://github.com/go-gitea/gitea/releases/tag/v1.26.0) - 2026-04-17
|
||||||
|
|
||||||
* BREAKING
|
* BREAKING
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ func GetActiveOAuth2SourceByAuthName(ctx context.Context, name string) (*Source,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !has {
|
if !has {
|
||||||
return nil, fmt.Errorf("oauth2 source not found, name: %q", name)
|
return nil, util.NewNotExistErrorf("oauth2 source not found, name: %q", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return authSource, nil
|
return authSource, nil
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
ref: "refs/heads/test"
|
ref: "refs/heads/test"
|
||||||
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
|
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
|
||||||
event: "push"
|
event: "push"
|
||||||
trigger_event: "push"
|
trigger_event: "schedule"
|
||||||
is_fork_pull_request: 0
|
is_fork_pull_request: 0
|
||||||
status: 1
|
status: 1
|
||||||
started: 1683636528
|
started: 1683636528
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/models/perm"
|
|
||||||
"code.gitea.io/gitea/models/unit"
|
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/container"
|
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
@@ -129,49 +126,6 @@ func IsUserOrgOwner(ctx context.Context, users user_model.UserList, orgID int64)
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrgAssignees returns all users that have write access and can be assigned to issues
|
|
||||||
// of the any repository in the organization.
|
|
||||||
func GetOrgAssignees(ctx context.Context, orgID int64) (_ []*user_model.User, err error) {
|
|
||||||
e := db.GetEngine(ctx)
|
|
||||||
userIDs := make([]int64, 0, 10)
|
|
||||||
if err = e.Table("access").
|
|
||||||
Join("INNER", "repository", "`repository`.id = `access`.repo_id").
|
|
||||||
Where("`repository`.owner_id = ? AND `access`.mode >= ?", orgID, perm.AccessModeWrite).
|
|
||||||
Select("user_id").
|
|
||||||
Find(&userIDs); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
additionalUserIDs := make([]int64, 0, 10)
|
|
||||||
if err = e.Table("team_user").
|
|
||||||
Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id").
|
|
||||||
Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id").
|
|
||||||
Join("INNER", "repository", "`repository`.id = `team_repo`.repo_id").
|
|
||||||
Where("`repository`.owner_id = ? AND (`team_unit`.access_mode >= ? OR (`team_unit`.access_mode = ? AND `team_unit`.`type` = ?))",
|
|
||||||
orgID, perm.AccessModeWrite, perm.AccessModeRead, unit.TypePullRequests).
|
|
||||||
Distinct("`team_user`.uid").
|
|
||||||
Select("`team_user`.uid").
|
|
||||||
Find(&additionalUserIDs); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
uniqueUserIDs := make(container.Set[int64])
|
|
||||||
uniqueUserIDs.AddMultiple(userIDs...)
|
|
||||||
uniqueUserIDs.AddMultiple(additionalUserIDs...)
|
|
||||||
|
|
||||||
users := make([]*user_model.User, 0, len(uniqueUserIDs))
|
|
||||||
if len(userIDs) > 0 {
|
|
||||||
if err = e.In("id", uniqueUserIDs.Values()).
|
|
||||||
Where(builder.Eq{"`user`.is_active": true}).
|
|
||||||
OrderBy(user_model.GetOrderByName()).
|
|
||||||
Find(&users); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return users, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOrganizationOwners(ctx context.Context, users user_model.UserList, orgID int64) (map[int64]*TeamUser, error) {
|
func loadOrganizationOwners(ctx context.Context, users user_model.UserList, orgID int64) (map[int64]*TeamUser, error) {
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
return nil, nil //nolint:nilnil // return nil when there are no users
|
return nil, nil //nolint:nilnil // return nil when there are no users
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"code.gitea.io/gitea/models/organization"
|
||||||
"code.gitea.io/gitea/models/perm"
|
"code.gitea.io/gitea/models/perm"
|
||||||
"code.gitea.io/gitea/models/unit"
|
"code.gitea.io/gitea/models/unit"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
@@ -94,8 +95,7 @@ func GetWatchedRepos(ctx context.Context, opts *WatchedReposOptions) ([]*Reposit
|
|||||||
return db.FindAndCount[Repository](ctx, opts)
|
return db.FindAndCount[Repository](ctx, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRepoAssignees returns all users that have write access and can be assigned to issues
|
// GetRepoAssignees returns all users that have write access and can be assigned to issues or pull-requests of the repository,
|
||||||
// of the repository,
|
|
||||||
func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.User, err error) {
|
func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.User, err error) {
|
||||||
if err = repo.LoadOwner(ctx); err != nil {
|
if err = repo.LoadOwner(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -114,15 +114,9 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us
|
|||||||
uniqueUserIDs.AddMultiple(userIDs...)
|
uniqueUserIDs.AddMultiple(userIDs...)
|
||||||
|
|
||||||
if repo.Owner.IsOrganization() {
|
if repo.Owner.IsOrganization() {
|
||||||
additionalUserIDs := make([]int64, 0, 10)
|
// issues and pull requests both need "assignee list"
|
||||||
if err = e.Table("team_user").
|
additionalUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypeIssues, unit.TypePullRequests)
|
||||||
Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id").
|
if err != nil {
|
||||||
Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id").
|
|
||||||
Where("`team_repo`.repo_id = ? AND (`team_unit`.access_mode >= ? OR (`team_unit`.access_mode = ? AND `team_unit`.`type` = ?))",
|
|
||||||
repo.ID, perm.AccessModeWrite, perm.AccessModeRead, unit.TypePullRequests).
|
|
||||||
Distinct("`team_user`.uid").
|
|
||||||
Select("`team_user`.uid").
|
|
||||||
Find(&additionalUserIDs); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
uniqueUserIDs.AddMultiple(additionalUserIDs...)
|
uniqueUserIDs.AddMultiple(additionalUserIDs...)
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ package repo_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"code.gitea.io/gitea/models/organization"
|
||||||
|
perm_model "code.gitea.io/gitea/models/perm"
|
||||||
|
access_model "code.gitea.io/gitea/models/perm/access"
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unit"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
|
||||||
@@ -14,9 +19,14 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRepoAssignees(t *testing.T) {
|
func TestUserRepo(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
t.Run("GetIssuePostersWithSearch", testUserRepoGetIssuePostersWithSearch)
|
||||||
|
t.Run("Assignees", testUserRepoAssignees)
|
||||||
|
t.Run("AssigneesNoTeamUnit", testRepoAssigneesNoTeamUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUserRepoAssignees(t *testing.T) {
|
||||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
users, err := repo_model.GetRepoAssignees(t.Context(), repo2)
|
users, err := repo_model.GetRepoAssignees(t.Context(), repo2)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -39,9 +49,29 @@ func TestRepoAssignees(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetIssuePostersWithSearch(t *testing.T) {
|
func testRepoAssigneesNoTeamUnit(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
ctx := t.Context()
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32})
|
||||||
|
require.NoError(t, repo.LoadOwner(ctx))
|
||||||
|
require.True(t, repo.Owner.IsOrganization())
|
||||||
|
|
||||||
|
require.NoError(t, db.TruncateBeans(ctx, &organization.Team{}, &organization.TeamUser{}, &organization.TeamRepo{}, &organization.TeamUnit{}, &access_model.Access{}))
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||||
|
team := &organization.Team{OrgID: repo.OwnerID, LowerName: "admin-team", AccessMode: perm_model.AccessModeAdmin}
|
||||||
|
require.NoError(t, db.Insert(ctx, team))
|
||||||
|
require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: repo.OwnerID, TeamID: team.ID, UID: user.ID}))
|
||||||
|
require.NoError(t, db.Insert(ctx, &organization.TeamRepo{OrgID: repo.OwnerID, TeamID: team.ID, RepoID: repo.ID}))
|
||||||
|
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: repo.OwnerID, TeamID: team.ID, Type: unit.TypePullRequests, AccessMode: perm_model.AccessModeNone}))
|
||||||
|
|
||||||
|
users, err := repo_model.GetRepoAssignees(ctx, repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, users, 1)
|
||||||
|
assert.ElementsMatch(t, []int64{4}, []int64{users[0].ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUserRepoGetIssuePostersWithSearch(t *testing.T) {
|
||||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
|
|
||||||
users, err := repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "USER")
|
users, err := repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "USER")
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ type GetBadgeUsersOptions struct {
|
|||||||
func GetBadgeUsers(ctx context.Context, opts *GetBadgeUsersOptions) ([]*User, int64, error) {
|
func GetBadgeUsers(ctx context.Context, opts *GetBadgeUsersOptions) ([]*User, int64, error) {
|
||||||
sess := db.GetEngine(ctx).
|
sess := db.GetEngine(ctx).
|
||||||
Select("`user`.*").
|
Select("`user`.*").
|
||||||
Join("INNER", "user_badge", "`user_badge`.user_id=user.id").
|
Join("INNER", "user_badge", "`user_badge`.user_id=`user`.id").
|
||||||
Join("INNER", "badge", "`user_badge`.badge_id=badge.id").
|
Join("INNER", "badge", "`user_badge`.badge_id=badge.id").
|
||||||
Where("badge.slug=?", opts.BadgeSlug)
|
Where("badge.slug=?", opts.BadgeSlug)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package dump
|
package dump
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -85,7 +86,7 @@ func NewDumper(ctx context.Context, format string, output io.Writer) (*Dumper, e
|
|||||||
var comp archives.ArchiverAsync
|
var comp archives.ArchiverAsync
|
||||||
switch format {
|
switch format {
|
||||||
case "zip":
|
case "zip":
|
||||||
comp = archives.Zip{}
|
comp = archives.Zip{Compression: zip.Deflate}
|
||||||
case "tar":
|
case "tar":
|
||||||
comp = archives.Tar{}
|
comp = archives.Tar{}
|
||||||
case "tar.sz":
|
case "tar.sz":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package templates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -169,9 +170,21 @@ func TestQueryBuild(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryNonASCII = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" // all non-letter & non-number chars
|
||||||
|
|
||||||
func TestQueryEscape(t *testing.T) {
|
func TestQueryEscape(t *testing.T) {
|
||||||
// this test is a reference for "urlQueryEscape" in JS
|
// this test is a reference for "urlQueryEscape" in JS
|
||||||
in := "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" // all non-letter & non-number chars
|
// Special case for space encoding:
|
||||||
expected := "%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
|
// * RFC 3986: Uniform Resource Identifier (URI): %20
|
||||||
assert.Equal(t, expected, string(queryEscape(in)))
|
// * WHATWG HTML: application/x-www-form-urlencoded: +
|
||||||
|
// * JavaScript: encodeURIComponent() uses "%20". URLSearchParams uses "+"
|
||||||
|
// * Golang: QueryEscape uses "+"
|
||||||
|
expected := "+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
|
||||||
|
assert.Equal(t, expected, url.QueryEscape(queryNonASCII))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathEscape(t *testing.T) {
|
||||||
|
// this test is a reference for "pathEscape" in JS
|
||||||
|
expected := "%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~"
|
||||||
|
assert.Equal(t, expected, url.PathEscape(queryNonASCII))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,11 +255,13 @@ func EnumValue[T comparable](val EnumConst[T]) (ret T, valid bool) {
|
|||||||
return enums[0], false
|
return enums[0], false
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReserveLineBreakForTextarea(input string) string {
|
func NormalizeStringEOL(input string) string {
|
||||||
// Since the content is from a form which is a textarea, the line endings are \r\n.
|
// Since the content is from a form which is a textarea, the line endings are \r\n.
|
||||||
// It's a standard behavior of HTML.
|
// It's a standard behavior of HTML.
|
||||||
// But we want to store them as \n like what GitHub does.
|
// But in most cases, we only want "\n" for EOL
|
||||||
// And users are unlikely to really need to keep the \r.
|
// * Text files: use "\n" by default because "\r\n" sometimes doesn't work in POSIX
|
||||||
|
// * Actions values: store them as "\n" like what GitHub does.
|
||||||
|
// And users are unlikely to really need the "\r".
|
||||||
// Other than this, we should respect the original content, even leading or trailing spaces.
|
// Other than this, we should respect the original content, even leading or trailing spaces.
|
||||||
return strings.ReplaceAll(input, "\r\n", "\n")
|
return UnsafeBytesToString(NormalizeEOL(UnsafeStringToBytes(input)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,9 +175,9 @@ func TestToTitleCase(t *testing.T) {
|
|||||||
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`FOO BAR BAZ`))
|
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`FOO BAR BAZ`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReserveLineBreakForTextarea(t *testing.T) {
|
func TestNormalizeStringEOL(t *testing.T) {
|
||||||
assert.Equal(t, "test\ndata", ReserveLineBreakForTextarea("test\r\ndata"))
|
assert.Equal(t, "test\ndata", NormalizeStringEOL("test\r\ndata"))
|
||||||
assert.Equal(t, "test\ndata\n", ReserveLineBreakForTextarea("test\r\ndata\r\n"))
|
assert.Equal(t, " test\ndata\n ", NormalizeStringEOL(" test\rdata\r "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOptionalArg(t *testing.T) {
|
func TestOptionalArg(t *testing.T) {
|
||||||
@@ -192,3 +192,10 @@ func TestOptionalArg(t *testing.T) {
|
|||||||
assert.Equal(t, 42, bar(nil))
|
assert.Equal(t, 42, bar(nil))
|
||||||
assert.Equal(t, 100, bar(nil, 100))
|
assert.Equal(t, 100, bar(nil, 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPathEscapeSegments(t *testing.T) {
|
||||||
|
assert.Equal(t, "a", PathEscapeSegments("a"))
|
||||||
|
assert.Equal(t, "a/b", PathEscapeSegments("a/b"))
|
||||||
|
assert.Equal(t, "a/b%20c", PathEscapeSegments("a/b c"))
|
||||||
|
assert.Equal(t, "a/b+c", PathEscapeSegments("a/b+c"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -637,14 +637,8 @@
|
|||||||
"user.block.unblock.failure": "Failed to unblock user: %s",
|
"user.block.unblock.failure": "Failed to unblock user: %s",
|
||||||
"user.block.blocked": "You have blocked this user.",
|
"user.block.blocked": "You have blocked this user.",
|
||||||
"user.block.title": "Block a user",
|
"user.block.title": "Block a user",
|
||||||
"user.block.info": "Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.",
|
"user.block.info": "Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues.",
|
||||||
"user.block.info_1": "Blocking a user prevents the following actions on your account and your repositories:",
|
"user.block.info.docs": "Learn more about blocking a user.",
|
||||||
"user.block.info_2": "following your account",
|
|
||||||
"user.block.info_3": "send you notifications by @mentioning your username",
|
|
||||||
"user.block.info_4": "inviting you as a collaborator to their repositories",
|
|
||||||
"user.block.info_5": "starring, forking or watching on repositories",
|
|
||||||
"user.block.info_6": "opening and commenting on issues or pull requests",
|
|
||||||
"user.block.info_7": "reacting to your comments in issues or pull requests",
|
|
||||||
"user.block.user_to_block": "User to block",
|
"user.block.user_to_block": "User to block",
|
||||||
"user.block.note": "Note",
|
"user.block.note": "Note",
|
||||||
"user.block.note.title": "Optional note:",
|
"user.block.note.title": "Optional note:",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
container_module "code.gitea.io/gitea/modules/packages/container"
|
container_module "code.gitea.io/gitea/modules/packages/container"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/storage"
|
"code.gitea.io/gitea/modules/storage"
|
||||||
|
"code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||||
auth_service "code.gitea.io/gitea/services/auth"
|
auth_service "code.gitea.io/gitea/services/auth"
|
||||||
@@ -125,8 +126,15 @@ func APIUnauthorizedError(ctx *context.Context) {
|
|||||||
// container registry requires that the "/v2" must be in the root, so the sub-path in AppURL should be removed
|
// container registry requires that the "/v2" must be in the root, so the sub-path in AppURL should be removed
|
||||||
realmURL := httplib.GuessCurrentHostURL(ctx) + "/v2/token"
|
realmURL := httplib.GuessCurrentHostURL(ctx) + "/v2/token"
|
||||||
ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+realmURL+`",service="container_registry",scope="*"`)
|
ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+realmURL+`",service="container_registry",scope="*"`)
|
||||||
// support apple container like: container registry login <gitea-host> -u
|
|
||||||
ctx.Resp.Header().Add("WWW-Authenticate", `Basic realm="Gitea Container Registry"`)
|
ownerName := ctx.PathParam("username")
|
||||||
|
owner, _ := user_model.GetUserByName(ctx, ownerName)
|
||||||
|
requireSignIn := owner != nil && owner.Visibility != structs.VisibleTypePublic
|
||||||
|
requireSignIn = requireSignIn || setting.Service.RequireSignInViewStrict
|
||||||
|
if requireSignIn {
|
||||||
|
// support apple container like: container registry login <gitea-host> -u
|
||||||
|
ctx.Resp.Header().Add("WWW-Authenticate", `Basic realm="Gitea Container Registry"`)
|
||||||
|
}
|
||||||
apiErrorDefined(ctx, errUnauthorized)
|
apiErrorDefined(ctx, errUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -220,30 +221,38 @@ func UploadPackageFile(ctx *context.Context) {
|
|||||||
func DownloadPackageFile(ctx *context.Context) {
|
func DownloadPackageFile(ctx *context.Context) {
|
||||||
name := ctx.PathParam("name")
|
name := ctx.PathParam("name")
|
||||||
version := ctx.PathParam("version")
|
version := ctx.PathParam("version")
|
||||||
|
architecture := ctx.PathParam("architecture")
|
||||||
|
group := ctx.PathParam("group")
|
||||||
|
|
||||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
openForDownload := func(filename string) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) {
|
||||||
ctx,
|
return packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||||
&packages_service.PackageInfo{
|
ctx,
|
||||||
Owner: ctx.Package.Owner,
|
&packages_service.PackageInfo{
|
||||||
PackageType: packages_model.TypeRpm,
|
Owner: ctx.Package.Owner,
|
||||||
Name: name,
|
PackageType: packages_model.TypeRpm,
|
||||||
Version: version,
|
Name: name,
|
||||||
},
|
Version: version,
|
||||||
&packages_service.PackageFileInfo{
|
},
|
||||||
Filename: fmt.Sprintf("%s-%s.%s.rpm", name, version, ctx.PathParam("architecture")),
|
&packages_service.PackageFileInfo{
|
||||||
CompositeKey: ctx.PathParam("group"),
|
Filename: filename,
|
||||||
},
|
CompositeKey: group,
|
||||||
ctx.Req.Method,
|
},
|
||||||
)
|
ctx.Req.Method,
|
||||||
if err != nil {
|
)
|
||||||
if errors.Is(err, util.ErrNotExist) {
|
|
||||||
apiError(ctx, http.StatusNotFound, err)
|
|
||||||
} else {
|
|
||||||
apiError(ctx, http.StatusInternalServerError, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s, u, pf, err := openForDownload(fmt.Sprintf("%s-%s.%s.rpm", name, version, architecture))
|
||||||
|
if errors.Is(err, util.ErrNotExist) && architecture != "noarch" {
|
||||||
|
s, u, pf, err = openForDownload(fmt.Sprintf("%s-%s.%s.rpm", name, version, "noarch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
|
apiError(ctx, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
helper.ServePackageFile(ctx, s, u, pf)
|
helper.ServePackageFile(ctx, s, u, pf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ func performAutoLoginOAuth2(ctx *context.Context, data *preparedSignInData) bool
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
skipToOAuthURL := setting.AppSubURL + "/user/oauth2/" + url.QueryEscape(data.oauth2Providers[0].DisplayName())
|
skipToOAuthURL := setting.AppSubURL + "/user/oauth2/" + url.PathEscape(data.oauth2Providers[0].DisplayName())
|
||||||
if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" {
|
if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" {
|
||||||
skipToOAuthURL += "?redirect_to=" + url.QueryEscape(redirectTo)
|
skipToOAuthURL += "?redirect_to=" + url.QueryEscape(redirectTo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -67,15 +68,15 @@ func TestWebAuthOAuth2(t *testing.T) {
|
|||||||
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
||||||
|
|
||||||
_ = oauth2.Init(t.Context())
|
_ = oauth2.Init(t.Context())
|
||||||
addOAuth2Source(t, "dummy-auth-source", oauth2.Source{})
|
addOAuth2Source(t, "dummy+auth's source", oauth2.Source{})
|
||||||
|
|
||||||
t.Run("OAuth2MissingField", func(t *testing.T) {
|
t.Run("OAuth2MissingField", func(t *testing.T) {
|
||||||
defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
|
defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
|
||||||
return goth.User{Provider: "dummy-auth-source", UserID: "dummy-user"}, nil
|
return goth.User{Provider: "dummy+auth's source", UserID: "dummy-user"}, nil
|
||||||
})()
|
})()
|
||||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
|
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
|
||||||
ctx, resp := contexttest.MockContext(t, "/user/oauth2/dummy-auth-source/callback?code=dummy-code", mockOpt)
|
ctx, resp := contexttest.MockContext(t, "/user/oauth2/..../callback?code=dummy-code", mockOpt)
|
||||||
ctx.SetPathParam("provider", "dummy-auth-source")
|
ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source")
|
||||||
SignInOAuthCallback(ctx)
|
SignInOAuthCallback(ctx)
|
||||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||||
assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
|
assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
|
||||||
@@ -83,13 +84,13 @@ func TestWebAuthOAuth2(t *testing.T) {
|
|||||||
// then the user will be redirected to the link account page, and see a message about the missing fields
|
// then the user will be redirected to the link account page, and see a message about the missing fields
|
||||||
ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt)
|
ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt)
|
||||||
LinkAccount(ctx)
|
LinkAccount(ctx)
|
||||||
assert.EqualValues(t, "auth.oauth_callback_unable_auto_reg:dummy-auth-source,email", ctx.Data["AutoRegistrationFailedPrompt"])
|
assert.Equal(t, template.HTML("auth.oauth_callback_unable_auto_reg:dummy+auth's source,email"), ctx.Data["AutoRegistrationFailedPrompt"])
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("OAuth2CallbackError", func(t *testing.T) {
|
t.Run("OAuth2CallbackError", func(t *testing.T) {
|
||||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
|
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
|
||||||
ctx, resp := contexttest.MockContext(t, "/user/oauth2/dummy-auth-source/callback", mockOpt)
|
ctx, resp := contexttest.MockContext(t, "/user/oauth2/...../callback", mockOpt)
|
||||||
ctx.SetPathParam("provider", "dummy-auth-source")
|
ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source")
|
||||||
SignInOAuthCallback(ctx)
|
SignInOAuthCallback(ctx)
|
||||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||||
assert.Equal(t, "/user/login", test.RedirectURL(resp))
|
assert.Equal(t, "/user/login", test.RedirectURL(resp))
|
||||||
@@ -112,8 +113,8 @@ func TestWebAuthOAuth2(t *testing.T) {
|
|||||||
assert.Equal(t, expectedRedirect, test.RedirectURL(resp))
|
assert.Equal(t, expectedRedirect, test.RedirectURL(resp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
testSignIn(t, "/user/login", http.StatusSeeOther, "/user/oauth2/dummy-auth-source")
|
testSignIn(t, "/user/login", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source")
|
||||||
testSignIn(t, "/user/login?redirect_to=/", http.StatusSeeOther, "/user/oauth2/dummy-auth-source?redirect_to=%2F")
|
testSignIn(t, "/user/login?redirect_to=/", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source?redirect_to=%2F")
|
||||||
|
|
||||||
*enablePassword, *enableOpenID, *enablePasskey = true, false, false
|
*enablePassword, *enableOpenID, *enablePasskey = true, false, false
|
||||||
testSignIn(t, "/user/login", http.StatusOK, "")
|
testSignIn(t, "/user/login", http.StatusOK, "")
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
issues_model "code.gitea.io/gitea/models/issues"
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
org_model "code.gitea.io/gitea/models/organization"
|
|
||||||
project_model "code.gitea.io/gitea/models/project"
|
project_model "code.gitea.io/gitea/models/project"
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unit"
|
"code.gitea.io/gitea/models/unit"
|
||||||
@@ -459,9 +458,9 @@ func ViewProject(ctx *context.Context) {
|
|||||||
ctx.Data["MilestoneID"] = milestoneID
|
ctx.Data["MilestoneID"] = milestoneID
|
||||||
|
|
||||||
// Get assignees.
|
// Get assignees.
|
||||||
assigneeUsers, err := org_model.GetOrgAssignees(ctx, project.OwnerID)
|
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetRepoAssignees", err)
|
ctx.ServerError("LoadIssuesAssigneesForProject", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) {
|
|||||||
func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
|
func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
|
||||||
form := web.GetForm(ctx).(*forms.AddSecretForm)
|
form := web.GetForm(ctx).(*forms.AddSecretForm)
|
||||||
|
|
||||||
s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data), form.Description)
|
s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.NormalizeStringEOL(form.Data), form.Description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("CreateOrUpdateSecret failed: %v", err)
|
log.Error("CreateOrUpdateSecret failed: %v", err)
|
||||||
ctx.JSONError(ctx.Tr("secrets.save_failed"))
|
ctx.JSONError(ctx.Tr("secrets.save_failed"))
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
@@ -143,57 +142,59 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
|
|||||||
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
|
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
|
||||||
runName = wfs[0].Name
|
runName = wfs[0].Name
|
||||||
}
|
}
|
||||||
ctxName := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)
|
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
|
||||||
ctxName = strings.TrimSpace(ctxName) // git_model.NewCommitStatus also trims spaces
|
|
||||||
state := toCommitStatus(job.Status)
|
state := toCommitStatus(job.Status)
|
||||||
if statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll); err == nil {
|
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
||||||
for _, v := range statuses {
|
description := toCommitStatusDescription(job)
|
||||||
if v.Context == ctxName {
|
|
||||||
if v.State == state {
|
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
|
||||||
// no need to update
|
if err != nil {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
||||||
}
|
}
|
||||||
|
for _, v := range statuses {
|
||||||
var description string
|
if v.Context == ctxName {
|
||||||
switch job.Status {
|
if v.State == state && v.TargetURL == targetURL && v.Description == description {
|
||||||
// TODO: if we want support description in different languages, we need to support i18n placeholders in it
|
return nil
|
||||||
case actions_model.StatusSuccess:
|
}
|
||||||
description = fmt.Sprintf("Successful in %s", job.Duration())
|
break
|
||||||
case actions_model.StatusFailure:
|
}
|
||||||
description = fmt.Sprintf("Failing after %s", job.Duration())
|
|
||||||
case actions_model.StatusCancelled:
|
|
||||||
description = "Has been cancelled"
|
|
||||||
case actions_model.StatusSkipped:
|
|
||||||
description = "Has been skipped"
|
|
||||||
case actions_model.StatusRunning:
|
|
||||||
description = "Has started running"
|
|
||||||
case actions_model.StatusWaiting:
|
|
||||||
description = "Waiting to run"
|
|
||||||
case actions_model.StatusBlocked:
|
|
||||||
description = "Blocked by required conditions"
|
|
||||||
default:
|
|
||||||
description = "Unknown status: " + strconv.Itoa(int(job.Status))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
creator := user_model.NewActionsUser()
|
creator := user_model.NewActionsUser()
|
||||||
status := git_model.CommitStatus{
|
status := git_model.CommitStatus{
|
||||||
SHA: commitID,
|
SHA: commitID,
|
||||||
TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID),
|
TargetURL: targetURL,
|
||||||
Description: description,
|
Description: description,
|
||||||
Context: ctxName,
|
Context: ctxName,
|
||||||
CreatorID: creator.ID,
|
|
||||||
State: state,
|
State: state,
|
||||||
|
CreatorID: creator.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &status)
|
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toCommitStatusDescription(job *actions_model.ActionRunJob) string {
|
||||||
|
switch job.Status {
|
||||||
|
// TODO: if we want support description in different languages, we need to support i18n placeholders in it
|
||||||
|
case actions_model.StatusSuccess:
|
||||||
|
return fmt.Sprintf("Successful in %s", job.Duration())
|
||||||
|
case actions_model.StatusFailure:
|
||||||
|
return fmt.Sprintf("Failing after %s", job.Duration())
|
||||||
|
case actions_model.StatusCancelled:
|
||||||
|
return "Has been cancelled"
|
||||||
|
case actions_model.StatusSkipped:
|
||||||
|
return "Has been skipped"
|
||||||
|
case actions_model.StatusRunning:
|
||||||
|
return "Has started running"
|
||||||
|
case actions_model.StatusWaiting:
|
||||||
|
return "Waiting to run"
|
||||||
|
case actions_model.StatusBlocked:
|
||||||
|
return "Blocked by required conditions"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Unknown status: %d", job.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState {
|
func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState {
|
||||||
switch status {
|
switch status {
|
||||||
case actions_model.StatusSuccess:
|
case actions_model.StatusSuccess:
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
git_model "code.gitea.io/gitea/models/git"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
"code.gitea.io/gitea/modules/commitstatus"
|
||||||
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateCommitStatus_Dedupe(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
run := &actions_model.ActionRun{
|
||||||
|
ID: 99001,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Repo: repo,
|
||||||
|
WorkflowID: "status-dedupe-test.yaml",
|
||||||
|
}
|
||||||
|
job := &actions_model.ActionRunJob{
|
||||||
|
ID: 99002,
|
||||||
|
RunID: run.ID,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Name: "status-dedupe-job",
|
||||||
|
Status: actions_model.StatusWaiting,
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)"
|
||||||
|
expectedTargetURL := run.Link() + "/jobs/99002"
|
||||||
|
|
||||||
|
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
|
||||||
|
|
||||||
|
statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
|
||||||
|
require.Len(t, statuses, 1)
|
||||||
|
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
||||||
|
assert.Equal(t, "Waiting to run", statuses[0].Description)
|
||||||
|
assert.Equal(t, expectedTargetURL, statuses[0].TargetURL)
|
||||||
|
|
||||||
|
job.Status = actions_model.StatusRunning
|
||||||
|
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
|
||||||
|
|
||||||
|
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
|
||||||
|
require.Len(t, statuses, 2)
|
||||||
|
assert.Equal(t, "Waiting to run", statuses[0].Description)
|
||||||
|
assert.Equal(t, commitstatus.CommitStatusPending, statuses[1].State)
|
||||||
|
assert.Equal(t, "Has started running", statuses[1].Description)
|
||||||
|
assert.Equal(t, expectedTargetURL, statuses[1].TargetURL)
|
||||||
|
|
||||||
|
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
|
||||||
|
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
|
||||||
|
assert.Len(t, statuses, 2)
|
||||||
|
|
||||||
|
job.Status = actions_model.StatusSuccess
|
||||||
|
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
|
||||||
|
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
|
||||||
|
require.Len(t, statuses, 3)
|
||||||
|
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var statuses []*git_model.CommitStatus
|
||||||
|
err := db.GetEngine(t.Context()).
|
||||||
|
Where("repo_id = ? AND sha = ? AND context = ?", repoID, sha, context).
|
||||||
|
Asc("`index`").
|
||||||
|
Find(&statuses)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio
|
|||||||
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
|
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
|
||||||
"repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git.
|
"repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git.
|
||||||
"retention_days": "", // string, The number of days that workflow run logs and artifacts are kept.
|
"retention_days": "", // string, The number of days that workflow run logs and artifacts are kept.
|
||||||
"run_id": "", // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
|
"run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
|
||||||
"run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
|
"run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
|
||||||
"run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
|
"run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
|
||||||
"secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces.
|
"secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces.
|
||||||
|
|||||||
@@ -4,14 +4,86 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
|
||||||
|
act_model "github.com/nektos/act/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) {
|
||||||
|
// Unit-level check that EvaluateRunConcurrencyFillModel resolves
|
||||||
|
// github.run_id from run.ID. The full-flow regression — that run.ID is
|
||||||
|
// non-zero by the time evaluation happens — is in
|
||||||
|
// TestPrepareRunAndInsert_ExpressionsSeeRunID.
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
runA := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791})
|
||||||
|
runB := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 792})
|
||||||
|
|
||||||
|
expr := &act_model.RawConcurrency{
|
||||||
|
Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}",
|
||||||
|
CancelInProgress: "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, expr, nil, nil))
|
||||||
|
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, expr, nil, nil))
|
||||||
|
|
||||||
|
assert.Contains(t, runA.ConcurrencyGroup, "791")
|
||||||
|
assert.Contains(t, runB.ConcurrencyGroup, "792")
|
||||||
|
assert.NotEqual(t, runA.ConcurrencyGroup, runB.ConcurrencyGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) {
|
||||||
|
// Regression for the cross-branch concurrency leak: github.run_id must
|
||||||
|
// be available during BOTH jobparser.Parse (run-name) and workflow-level
|
||||||
|
// concurrency evaluation. Re-ordering db.Insert relative to either step
|
||||||
|
// would leave run.ID at 0 and break this test.
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
content := []byte(`name: cross-branch
|
||||||
|
run-name: "Run ${{ github.run_id }}"
|
||||||
|
on: push
|
||||||
|
concurrency:
|
||||||
|
group: group-${{ github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
hello:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
`)
|
||||||
|
|
||||||
|
run := &actions_model.ActionRun{
|
||||||
|
Title: "before parse",
|
||||||
|
RepoID: 4,
|
||||||
|
OwnerID: 1,
|
||||||
|
WorkflowID: "expr-runid.yaml",
|
||||||
|
TriggerUserID: 1,
|
||||||
|
Ref: "refs/heads/master",
|
||||||
|
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||||
|
Event: "push",
|
||||||
|
TriggerEvent: "push",
|
||||||
|
EventPayload: "{}",
|
||||||
|
}
|
||||||
|
require.NoError(t, PrepareRunAndInsert(ctx, content, run, nil))
|
||||||
|
require.Positive(t, run.ID)
|
||||||
|
|
||||||
|
persisted := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
runIDStr := strconv.FormatInt(run.ID, 10)
|
||||||
|
assert.Equal(t, "Run "+runIDStr, persisted.Title)
|
||||||
|
assert.Equal(t, "group-"+runIDStr, persisted.ConcurrencyGroup)
|
||||||
|
// Rerun reads raw_concurrency from the DB to re-evaluate the group;
|
||||||
|
// see services/actions/rerun.go. Must survive the insert.
|
||||||
|
assert.NotEmpty(t, persisted.RawConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindTaskNeeds(t *testing.T) {
|
func TestFindTaskNeeds(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package actions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
issues_model "code.gitea.io/gitea/models/issues"
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/repository"
|
"code.gitea.io/gitea/modules/repository"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||||
"code.gitea.io/gitea/services/convert"
|
"code.gitea.io/gitea/services/convert"
|
||||||
notify_service "code.gitea.io/gitea/services/notify"
|
notify_service "code.gitea.io/gitea/services/notify"
|
||||||
@@ -805,7 +807,10 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
|
|||||||
}
|
}
|
||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
|
|
||||||
convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
|
convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
|
||||||
|
if err != nil && errors.Is(err, util.ErrNotExist) {
|
||||||
|
convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetActionWorkflow: %v", err)
|
log.Error("GetActionWorkflow: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
+34
-31
@@ -13,6 +13,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
notify_service "code.gitea.io/gitea/services/notify"
|
notify_service "code.gitea.io/gitea/services/notify"
|
||||||
|
|
||||||
|
act_model "github.com/nektos/act/pkg/model"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,25 +35,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model
|
|||||||
return fmt.Errorf("ReadWorkflowRawConcurrency: %w", err)
|
return fmt.Errorf("ReadWorkflowRawConcurrency: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if wfRawConcurrency != nil {
|
if err = InsertRun(ctx, run, content, vars, inputsWithDefaults, wfRawConcurrency); err != nil {
|
||||||
err = EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputsWithDefaults)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
giteaCtx := GenerateGiteaContext(run, nil)
|
|
||||||
|
|
||||||
jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputsWithDefaults))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse workflow: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(jobs) > 0 && jobs[0].RunName != "" {
|
|
||||||
run.Title = jobs[0].RunName
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = InsertRun(ctx, run, jobs, vars, inputsWithDefaults); err != nil {
|
|
||||||
return fmt.Errorf("InsertRun: %w", err)
|
return fmt.Errorf("InsertRun: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +57,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model
|
|||||||
|
|
||||||
// InsertRun inserts a run
|
// InsertRun inserts a run
|
||||||
// The title will be cut off at 255 characters if it's longer than 255 characters.
|
// The title will be cut off at 255 characters if it's longer than 255 characters.
|
||||||
func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any) error {
|
func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error {
|
||||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
|
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,23 +65,36 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar
|
|||||||
}
|
}
|
||||||
run.Index = index
|
run.Index = index
|
||||||
run.Title = util.EllipsisDisplayString(run.Title, 255)
|
run.Title = util.EllipsisDisplayString(run.Title, 255)
|
||||||
|
run.Status = actions_model.StatusWaiting
|
||||||
|
|
||||||
// check run (workflow-level) concurrency
|
// Insert before parsing jobs or evaluating workflow-level concurrency
|
||||||
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
|
// so that run.ID is populated. Expressions referencing github.run_id —
|
||||||
if err != nil {
|
// in run-name, job names, runs-on, or a workflow-level concurrency
|
||||||
return err
|
// group like `${{ github.head_ref || github.run_id }}` — would otherwise
|
||||||
}
|
// interpolate to an empty string.
|
||||||
|
|
||||||
if err := db.Insert(ctx, run); err != nil {
|
if err := db.Insert(ctx, run); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := run.LoadRepo(ctx); err != nil {
|
giteaCtx := GenerateGiteaContext(run, nil)
|
||||||
return err
|
jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputs))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse workflow: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := actions_model.UpdateRepoRunsNumbers(ctx, run.Repo); err != nil {
|
titleChanged := len(jobs) > 0 && jobs[0].RunName != ""
|
||||||
return err
|
if titleChanged {
|
||||||
|
run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wfRawConcurrency != nil {
|
||||||
|
if err := EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputs); err != nil {
|
||||||
|
return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err)
|
||||||
|
}
|
||||||
|
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
|
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
|
||||||
@@ -168,7 +164,14 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar
|
|||||||
}
|
}
|
||||||
|
|
||||||
run.Status = actions_model.AggregateJobStatus(runJobs)
|
run.Status = actions_model.AggregateJobStatus(runJobs)
|
||||||
if err := actions_model.UpdateRun(ctx, run, "status"); err != nil {
|
cols := []string{"status"}
|
||||||
|
if titleChanged {
|
||||||
|
cols = append(cols, "title")
|
||||||
|
}
|
||||||
|
if wfRawConcurrency != nil {
|
||||||
|
cols = append(cols, "raw_concurrency", "concurrency_group", "concurrency_cancel")
|
||||||
|
}
|
||||||
|
if err := actions_model.UpdateRun(ctx, run, cols...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unit"
|
"code.gitea.io/gitea/models/unit"
|
||||||
|
"code.gitea.io/gitea/modules/json"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||||
@@ -67,7 +68,7 @@ func startTasks(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := CreateScheduleTask(ctx, row.Schedule); err != nil {
|
if err := CreateScheduleTask(ctx, row); err != nil {
|
||||||
log.Error("CreateScheduleTask: %v", err)
|
log.Error("CreateScheduleTask: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -97,9 +98,12 @@ func startTasks(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateScheduleTask creates a scheduled task from a cron action schedule.
|
// CreateScheduleTask creates a scheduled task from a cron action schedule spec.
|
||||||
// It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job.
|
// It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job.
|
||||||
func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) error {
|
func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
|
||||||
|
cron := spec.Schedule
|
||||||
|
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec)
|
||||||
|
|
||||||
// Create a new action run based on the schedule
|
// Create a new action run based on the schedule
|
||||||
run := &actions_model.ActionRun{
|
run := &actions_model.ActionRun{
|
||||||
Title: cron.Title,
|
Title: cron.Title,
|
||||||
@@ -110,7 +114,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
|
|||||||
Ref: cron.Ref,
|
Ref: cron.Ref,
|
||||||
CommitSHA: cron.CommitSHA,
|
CommitSHA: cron.CommitSHA,
|
||||||
Event: cron.Event,
|
Event: cron.Event,
|
||||||
EventPayload: cron.EventPayload,
|
EventPayload: eventPayload,
|
||||||
TriggerEvent: string(webhook_module.HookEventSchedule),
|
TriggerEvent: string(webhook_module.HookEventSchedule),
|
||||||
ScheduleID: cron.ID,
|
ScheduleID: cron.ID,
|
||||||
Status: actions_model.StatusWaiting,
|
Status: actions_model.StatusWaiting,
|
||||||
@@ -126,3 +130,24 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
|
|||||||
// Return nil if no errors occurred
|
// Return nil if no errors occurred
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withScheduleInEventPayload(eventPayload, schedule string) string {
|
||||||
|
if schedule == "" || eventPayload == "" {
|
||||||
|
return eventPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
event := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(eventPayload), &event); err != nil {
|
||||||
|
log.Error("withScheduleInEventPayload: unmarshal: %v", err)
|
||||||
|
return eventPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
event["schedule"] = schedule
|
||||||
|
updatedPayload, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("withScheduleInEventPayload: marshal: %v", err)
|
||||||
|
return eventPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(updatedPayload)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithScheduleInEventPayload(t *testing.T) {
|
||||||
|
t.Run("adds schedule to existing payload", func(t *testing.T) {
|
||||||
|
payload := `{"ref":"refs/heads/main"}`
|
||||||
|
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
|
||||||
|
|
||||||
|
event := map[string]any{}
|
||||||
|
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||||
|
assert.Equal(t, "*/5 * * * *", event["schedule"])
|
||||||
|
assert.Equal(t, "refs/heads/main", event["ref"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps empty payload", func(t *testing.T) {
|
||||||
|
updated := withScheduleInEventPayload("", "37 12 5 1 2")
|
||||||
|
assert.Empty(t, updated)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps payload when schedule empty", func(t *testing.T) {
|
||||||
|
payload := `{"ref":"refs/heads/main"}`
|
||||||
|
updated := withScheduleInEventPayload(payload, "")
|
||||||
|
assert.Equal(t, payload, updated)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps payload when malformed JSON", func(t *testing.T) {
|
||||||
|
payload := `not a json object`
|
||||||
|
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
|
||||||
|
assert.Equal(t, payload, updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data, desc
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.ReserveLineBreakForTextarea(data), description)
|
v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.NormalizeStringEOL(data), description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ func UpdateVariableNameData(ctx context.Context, variable *actions_model.ActionV
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
variable.Data = util.ReserveLineBreakForTextarea(variable.Data)
|
variable.Data = util.NormalizeStringEOL(variable.Data)
|
||||||
|
|
||||||
return actions_model.UpdateVariableCols(ctx, variable, "name", "data", "description")
|
return actions_model.UpdateVariableCols(ctx, variable, "name", "data", "description")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ func (b *Base) PathParamInt(p string) int {
|
|||||||
|
|
||||||
// SetPathParam set request path params into routes
|
// SetPathParam set request path params into routes
|
||||||
func (b *Base) SetPathParam(name, value string) {
|
func (b *Base) SetPathParam(name, value string) {
|
||||||
if strings.HasPrefix(name, ":") {
|
|
||||||
setting.PanicInDevOrTesting("path param should not start with ':'")
|
|
||||||
name = name[1:]
|
|
||||||
}
|
|
||||||
chi.RouteContext(b).URLParams.Add(name, url.PathEscape(value))
|
chi.RouteContext(b).URLParams.Add(name, url.PathEscape(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Base) SetPathParamRaw(name, value string) {
|
||||||
|
chi.RouteContext(b).URLParams.Add(name, value)
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/structs"
|
"code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/templates"
|
"code.gitea.io/gitea/modules/templates"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
"code.gitea.io/gitea/modules/web/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,11 +144,9 @@ func (ctx *Context) NotFound(logErr error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
|
func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
|
||||||
|
// TODO: it's safe to show the error message to end users if the error is fully controlled by our error system
|
||||||
if logErr != nil {
|
if logErr != nil {
|
||||||
log.Log(2, log.DEBUG, "%s: %v", logMsg, logErr)
|
log.Log(2, log.DEBUG, "%s: %v", logMsg, logErr)
|
||||||
if !setting.IsProd {
|
|
||||||
ctx.Data["ErrorMsg"] = logErr
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// response simple message if Accept isn't text/html
|
// response simple message if Accept isn't text/html
|
||||||
@@ -166,11 +165,17 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
|
|||||||
|
|
||||||
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
|
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
|
||||||
ctx.Data["Title"] = "Page Not Found"
|
ctx.Data["Title"] = "Page Not Found"
|
||||||
|
ctx.Data["ErrorMsg"] = "" // FIXME: the template never renders this message, need to fix in the future (and show safe messages to end users)
|
||||||
ctx.HTML(http.StatusNotFound, "status/404")
|
ctx.HTML(http.StatusNotFound, "status/404")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerError displays a 500 (Internal Server Error) page and prints the given error, if any.
|
// ServerError displays a 500 (Internal Server Error) page and prints the given error, if any.
|
||||||
|
// If the error is controlled by our error system, a related 404 page can be displayed instead.
|
||||||
func (ctx *Context) ServerError(logMsg string, logErr error) {
|
func (ctx *Context) ServerError(logMsg string, logErr error) {
|
||||||
|
if errors.Is(logErr, util.ErrNotExist) {
|
||||||
|
ctx.notFoundInternal(logMsg, logErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.serverErrorInternal(logMsg, logErr)
|
ctx.serverErrorInternal(logMsg, logErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,5 +81,5 @@ func (c TemplateContext) AppFullLink(link ...string) template.URL {
|
|||||||
if len(link) == 0 {
|
if len(link) == 0 {
|
||||||
return template.URL(s)
|
return template.URL(s)
|
||||||
}
|
}
|
||||||
return template.URL(s + strings.TrimPrefix(link[0], "/"))
|
return template.URL(s + "/" + strings.TrimPrefix(link[0], "/"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,3 +49,17 @@ func TestRedirectToCurrentSite(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAppFullLink(t *testing.T) {
|
||||||
|
setting.IsInTesting = true
|
||||||
|
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")()
|
||||||
|
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||||
|
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
|
||||||
|
tmplCtx := NewTemplateContext(req.Context(), req)
|
||||||
|
|
||||||
|
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
|
||||||
|
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
|
||||||
|
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("/user/repo")))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unit"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildWorkflowTestRepo creates a temporary git repository for testing GetActionWorkflow.
|
||||||
|
// The default branch "main" has no workflow files; "feature" and "release-v1" each add their own workflow file.
|
||||||
|
func buildWorkflowTestRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
_, _, err := gitcmd.NewCommand("init").WithDir(tmpDir).RunStdString(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
readme := "readme"
|
||||||
|
featureWF := "on: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo test\n"
|
||||||
|
releaseWF := "on: [push]\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - run: echo release\n"
|
||||||
|
|
||||||
|
// Build a git fast-import stream:
|
||||||
|
// :4 = initial commit on main (README.md only)
|
||||||
|
// :5 = feature branch commit (adds feature workflow)
|
||||||
|
// :6 = release commit from :4 (adds release workflow, tagged release-v1, not on main)
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "blob\nmark :1\ndata %d\n%s\n", len(readme), readme)
|
||||||
|
fmt.Fprintf(&sb, "blob\nmark :2\ndata %d\n%s\n", len(featureWF), featureWF)
|
||||||
|
fmt.Fprintf(&sb, "blob\nmark :3\ndata %d\n%s\n", len(releaseWF), releaseWF)
|
||||||
|
fmt.Fprintf(&sb, "commit refs/heads/main\nmark :4\nauthor Test <test@gitea.com> 1000000000 +0000\ncommitter Test <test@gitea.com> 1000000000 +0000\ndata 14\ninitial commit\nM 100644 :1 README.md\n\n")
|
||||||
|
fmt.Fprintf(&sb, "commit refs/heads/feature\nmark :5\nauthor Test <test@gitea.com> 1000000001 +0000\ncommitter Test <test@gitea.com> 1000000001 +0000\ndata 12\nadd workflow\nfrom :4\nM 100644 :2 .gitea/workflows/my-workflow.yml\n\n")
|
||||||
|
fmt.Fprintf(&sb, "reset refs/pull/42/merge\nfrom :5\n\n")
|
||||||
|
fmt.Fprintf(&sb, "commit refs/heads/main\nmark :6\nauthor Test <test@gitea.com> 1000000002 +0000\ncommitter Test <test@gitea.com> 1000000002 +0000\ndata 16\nrelease workflow\nfrom :4\nM 100644 :3 .gitea/workflows/my-workflow.yml\n\n")
|
||||||
|
fmt.Fprintf(&sb, "reset refs/tags/release-v1\nfrom :6\n\n")
|
||||||
|
fmt.Fprintf(&sb, "reset refs/heads/main\nfrom :4\n\n")
|
||||||
|
fmt.Fprintf(&sb, "done\n")
|
||||||
|
|
||||||
|
_, _, err = gitcmd.NewCommand("fast-import").WithDir(tmpDir).WithStdinBytes([]byte(sb.String())).RunStdString(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetActionWorkflow_FallbackRef(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
repoDir := buildWorkflowTestRepo(t)
|
||||||
|
|
||||||
|
gitRepo, err := git.OpenRepository(ctx, repoDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
repo := &repo_model.Repository{
|
||||||
|
DefaultBranch: "main",
|
||||||
|
OwnerName: "test-owner",
|
||||||
|
Name: "test-repo",
|
||||||
|
Units: []*repo_model.RepoUnit{
|
||||||
|
{
|
||||||
|
Type: unit.TypeActions,
|
||||||
|
Config: &repo_model.ActionsConfig{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("returns error when workflow only on non-default branch", func(t *testing.T) {
|
||||||
|
_, err := GetActionWorkflow(ctx, gitRepo, repo, "my-workflow.yml")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns workflow when found via ref", func(t *testing.T) {
|
||||||
|
wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/heads/feature"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "my-workflow.yml", wf.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns workflow when found via pull ref", func(t *testing.T) {
|
||||||
|
wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/pull/42/merge"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "my-workflow.yml", wf.ID)
|
||||||
|
assert.Contains(t, wf.HTMLURL, "/src/commit/")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns workflow with tag link when found via tag ref", func(t *testing.T) {
|
||||||
|
wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/tags/release-v1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "my-workflow.yml", wf.ID)
|
||||||
|
assert.Contains(t, wf.HTMLURL, "/src/tag/release-v1/")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error when workflow missing from ref", func(t *testing.T) {
|
||||||
|
_, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "nonexistent.yml", git.RefName("refs/heads/feature"))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 803})
|
||||||
|
|
||||||
|
// Scheduled runs keep Event as the registration event (push) and use TriggerEvent as the real trigger.
|
||||||
|
run.Event = "push"
|
||||||
|
run.TriggerEvent = "schedule"
|
||||||
|
|
||||||
|
apiRun, err := ToActionWorkflowRun(t.Context(), repo, run)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "schedule", apiRun.Event)
|
||||||
|
}
|
||||||
@@ -260,7 +260,7 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *
|
|||||||
RunNumber: run.Index,
|
RunNumber: run.Index,
|
||||||
StartedAt: run.Started.AsLocalTime(),
|
StartedAt: run.Started.AsLocalTime(),
|
||||||
CompletedAt: run.Stopped.AsLocalTime(),
|
CompletedAt: run.Stopped.AsLocalTime(),
|
||||||
Event: string(run.Event),
|
Event: run.TriggerEvent,
|
||||||
DisplayTitle: run.Title,
|
DisplayTitle: run.Title,
|
||||||
HeadBranch: git.RefName(run.Ref).BranchName(),
|
HeadBranch: git.RefName(run.Ref).BranchName(),
|
||||||
HeadSha: run.CommitSHA,
|
HeadSha: run.CommitSHA,
|
||||||
@@ -387,12 +387,15 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branchName, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
|
func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, refName git.RefName, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
|
||||||
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
|
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
|
||||||
cfg := cfgUnit.ActionsConfig()
|
cfg := cfgUnit.ActionsConfig()
|
||||||
|
|
||||||
workflowURL := fmt.Sprintf("%s/actions/workflows/%s", repo.APIURL(), util.PathEscapeSegments(entry.Name()))
|
workflowURL := fmt.Sprintf("%s/actions/workflows/%s", repo.APIURL(), util.PathEscapeSegments(entry.Name()))
|
||||||
workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(branchName), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name()))
|
workflowRepoURL := fmt.Sprintf("%s/src/commit/%s/%s/%s", repo.HTMLURL(ctx), commit.ID.String(), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name()))
|
||||||
|
if refWebLinkPath := refName.RefWebLinkPath(); refWebLinkPath != "" {
|
||||||
|
workflowRepoURL = fmt.Sprintf("%s/src/%s/%s/%s", repo.HTMLURL(ctx), refWebLinkPath, util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name()))
|
||||||
|
}
|
||||||
badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repo.HTMLURL(ctx), util.PathEscapeSegments(entry.Name()), url.QueryEscape(repo.DefaultBranch))
|
badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repo.HTMLURL(ctx), util.PathEscapeSegments(entry.Name()), url.QueryEscape(repo.DefaultBranch))
|
||||||
|
|
||||||
// See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
|
// See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
|
||||||
@@ -457,7 +460,7 @@ func ListActionWorkflows(ctx context.Context, gitrepo *git.Repository, repo *rep
|
|||||||
|
|
||||||
workflows := make([]*api.ActionWorkflow, len(entries))
|
workflows := make([]*api.ActionWorkflow, len(entries))
|
||||||
for i, entry := range entries {
|
for i, entry := range entries {
|
||||||
workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, repo.DefaultBranch, folder, entry)
|
workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, git.RefNameFromBranch(repo.DefaultBranch), folder, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
return workflows, nil
|
return workflows, nil
|
||||||
@@ -469,14 +472,35 @@ func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
folder, entries, err := actions.ListWorkflows(defaultBranchCommit)
|
return getActionWorkflowFromCommit(ctx, repo, defaultBranchCommit, git.RefNameFromBranch(repo.DefaultBranch), workflowID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetActionWorkflowByRef(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string, ref git.RefName) (*api.ActionWorkflow, error) {
|
||||||
|
if ref == "" {
|
||||||
|
return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
|
||||||
|
}
|
||||||
|
|
||||||
|
refCommitID, err := gitrepo.GetRefCommitID(ref.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refCommit, err := gitrepo.GetCommit(refCommitID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return getActionWorkflowFromCommit(ctx, repo, refCommit, ref, workflowID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getActionWorkflowFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, refName git.RefName, workflowID string) (*api.ActionWorkflow, error) {
|
||||||
|
folder, entries, err := actions.ListWorkflows(commit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.Name() == workflowID {
|
if entry.Name() == workflowID {
|
||||||
return getActionWorkflowEntry(ctx, repo, defaultBranchCommit, repo.DefaultBranch, folder, entry), nil
|
return getActionWorkflowEntry(ctx, repo, commit, refName, folder, entry), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -102,10 +102,14 @@ func DeleteOrganization(ctx context.Context, org *org_model.Organization, purge
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateOrgRepoForVisibilityChanged(ctx context.Context, repo *repo_model.Repository, makePrivate bool) error {
|
func updateRepoForVisibilityChanged(ctx context.Context, repo *repo_model.Repository, makePrivate bool) error {
|
||||||
|
if err := repo.LoadOwner(ctx); err != nil {
|
||||||
|
return fmt.Errorf("LoadOwner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Organization repository need to recalculate access table when visibility is changed.
|
// Organization repository need to recalculate access table when visibility is changed.
|
||||||
if err := access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
|
if err := access_model.RecalculateAccesses(ctx, repo); err != nil {
|
||||||
return fmt.Errorf("recalculateTeamAccesses: %w", err)
|
return fmt.Errorf("RecalculateAccesses: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if makePrivate {
|
if makePrivate {
|
||||||
@@ -135,7 +139,7 @@ func updateOrgRepoForVisibilityChanged(ctx context.Context, repo *repo_model.Rep
|
|||||||
return fmt.Errorf("getRepositoriesByForkID: %w", err)
|
return fmt.Errorf("getRepositoriesByForkID: %w", err)
|
||||||
}
|
}
|
||||||
for i := range forkRepos {
|
for i := range forkRepos {
|
||||||
if err := updateOrgRepoForVisibilityChanged(ctx, forkRepos[i], makePrivate); err != nil {
|
if err := updateRepoForVisibilityChanged(ctx, forkRepos[i], makePrivate); err != nil {
|
||||||
return fmt.Errorf("updateRepoForVisibilityChanged[%s]: %w", forkRepos[i].FullName(), err)
|
return fmt.Errorf("updateRepoForVisibilityChanged[%s]: %w", forkRepos[i].FullName(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,8 +165,8 @@ func ChangeOrganizationVisibility(ctx context.Context, org *org_model.Organizati
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, repo := range repos {
|
for _, repo := range repos {
|
||||||
if err := updateOrgRepoForVisibilityChanged(ctx, repo, visibility == structs.VisibleTypePrivate); err != nil {
|
if err := updateRepoForVisibilityChanged(ctx, repo, visibility == structs.VisibleTypePrivate); err != nil {
|
||||||
return fmt.Errorf("updateOrgRepoForVisibilityChanged: %w", err)
|
return fmt.Errorf("updateRepoForVisibilityChanged: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -60,4 +61,11 @@ func TestOrg(t *testing.T) {
|
|||||||
assert.Error(t, DeleteOrganization(t.Context(), user, false))
|
assert.Error(t, DeleteOrganization(t.Context(), user, false))
|
||||||
unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{})
|
unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("ChangeVisibilityWithUserFork", func(t *testing.T) {
|
||||||
|
// org 19 has a repository 27 which has a forked repository 29 by user 20
|
||||||
|
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 19})
|
||||||
|
require.NoError(t, ChangeOrganizationVisibility(t.Context(), org, structs.VisibleTypePrivate))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: org.ID, Visibility: structs.VisibleTypePrivate})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ package project
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
issues_model "code.gitea.io/gitea/models/issues"
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
project_model "code.gitea.io/gitea/models/project"
|
project_model "code.gitea.io/gitea/models/project"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/container"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,6 +89,31 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) {
|
||||||
|
var issueList issues_model.IssueList
|
||||||
|
for _, colIssues := range issuesMap {
|
||||||
|
issueList = append(issueList, colIssues...)
|
||||||
|
}
|
||||||
|
err := issueList.LoadAssignees(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users := make([]*user_model.User, 0, len(issueList))
|
||||||
|
usersAdded := container.Set[int64]{}
|
||||||
|
for _, issue := range issueList {
|
||||||
|
for _, assignee := range issue.Assignees {
|
||||||
|
if !usersAdded.Contains(assignee.ID) {
|
||||||
|
usersAdded.Add(assignee.ID)
|
||||||
|
users = append(users, assignee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(users, func(a, b *user_model.User) int {
|
||||||
|
return strings.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoadIssuesFromProject load issues assigned to each project column inside the given project
|
// LoadIssuesFromProject load issues assigned to each project column inside the given project
|
||||||
func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) {
|
func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) {
|
||||||
issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
|
issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_Projects(t *testing.T) {
|
func Test_Projects(t *testing.T) {
|
||||||
@@ -207,4 +208,18 @@ func Test_Projects(t *testing.T) {
|
|||||||
assert.Len(t, columnIssues[3], 1)
|
assert.Len(t, columnIssues[3], 1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) {
|
||||||
|
issuesMap := map[int64]issues_model.IssueList{}
|
||||||
|
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||||
|
issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
||||||
|
issuesMap[1] = issues_model.IssueList{issue1}
|
||||||
|
issuesMap[2] = issues_model.IssueList{issue6}
|
||||||
|
assignees, err := LoadIssuesAssigneesForProject(t.Context(), issuesMap)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, assignees, 3)
|
||||||
|
require.Equal(t, "user1", assignees[0].Name)
|
||||||
|
require.Equal(t, "user10", assignees[1].Name)
|
||||||
|
require.Equal(t, "user2", assignees[2].Name)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,15 +40,8 @@ func GetReviewers(ctx context.Context, repo *repo_model.Repository, doerID, post
|
|||||||
uniqueUserIDs.AddMultiple(collaboratorIDs...)
|
uniqueUserIDs.AddMultiple(collaboratorIDs...)
|
||||||
|
|
||||||
if repo.Owner.IsOrganization() {
|
if repo.Owner.IsOrganization() {
|
||||||
additionalUserIDs := make([]int64, 0, 10)
|
additionalUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests)
|
||||||
if err := e.Table("team_user").
|
if err != nil {
|
||||||
Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id").
|
|
||||||
Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id").
|
|
||||||
Where("`team_repo`.repo_id = ? AND (`team_unit`.access_mode >= ? AND `team_unit`.`type` = ?)",
|
|
||||||
repo.ID, perm.AccessModeRead, unit.TypePullRequests).
|
|
||||||
Distinct("`team_user`.uid").
|
|
||||||
Select("`team_user`.uid").
|
|
||||||
Find(&additionalUserIDs); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
uniqueUserIDs.AddMultiple(additionalUserIDs...)
|
uniqueUserIDs.AddMultiple(additionalUserIDs...)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/templates/vars"
|
"code.gitea.io/gitea/modules/templates/vars"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateRepoOptions contains the create repository options
|
// CreateRepoOptions contains the create repository options
|
||||||
@@ -85,7 +86,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir
|
|||||||
cloneLink := repo.CloneLink(ctx, nil /* no doer so do not generate user-related SSH link */)
|
cloneLink := repo.CloneLink(ctx, nil /* no doer so do not generate user-related SSH link */)
|
||||||
match := map[string]string{
|
match := map[string]string{
|
||||||
"Name": repo.Name,
|
"Name": repo.Name,
|
||||||
"Description": repo.Description,
|
"Description": util.NormalizeStringEOL(repo.Description),
|
||||||
"CloneURL.SSH": cloneLink.SSH,
|
"CloneURL.SSH": cloneLink.SSH,
|
||||||
"CloneURL.HTTPS": cloneLink.HTTPS,
|
"CloneURL.HTTPS": cloneLink.HTTPS,
|
||||||
"OwnerName": repo.OwnerName,
|
"OwnerName": repo.OwnerName,
|
||||||
|
|||||||
@@ -178,8 +178,9 @@ func (t *TemporaryUploadRepository) HashObjectAndWrite(ctx context.Context, cont
|
|||||||
|
|
||||||
// AddObjectToIndex adds the provided object hash to the index with the provided mode and path
|
// AddObjectToIndex adds the provided object hash to the index with the provided mode and path
|
||||||
func (t *TemporaryUploadRepository) AddObjectToIndex(ctx context.Context, mode, objectHash, objectPath string) error {
|
func (t *TemporaryUploadRepository) AddObjectToIndex(ctx context.Context, mode, objectHash, objectPath string) error {
|
||||||
if err := gitcmd.NewCommand("update-index", "--add", "--replace", "--cacheinfo").
|
cmd := gitcmd.NewCommand("update-index", "--add", "--replace", "--cacheinfo").
|
||||||
AddDynamicArguments(mode, objectHash, objectPath).WithDir(t.basePath).RunWithStderr(ctx); err != nil {
|
AddDynamicArguments(mode + "," + objectHash + "," + objectPath).WithDir(t.basePath)
|
||||||
|
if err := cmd.RunWithStderr(ctx); err != nil {
|
||||||
if matched, _ := regexp.MatchString(".*Invalid path '.*", err.Stderr()); matched {
|
if matched, _ := regexp.MatchString(".*Invalid path '.*", err.Stderr()); matched {
|
||||||
return ErrFilePathInvalid{
|
return ErrFilePathInvalid{
|
||||||
Message: objectPath,
|
Message: objectPath,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTemporaryUploadRepository(t *testing.T) {
|
||||||
|
mockedRepo := &repo_model.Repository{Name: "mocked-repo-name", OwnerName: "mocked-owner-name"}
|
||||||
|
|
||||||
|
doTest := func(t *testing.T, objectFormatName string) {
|
||||||
|
tmpGitRepo, err := NewTemporaryUploadRepository(mockedRepo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer tmpGitRepo.Close()
|
||||||
|
|
||||||
|
require.NoError(t, tmpGitRepo.Init(t.Context(), objectFormatName))
|
||||||
|
|
||||||
|
require.NoError(t, tmpGitRepo.RemoveFilesFromIndex(t.Context(), "any-file-name"))
|
||||||
|
require.NoError(t, tmpGitRepo.RemoveFilesFromIndex(t.Context(), "--any-file-name"))
|
||||||
|
|
||||||
|
objID, err := tmpGitRepo.HashObjectAndWrite(t.Context(), bytes.NewReader(nil))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, tmpGitRepo.AddObjectToIndex(t.Context(), "100644", objID, "any-file-name"))
|
||||||
|
require.NoError(t, tmpGitRepo.AddObjectToIndex(t.Context(), "100644", objID, "--any-file-name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("sha1", func(t *testing.T) {
|
||||||
|
doTest(t, git.Sha1ObjectFormat.Name())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sha256", func(t *testing.T) {
|
||||||
|
if !git.DefaultFeatures().SupportHashSha256 {
|
||||||
|
t.Skip("sha256 is not supported")
|
||||||
|
}
|
||||||
|
doTest(t, git.Sha256ObjectFormat.Name())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package webhook
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
git_model "code.gitea.io/gitea/models/git"
|
git_model "code.gitea.io/gitea/models/git"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/repository"
|
"code.gitea.io/gitea/modules/repository"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||||
"code.gitea.io/gitea/services/convert"
|
"code.gitea.io/gitea/services/convert"
|
||||||
notify_service "code.gitea.io/gitea/services/notify"
|
notify_service "code.gitea.io/gitea/services/notify"
|
||||||
@@ -1032,7 +1034,10 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
|
|||||||
}
|
}
|
||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
|
|
||||||
convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
|
convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
|
||||||
|
if err != nil && errors.Is(err, util.ErrNotExist) {
|
||||||
|
convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetActionWorkflow: %v", err)
|
log.Error("GetActionWorkflow: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="ui small modal" id="block-user-modal">
|
<div class="ui small modal" id="block-user-modal">
|
||||||
<div class="header">{{ctx.Locale.Tr "user.block.title"}}</div>
|
<div class="header">{{ctx.Locale.Tr "user.block.title"}}</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="ui warning message">{{ctx.Locale.Tr "user.block.info"}}</div>
|
<div class="ui warning message">{{ctx.Locale.Tr "user.block.info"}} <a target="_blank" href="https://docs.gitea.com/usage/access-control/blocking-user">{{ctx.Locale.Tr "user.block.info.docs"}}</a></div>
|
||||||
<form class="ui form modal-form" method="post">
|
<form class="ui form modal-form" method="post">
|
||||||
<input type="hidden" name="action" value="block" />
|
<input type="hidden" name="action" value="block" />
|
||||||
<input type="hidden" name="blockee" class="modal-blockee" />
|
<input type="hidden" name="blockee" class="modal-blockee" />
|
||||||
|
|||||||
@@ -2,15 +2,7 @@
|
|||||||
{{ctx.Locale.Tr "user.block.title"}}
|
{{ctx.Locale.Tr "user.block.title"}}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
<p>{{ctx.Locale.Tr "user.block.info_1"}}</p>
|
<p>{{ctx.Locale.Tr "user.block.info"}} <a target="_blank" href="https://docs.gitea.com/usage/access-control/blocking-user">{{ctx.Locale.Tr "user.block.info.docs"}}</a></p>
|
||||||
<ul>
|
|
||||||
<li>{{ctx.Locale.Tr "user.block.info_2"}}</li>
|
|
||||||
<li>{{ctx.Locale.Tr "user.block.info_3"}}</li>
|
|
||||||
<li>{{ctx.Locale.Tr "user.block.info_4"}}</li>
|
|
||||||
<li>{{ctx.Locale.Tr "user.block.info_5"}}</li>
|
|
||||||
<li>{{ctx.Locale.Tr "user.block.info_6"}}</li>
|
|
||||||
<li>{{ctx.Locale.Tr "user.block.info_7"}}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ui segment">
|
<div class="ui segment">
|
||||||
<form class="ui form ignore-dirty" action="{{$.Link}}" method="post">
|
<form class="ui form ignore-dirty" action="{{$.Link}}" method="post">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<div id="external-login-navigator" class="tw-py-1 tw-flex tw-flex-col tw-gap-3">
|
<div id="external-login-navigator" class="tw-py-1 tw-flex tw-flex-col tw-gap-3">
|
||||||
{{range $provider := .OAuth2Providers}}
|
{{range $provider := .OAuth2Providers}}
|
||||||
{{/* use QueryEscape for consistent with frontend urlQueryEscape, it is right for a path component */}}
|
<a class="ui button external-login-link tw-gap-3" data-require-appurl-check="true" rel="nofollow" href="{{AppSubUrl}}/user/oauth2/{{$provider.DisplayName | PathEscape}}">
|
||||||
<a class="ui button external-login-link tw-gap-3" data-require-appurl-check="true" rel="nofollow" href="{{AppSubUrl}}/user/oauth2/{{$provider.DisplayName | QueryEscape}}">
|
|
||||||
{{$provider.IconHTML 24}} {{ctx.Locale.Tr "sign_in_with_provider" $provider.DisplayName}}
|
{{$provider.IconHTML 24}} {{ctx.Locale.Tr "sign_in_with_provider" $provider.DisplayName}}
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="menu">
|
<div class="menu">
|
||||||
{{range $key := .OrderedOAuth2Names}}
|
{{range $key := .OrderedOAuth2Names}}
|
||||||
{{$provider := index $.OAuth2Providers $key}}
|
{{$provider := index $.OAuth2Providers $key}}
|
||||||
<a class="item" href="{{AppSubUrl}}/user/oauth2/{{$key}}">
|
<a class="item" href="{{AppSubUrl}}/user/oauth2/{{$key | PathEscape}}">
|
||||||
{{$provider.IconHTML 20}}
|
{{$provider.IconHTML 20}}
|
||||||
{{$provider.DisplayName}}
|
{{$provider.DisplayName}}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ test.describe('events', () => {
|
|||||||
// Verify page2 is logged in
|
// Verify page2 is logged in
|
||||||
await expect(page2.getByRole('link', {name: 'Sign In'})).toBeHidden();
|
await expect(page2.getByRole('link', {name: 'Sign In'})).toBeHidden();
|
||||||
|
|
||||||
|
// Give page2's SharedWorker time to register its SSE connection on the
|
||||||
|
// server — otherwise the logout event can race the connection and be
|
||||||
|
// silently dropped. See https://github.com/go-gitea/gitea/pull/37403
|
||||||
|
// In the future, we can set an attribute to HTML page when the connection is established,
|
||||||
|
// then here we can just wait for that attribute (it should also work for the planned WebSocket SharedWorker)
|
||||||
|
await page2.waitForTimeout(500); // eslint-disable-line playwright/no-wait-for-timeout
|
||||||
|
|
||||||
// Logout from page1 — this sends a logout event to all tabs
|
// Logout from page1 — this sends a logout event to all tabs
|
||||||
await page1.goto('/user/logout');
|
await page1.goto('/user/logout');
|
||||||
|
|
||||||
|
|||||||
@@ -88,37 +88,34 @@ func TestPackageContainer(t *testing.T) {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultAuthenticateValues := []string{
|
wwwAuthenticateForPublic := []string{
|
||||||
|
`Bearer realm="` + setting.AppURL + `v2/token",service="container_registry",scope="*"`,
|
||||||
|
}
|
||||||
|
wwwAuthenticateForRequiredSignIn := []string{
|
||||||
`Bearer realm="` + setting.AppURL + `v2/token",service="container_registry",scope="*"`,
|
`Bearer realm="` + setting.AppURL + `v2/token",service="container_registry",scope="*"`,
|
||||||
`Basic realm="Gitea Container Registry"`,
|
`Basic realm="Gitea Container Registry"`,
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("Anonymous", func(t *testing.T) {
|
t.Run("Anonymous", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
req := NewRequest(t, "GET", setting.AppURL+"v2")
|
req := NewRequest(t, "GET", setting.AppURL+"v2")
|
||||||
resp := MakeRequest(t, req, http.StatusUnauthorized)
|
resp := MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
assert.ElementsMatch(t, wwwAuthenticateForPublic, resp.Header().Values("WWW-Authenticate"))
|
||||||
assert.ElementsMatch(t, defaultAuthenticateValues, resp.Header().Values("WWW-Authenticate"))
|
|
||||||
|
|
||||||
req = NewRequest(t, "GET", setting.AppURL+"v2/token")
|
req = NewRequest(t, "GET", setting.AppURL+"v2/token")
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
tokenResponse := DecodeJSON(t, resp, &TokenResponse{})
|
||||||
tokenResponse := &TokenResponse{}
|
require.NotEmpty(t, tokenResponse.Token)
|
||||||
DecodeJSON(t, resp, &tokenResponse)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, tokenResponse.Token)
|
|
||||||
|
|
||||||
anonymousToken = "Bearer " + tokenResponse.Token
|
anonymousToken = "Bearer " + tokenResponse.Token
|
||||||
|
|
||||||
req = NewRequest(t, "GET", setting.AppURL+"v2").
|
req = NewRequest(t, "GET", setting.AppURL+"v2").AddTokenAuth(anonymousToken)
|
||||||
AddTokenAuth(anonymousToken)
|
|
||||||
MakeRequest(t, req, http.StatusOK)
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
|
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
|
||||||
|
|
||||||
req = NewRequest(t, "GET", setting.AppURL+"v2")
|
req = NewRequest(t, "GET", setting.AppURL+"v2")
|
||||||
MakeRequest(t, req, http.StatusUnauthorized)
|
resp = MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
assert.ElementsMatch(t, wwwAuthenticateForRequiredSignIn, resp.Header().Values("WWW-Authenticate"))
|
||||||
|
|
||||||
req = NewRequest(t, "GET", setting.AppURL+"v2/token")
|
req = NewRequest(t, "GET", setting.AppURL+"v2/token")
|
||||||
MakeRequest(t, req, http.StatusUnauthorized)
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
@@ -135,17 +132,13 @@ func TestPackageContainer(t *testing.T) {
|
|||||||
|
|
||||||
req := NewRequest(t, "GET", setting.AppURL+"v2")
|
req := NewRequest(t, "GET", setting.AppURL+"v2")
|
||||||
resp := MakeRequest(t, req, http.StatusUnauthorized)
|
resp := MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
assert.ElementsMatch(t, wwwAuthenticateForPublic, resp.Header().Values("WWW-Authenticate"))
|
||||||
|
|
||||||
assert.ElementsMatch(t, defaultAuthenticateValues, resp.Header().Values("WWW-Authenticate"))
|
req = NewRequest(t, "GET", setting.AppURL+"v2/token").AddBasicAuth(user.Name)
|
||||||
|
|
||||||
req = NewRequest(t, "GET", setting.AppURL+"v2/token").
|
|
||||||
AddBasicAuth(user.Name)
|
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
tokenResponse := DecodeJSON(t, resp, &TokenResponse{})
|
||||||
tokenResponse := &TokenResponse{}
|
|
||||||
DecodeJSON(t, resp, &tokenResponse)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, tokenResponse.Token)
|
assert.NotEmpty(t, tokenResponse.Token)
|
||||||
|
|
||||||
pkgMeta, err := package_service.ParseAuthorizationToken(tokenResponse.Token)
|
pkgMeta, err := package_service.ParseAuthorizationToken(tokenResponse.Token)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, user.ID, pkgMeta.UserID)
|
assert.Equal(t, user.ID, pkgMeta.UserID)
|
||||||
|
|||||||
@@ -32,14 +32,24 @@ import (
|
|||||||
|
|
||||||
func TestPackageRpm(t *testing.T) {
|
func TestPackageRpm(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
|
||||||
packageName := "gitea-test"
|
packageName := "gitea-test"
|
||||||
packageVersion := "1.0.2-1"
|
packageVersion := "1.0.2-1"
|
||||||
packageArchitecture := "x86_64"
|
packageArchitecture := "x86_64"
|
||||||
|
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
// To build an RPM package, it needs tools lik "cpio", it's not easy to do in Golang.
|
||||||
|
// So here we only use pre-built package contents. And to save space, the content is gzipped and base64 encoded.
|
||||||
base64RpmPackageContent := `H4sICFayB2QCAGdpdGVhLXRlc3QtMS4wLjItMS14ODZfNjQucnBtAO2YV4gTQRjHJzl7wbNhhxVF
|
rpmContentFromGzipBase64 := func(t testing.TB, s string) []byte {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
require.NoError(t, err)
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(b))
|
||||||
|
require.NoError(t, err)
|
||||||
|
content, err := io.ReadAll(zr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
packageRpmGzipBase64 := `H4sICFayB2QCAGdpdGVhLXRlc3QtMS4wLjItMS14ODZfNjQucnBtAO2YV4gTQRjHJzl7wbNhhxVF
|
||||||
VNwk2zd2PdvZ9Sxnd3Z3NllNsmF3o6congVFsWFHRWwIImIXfRER0QcRfPBJEXvvBQvWSfZTT0VQ
|
VNwk2zd2PdvZ9Sxnd3Z3NllNsmF3o6congVFsWFHRWwIImIXfRER0QcRfPBJEXvvBQvWSfZTT0VQ
|
||||||
8TF/MuU33zcz3+zOJGEe73lyuQBRBWKWRzDrEddjuVAkxLMc+lsFUOWfm5bvvReAalWECg/TsivU
|
8TF/MuU33zcz3+zOJGEe73lyuQBRBWKWRzDrEddjuVAkxLMc+lsFUOWfm5bvvReAalWECg/TsivU
|
||||||
dyKa0U61aVnl6wj0Uxe4nc8F92hZiaYE8CO/P0r7/Quegr0c7M/AvoCaGZEIWNGUqMHrhhGROIUT
|
dyKa0U61aVnl6wj0Uxe4nc8F92hZiaYE8CO/P0r7/Quegr0c7M/AvoCaGZEIWNGUqMHrhhGROIUT
|
||||||
@@ -67,14 +77,8 @@ Mu0UFYgZ/bYnuvn/vz4wtCz8qMwsHUvP0PX3tbYFUctAPdrY6tiiDtcCddDECahx7SuVNP5dpmb5
|
|||||||
9tMDyaXb7OAlk5acuPn57ss9mw6Wym0m1Fq2cej7tUt2LL4/b8enXU2fndk+fvv57ndnt55/cQob
|
9tMDyaXb7OAlk5acuPn57ss9mw6Wym0m1Fq2cej7tUt2LL4/b8enXU2fndk+fvv57ndnt55/cQob
|
||||||
7tpp/pEjDS7cGPZ6BY430+7danDq6f42Nw49b9F7zp6BiKpJb9s5P0AYN2+L159cnrur636rx+v1
|
7tpp/pEjDS7cGPZ6BY430+7danDq6f42Nw49b9F7zp6BiKpJb9s5P0AYN2+L159cnrur636rx+v1
|
||||||
7ae1K28QbMMcqI8CqwIrgwg9nTOp8Oj9q81plUY7ZuwXN8Vvs8wbAAA=`
|
7ae1K28QbMMcqI8CqwIrgwg9nTOp8Oj9q81plUY7ZuwXN8Vvs8wbAAA=`
|
||||||
rpmPackageContent, err := base64.StdEncoding.DecodeString(base64RpmPackageContent)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
zr, err := gzip.NewReader(bytes.NewReader(rpmPackageContent))
|
packageRpmContent := rpmContentFromGzipBase64(t, packageRpmGzipBase64)
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
content, err := io.ReadAll(zr)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
decodeGzipXML := func(t testing.TB, resp *httptest.ResponseRecorder, v any) {
|
decodeGzipXML := func(t testing.TB, resp *httptest.ResponseRecorder, v any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -130,10 +134,10 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`,
|
|||||||
t.Run("Upload", func(t *testing.T) {
|
t.Run("Upload", func(t *testing.T) {
|
||||||
url := groupURL + "/upload"
|
url := groupURL + "/upload"
|
||||||
|
|
||||||
req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content))
|
req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(packageRpmContent))
|
||||||
MakeRequest(t, req, http.StatusUnauthorized)
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
|
||||||
req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
|
req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(packageRpmContent)).
|
||||||
AddBasicAuth(user.Name)
|
AddBasicAuth(user.Name)
|
||||||
MakeRequest(t, req, http.StatusCreated)
|
MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
@@ -156,9 +160,9 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`,
|
|||||||
|
|
||||||
pb, err := packages.GetBlobByID(t.Context(), pfs[0].BlobID)
|
pb, err := packages.GetBlobByID(t.Context(), pfs[0].BlobID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, int64(len(content)), pb.Size)
|
assert.Equal(t, int64(len(packageRpmContent)), pb.Size)
|
||||||
|
|
||||||
req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
|
req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(packageRpmContent)).
|
||||||
AddBasicAuth(user.Name)
|
AddBasicAuth(user.Name)
|
||||||
MakeRequest(t, req, http.StatusConflict)
|
MakeRequest(t, req, http.StatusConflict)
|
||||||
})
|
})
|
||||||
@@ -169,12 +173,12 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`,
|
|||||||
// download the package without the file name
|
// download the package without the file name
|
||||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture))
|
req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture))
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
assert.Equal(t, content, resp.Body.Bytes())
|
assert.Equal(t, packageRpmContent, resp.Body.Bytes())
|
||||||
|
|
||||||
// download the package with a file name (it can be anything)
|
// download the package with a file name (it can be anything)
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s/any-file-name", groupURL, packageName, packageVersion, packageArchitecture))
|
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s/any-file-name", groupURL, packageName, packageVersion, packageArchitecture))
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
assert.Equal(t, content, resp.Body.Bytes())
|
assert.Equal(t, packageRpmContent, resp.Body.Bytes())
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Repository", func(t *testing.T) {
|
t.Run("Repository", func(t *testing.T) {
|
||||||
@@ -332,7 +336,7 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`,
|
|||||||
assert.Equal(t, "sha256", p.Checksum.Type)
|
assert.Equal(t, "sha256", p.Checksum.Type)
|
||||||
assert.Equal(t, "f1d5d2ffcbe4a7568e98b864f40d923ecca084e9b9bcd5977ed6521c46d3fa4c", p.Checksum.Checksum)
|
assert.Equal(t, "f1d5d2ffcbe4a7568e98b864f40d923ecca084e9b9bcd5977ed6521c46d3fa4c", p.Checksum.Checksum)
|
||||||
assert.Equal(t, "https://gitea.io", p.URL)
|
assert.Equal(t, "https://gitea.io", p.URL)
|
||||||
assert.EqualValues(t, len(content), p.Size.Package)
|
assert.EqualValues(t, len(packageRpmContent), p.Size.Package)
|
||||||
assert.EqualValues(t, 13, p.Size.Installed)
|
assert.EqualValues(t, 13, p.Size.Installed)
|
||||||
assert.EqualValues(t, 272, p.Size.Archive)
|
assert.EqualValues(t, 272, p.Size.Archive)
|
||||||
assert.Equal(t, fmt.Sprintf("package/%s/%s/%s/%s", packageName, packageVersion, packageArchitecture, fmt.Sprintf("%s-%s.%s.rpm", packageName, packageVersion, packageArchitecture)), p.Location.Href)
|
assert.Equal(t, fmt.Sprintf("package/%s/%s/%s/%s", packageName, packageVersion, packageArchitecture, fmt.Sprintf("%s-%s.%s.rpm", packageName, packageVersion, packageArchitecture)), p.Location.Href)
|
||||||
@@ -744,6 +748,95 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`,
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("NoArch", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
noarchPackageName := "gitea-noarch-test"
|
||||||
|
noarchPackageVersion := "1.0.0-1"
|
||||||
|
noarchPackageGzipBase64 := `H4sICKC05mkAA2dpdGVhLW5vYXJjaC10ZXN0LTEuMC4wLTEubm9hcmNoLnJwbQDtmLtrFEEcx+dy` +
|
||||||
|
`p0ZROYliFMUTLJJiz53HPgbBBwZR0Jgiglo5sztzLt6Lu0uIYmEhFhFUsBUJahd8gHZWKvgnpLES` +
|
||||||
|
`VIwYo43a6Dm7+zvfhbXsF+ZmP/N7zM5y03wXZt89yyOjbiXqKGHVG6IVnLQ6qt2xcNku2xZG/6wc` +
|
||||||
|
`WvL70qXbr3PwuAyh4gMz74TnW2YumqJVZl76vQPKrQEeTjn/2swFM6rAb9N61Ezr84sQPwfx9xA/` +
|
||||||
|
`b8Il6nEpWSgwVz7lrqOZ9oWjqKZSShIQT/k4cHWIAtcJHNsR1OGKSp8w5QcMM+4SiSV1OSY293wi` +
|
||||||
|
`TAMmmBauL1XoeJ7mXAqhafL6BXzzSd+N2eFP9x8/nHt25u7CBbN49t8/YKZMmTJlypQpU6ZMmTJl` +
|
||||||
|
`ypTpP1biiXS73Sso8TR+8U1KCOU+mHkXSnyN3HPICc3oh5yeTxL7Jn3A88Brgd8Ab0Q/fJTlZmwC` +
|
||||||
|
`XgC2gd+h1FfZDbwI9SPAHyB+EPgjxMeAP0O/ceAvED8B/BVYp1xYC1wDXg/nuww8CPvFvlHePG6A` +
|
||||||
|
`+D3gjcBd4KG0X24d1B9N63Nw3sKxND9XApaQPwQcAm8HVsAMWANz4CrwjpQHrsJ+8D0GnkIcvsfA` +
|
||||||
|
`C9j/OPBLyL8GPA/xmZj3oj/8OZT4cwij0WStFK+VmiI4JSoKjf8EZdMgevVgRjbqqg1/mEMHxtGR` +
|
||||||
|
`erupgkhHKkTVqD4xhdLuf27VswLL7VZQbjVrf3mZ9KVX9IZJqkZyaG+j1mypdluF+6KqGhU11R5G` +
|
||||||
|
`EItXRqKKKf6xNiZOVxsiSW7vF5NqrKV0NDWMqNmfWRixsptYkgx+iV1O/Mn+nldpHSYlq4KCZtRA` +
|
||||||
|
`lTNRE3E4lDWp6mGjZaUHTWomOtrykdCUKxxgLjTzfKG0J2wqFfeVFjyMzT1CfC255lRRn5HQDT2J` +
|
||||||
|
`mcDMdQRzeNqL0NBmhEimlTDpnoeV7xGPYSmpZ3vcljQIbYf6SmjJXEwoVT6xpc+k4wkS/7fSC97t` +
|
||||||
|
`xvcCFbdcTO92X57apoHNSquLs6s3b3s0uHXPpes77+yZnp5eaa7m3PxbJ3YYvwFme3wbyRUAAA==`
|
||||||
|
noarchRpmPackageContent := rpmContentFromGzipBase64(t, noarchPackageGzipBase64)
|
||||||
|
// Upload the noarch RPM
|
||||||
|
req := NewRequestWithBody(t, "PUT", groupURL+"/upload", bytes.NewReader(noarchRpmPackageContent)).AddBasicAuth(user.Name)
|
||||||
|
MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
// The noarch package should appear in primary.xml when any arch is requested.
|
||||||
|
// At this point the group contains the existing x86_64 package + this noarch one.
|
||||||
|
req = NewRequest(t, "GET", groupURL+"/repodata/primary.xml.gz")
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
type PackageSummary struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
Architecture string `xml:"arch"`
|
||||||
|
}
|
||||||
|
type PrimaryMetadata struct {
|
||||||
|
XMLName xml.Name `xml:"metadata"`
|
||||||
|
PackageCount int `xml:"packages,attr"`
|
||||||
|
Packages []PackageSummary `xml:"package"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var primary PrimaryMetadata
|
||||||
|
decodeGzipXML(t, resp, &primary)
|
||||||
|
|
||||||
|
// Both the arch-specific package uploaded earlier and the noarch one must be present.
|
||||||
|
assert.Equal(t, 2, primary.PackageCount)
|
||||||
|
assert.Len(t, primary.Packages, 2)
|
||||||
|
|
||||||
|
archNames := make([]string, 0, len(primary.Packages))
|
||||||
|
for _, p := range primary.Packages {
|
||||||
|
archNames = append(archNames, p.Architecture)
|
||||||
|
}
|
||||||
|
assert.Contains(t, archNames, packageArchitecture) // x86_64 from the Upload subtest
|
||||||
|
assert.Contains(t, archNames, "noarch")
|
||||||
|
|
||||||
|
// noarch package must be downloadable regardless of the arch path used.
|
||||||
|
for _, arch := range []string{"noarch", "x86_64", "aarch64", "my_arch"} {
|
||||||
|
req = NewRequest(t, "GET", fmt.Sprintf(
|
||||||
|
"%s/package/%s/%s/%s",
|
||||||
|
groupURL, noarchPackageName, noarchPackageVersion, arch,
|
||||||
|
))
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up: delete via the canonical noarch path.
|
||||||
|
req = NewRequest(t, "DELETE", fmt.Sprintf(
|
||||||
|
"%s/package/%s/%s/noarch",
|
||||||
|
groupURL, noarchPackageName, noarchPackageVersion,
|
||||||
|
)).AddBasicAuth(user.Name)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// After deletion, the noarch package must no longer be reachable via any arch.
|
||||||
|
for _, arch := range []string{"noarch", "x86_64", "aarch64"} {
|
||||||
|
req = NewRequest(t, "GET", fmt.Sprintf(
|
||||||
|
"%s/package/%s/%s/%s",
|
||||||
|
groupURL, noarchPackageName, noarchPackageVersion, arch,
|
||||||
|
))
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The x86_64 package from the Upload subtest must still be present.
|
||||||
|
req = NewRequest(t, "GET", groupURL+"/repodata/primary.xml.gz")
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
var primaryAfter PrimaryMetadata
|
||||||
|
decodeGzipXML(t, resp, &primaryAfter)
|
||||||
|
assert.Equal(t, 1, primaryAfter.PackageCount)
|
||||||
|
assert.Equal(t, packageArchitecture, primaryAfter.Packages[0].Architecture)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Delete", func(t *testing.T) {
|
t.Run("Delete", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
@@ -765,7 +858,7 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`,
|
|||||||
t.Run("UploadSign", func(t *testing.T) {
|
t.Run("UploadSign", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
url := groupURL + "/upload?sign=true"
|
url := groupURL + "/upload?sign=true"
|
||||||
req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
|
req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(packageRpmContent)).
|
||||||
AddBasicAuth(user.Name)
|
AddBasicAuth(user.Name)
|
||||||
MakeRequest(t, req, http.StatusCreated)
|
MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"code.gitea.io/gitea/services/oauth2_provider"
|
"code.gitea.io/gitea/services/oauth2_provider"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
"github.com/markbates/goth"
|
"github.com/markbates/goth"
|
||||||
"github.com/markbates/goth/gothic"
|
"github.com/markbates/goth/gothic"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -44,6 +45,8 @@ func TestOAuth2Provider(t *testing.T) {
|
|||||||
t.Run("AuthorizeLoginRedirect", testAuthorizeLoginRedirect)
|
t.Run("AuthorizeLoginRedirect", testAuthorizeLoginRedirect)
|
||||||
|
|
||||||
t.Run("OAuth2WellKnown", testOAuth2WellKnown)
|
t.Run("OAuth2WellKnown", testOAuth2WellKnown)
|
||||||
|
t.Run("OAuthSourceSpecialChars", testOAuthSourceSpecialChars)
|
||||||
|
// TODO: move more tests as sub-tests here, avoid unnecessary PrepareTestEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAuthorizeNoClientID(t *testing.T) {
|
func testAuthorizeNoClientID(t *testing.T) {
|
||||||
@@ -999,9 +1002,7 @@ func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) {
|
func createMockServer() *httptest.Server {
|
||||||
defer tests.PrepareTestEnv(t)()
|
|
||||||
|
|
||||||
var mockServer *httptest.Server
|
var mockServer *httptest.Server
|
||||||
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
@@ -1016,6 +1017,14 @@ func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
return mockServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
mockServer := createMockServer()
|
||||||
defer mockServer.Close()
|
defer mockServer.Close()
|
||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -1091,3 +1100,47 @@ func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checks if an OAuth provider with spaces within the name does work,
|
||||||
|
// with the encoding of its names in the URL (PR#37327)
|
||||||
|
func testOAuthSourceSpecialChars(t *testing.T) {
|
||||||
|
mockServer := createMockServer()
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
addOAuth2Source(t, "test space", oauth2.Source{
|
||||||
|
Provider: "openidConnect",
|
||||||
|
OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
|
||||||
|
})
|
||||||
|
addOAuth2Source(t, "test+plus", oauth2.Source{
|
||||||
|
Provider: "openidConnect",
|
||||||
|
OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
|
||||||
|
})
|
||||||
|
|
||||||
|
testOAuth2 := func(t *testing.T, uri string, statusCode int) {
|
||||||
|
req := NewRequest(t, "GET", uri)
|
||||||
|
resp := MakeRequest(t, req, statusCode)
|
||||||
|
if statusCode == http.StatusTemporaryRedirect {
|
||||||
|
assert.NotEmpty(t, resp.Header().Get("Location"))
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, resp.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusOK)
|
||||||
|
doc := NewHTMLParser(t, req.Body)
|
||||||
|
var oauth2Links []string
|
||||||
|
doc.Find(".external-login-link").Each(func(i int, s *goquery.Selection) {
|
||||||
|
oauth2Links = append(oauth2Links, s.AttrOr("href", ""))
|
||||||
|
})
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"/user/oauth2/test%20space",
|
||||||
|
"/user/oauth2/test+plus",
|
||||||
|
}, oauth2Links)
|
||||||
|
|
||||||
|
testOAuth2(t, "/user/oauth2/test%20space", http.StatusTemporaryRedirect)
|
||||||
|
testOAuth2(t, "/user/oauth2/test+space", http.StatusNotFound)
|
||||||
|
|
||||||
|
testOAuth2(t, "/user/oauth2/test+plus", http.StatusTemporaryRedirect)
|
||||||
|
testOAuth2(t, "/user/oauth2/test%2Bplus", http.StatusTemporaryRedirect)
|
||||||
|
testOAuth2(t, "/user/oauth2/test%20plus", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runI
|
|||||||
foundRun := false
|
foundRun := false
|
||||||
|
|
||||||
for _, run := range runnerList.Entries {
|
for _, run := range runnerList.Entries {
|
||||||
|
if run.ID == 802 {
|
||||||
|
// Fixture stores registration event (push) and schedule as trigger; API must expose the trigger as Event.
|
||||||
|
assert.Equal(t, "schedule", run.Event)
|
||||||
|
}
|
||||||
// Verify filtering works
|
// Verify filtering works
|
||||||
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", run.Status, "", "", "", "")
|
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", run.Status, "", "", "", "")
|
||||||
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, run.Conclusion, "", "", "", "", "")
|
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, run.Conclusion, "", "", "", "", "")
|
||||||
|
|||||||
+5
-11
@@ -87,7 +87,7 @@ function iifeBuildOpts({sourceFileName, write}: {sourceFileName: string, write?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory
|
// Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory
|
||||||
// and rebuilds on file changes. In prod mode, writes to disk during closeBundle.
|
// and rebuilds on file changes. In prod mode, writes to disk and updates "manifest.json".
|
||||||
function iifePlugin(sourceFileName: string): Plugin {
|
function iifePlugin(sourceFileName: string): Plugin {
|
||||||
let iifeCode = '', iifeMap = '';
|
let iifeCode = '', iifeMap = '';
|
||||||
const iifeModules = new Set<string>();
|
const iifeModules = new Set<string>();
|
||||||
@@ -144,7 +144,7 @@ function iifePlugin(sourceFileName: string): Plugin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async closeBundle() {
|
async writeBundle() {
|
||||||
for (const file of globSync(`js/${sourceBaseName}.*.js*`, {cwd: outDir})) unlinkSync(join(outDir, file));
|
for (const file of globSync(`js/${sourceBaseName}.*.js*`, {cwd: outDir})) unlinkSync(join(outDir, file));
|
||||||
|
|
||||||
const result = await build(iifeBuildOpts({sourceFileName}));
|
const result = await build(iifeBuildOpts({sourceFileName}));
|
||||||
@@ -153,15 +153,9 @@ function iifePlugin(sourceFileName: string): Plugin {
|
|||||||
if (!entry) throw new Error('IIFE build produced no output');
|
if (!entry) throw new Error('IIFE build produced no output');
|
||||||
|
|
||||||
const manifestPath = join(outDir, '.vite', 'manifest.json');
|
const manifestPath = join(outDir, '.vite', 'manifest.json');
|
||||||
try {
|
const manifestData = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||||
const manifestData = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
manifestData[`web_src/js/${sourceFileName}`] = {file: entry.fileName, name: sourceBaseName, isEntry: true};
|
||||||
manifestData[`web_src/js/${sourceFileName}`] = {file: entry.fileName, name: sourceBaseName, isEntry: true};
|
writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2));
|
||||||
writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2));
|
|
||||||
} catch {
|
|
||||||
// FIXME: if it throws error here, the real Vite compilation error will be hidden, and makes the debug very difficult
|
|
||||||
// Need to find a correct way to handle errors.
|
|
||||||
console.error(`Failed to update manifest for ${sourceFileName}`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,18 +192,19 @@ td .commit-summary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.repo-editor-header {
|
.repo-editor-header {
|
||||||
|
/* it should match ".repo-button-row" so the tree toggle button stays aligned */
|
||||||
|
margin: 8px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 1rem 0;
|
|
||||||
padding: 3px 0;
|
|
||||||
width: 100%;
|
|
||||||
gap: 0.5em;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-editor-header input {
|
.repo-editor-header input {
|
||||||
vertical-align: middle !important;
|
vertical-align: middle !important;
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
padding: 7px 8px !important;
|
height: 30px !important;
|
||||||
|
padding: 5px 8px !important;
|
||||||
margin-right: 5px !important;
|
margin-right: 5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {checkAppUrl} from '../common-page.ts';
|
|||||||
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
||||||
import {POST} from '../../modules/fetch.ts';
|
import {POST} from '../../modules/fetch.ts';
|
||||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||||
import {urlQueryEscape} from '../../utils.ts';
|
import {pathEscape} from '../../utils/url.ts';
|
||||||
|
|
||||||
const {appSubUrl} = window.config;
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ function initAdminAuthentication() {
|
|||||||
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
|
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
|
||||||
const onAuthNameChange = function () {
|
const onAuthNameChange = function () {
|
||||||
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
|
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
|
||||||
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${urlQueryEscape(elAuthName.value)}/callback`;
|
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
|
||||||
};
|
};
|
||||||
elAuthName.addEventListener('input', onAuthNameChange);
|
elAuthName.addEventListener('input', onAuthNameChange);
|
||||||
onAuthNameChange();
|
onAuthNameChange();
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ async function initRepoPullRequestMergeForm(box: HTMLElement) {
|
|||||||
view.mount(el);
|
view.mount(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeScripts(elem: HTMLElement) {
|
function executeScripts(elem: Element) {
|
||||||
for (const oldScript of elem.querySelectorAll('script')) {
|
for (const oldScript of elem.querySelectorAll('script')) {
|
||||||
// TODO: that's the only way to load the data for the merge form. In the future
|
// TODO: that's the only way to load the data for the merge form. In the future
|
||||||
// we need to completely decouple the page data and embedded script
|
// we need to completely decouple the page data and embedded script
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {isDarkTheme, parseDom} from '../utils.ts';
|
import {isDarkTheme} from '../utils.ts';
|
||||||
import {displayError} from './common.ts';
|
import {displayError} from './common.ts';
|
||||||
import {createElementFromAttrs, createElementFromHTML, queryElems} from '../utils/dom.ts';
|
import {createElementFromAttrs, createElementFromHTML, queryElems} from '../utils/dom.ts';
|
||||||
import {html, htmlRaw} from '../utils/html.ts';
|
import {html, htmlRaw} from '../utils/html.ts';
|
||||||
@@ -81,7 +81,7 @@ async function loadMermaid(needElkRender: boolean) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function initMermaidViewController(viewController: HTMLElement, dragElement: SVGSVGElement) {
|
function initMermaidViewController(viewController: Element, dragElement: SVGSVGElement) {
|
||||||
let inited = false, isDragging = false;
|
let inited = false, isDragging = false;
|
||||||
let currentScale = 1, initLeft = 0, lastLeft = 0, lastTop = 0, lastPageX = 0, lastPageY = 0;
|
let currentScale = 1, initLeft = 0, lastLeft = 0, lastTop = 0, lastPageX = 0, lastPageY = 0;
|
||||||
|
|
||||||
@@ -201,8 +201,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
|
|||||||
try {
|
try {
|
||||||
// render the mermaid diagram to svg text, and parse it to a DOM node
|
// render the mermaid diagram to svg text, and parse it to a DOM node
|
||||||
const {svg: svgText, bindFunctions} = await mermaid.render('mermaid', source, parentContainer);
|
const {svg: svgText, bindFunctions} = await mermaid.render('mermaid', source, parentContainer);
|
||||||
const svgDoc = parseDom(svgText, 'image/svg+xml');
|
const svgNode = createElementFromHTML<SVGSVGElement>(svgText);
|
||||||
const svgNode = (svgDoc.documentElement as unknown) as SVGSVGElement;
|
|
||||||
|
|
||||||
const viewControllerHtml = html`
|
const viewControllerHtml = html`
|
||||||
<div class="view-controller auto-hide-control flex-text-block">
|
<div class="view-controller auto-hide-control flex-text-block">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
dirname, basename, extname, isObject, stripTags, parseIssueHref,
|
dirname, basename, extname, isObject, stripTags, parseIssueHref,
|
||||||
parseUrl, translateMonth, translateDay, blobToDataURI,
|
parseUrl, translateMonth, translateDay, blobToDataURI,
|
||||||
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
|
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
|
||||||
urlQueryEscape,
|
|
||||||
} from './utils.ts';
|
} from './utils.ts';
|
||||||
|
|
||||||
test('dirname', () => {
|
test('dirname', () => {
|
||||||
@@ -34,12 +33,6 @@ test('stripTags', () => {
|
|||||||
expect(stripTags('<a>test</a>')).toEqual('test');
|
expect(stripTags('<a>test</a>')).toEqual('test');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('urlQueryEscape', () => {
|
|
||||||
const input = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
|
||||||
const expected = '%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~';
|
|
||||||
expect(urlQueryEscape(input)).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseIssueHref', () => {
|
test('parseIssueHref', () => {
|
||||||
expect(parseIssueHref('/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
expect(parseIssueHref('/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||||
expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
|
expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
|
||||||
|
|||||||
@@ -51,15 +51,6 @@ export function stripTags(text: string): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function urlQueryEscape(s: string) {
|
|
||||||
// See "TestQueryEscape" in backend
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
|
||||||
return encodeURIComponent(s).replace(
|
|
||||||
/[!'()*]/g,
|
|
||||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseIssueHref(href: string): IssuePathInfo {
|
export function parseIssueHref(href: string): IssuePathInfo {
|
||||||
// FIXME: it should use pathname and trim the appSubUrl ahead
|
// FIXME: it should use pathname and trim the appSubUrl ahead
|
||||||
const path = (href || '').replace(/[#?].*$/, '');
|
const path = (href || '').replace(/[#?].*$/, '');
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export function isElemVisible(el: HTMLElement): boolean {
|
|||||||
return Boolean(!el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none');
|
return Boolean(!el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
|
export function createElementFromHTML<T extends Element>(htmlString: string): T {
|
||||||
htmlString = htmlString.trim();
|
htmlString = htmlString.trim();
|
||||||
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
|
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
|
||||||
// eslint-disable-next-line github/unescaped-html-literal
|
// eslint-disable-next-line github/unescaped-html-literal
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import {linkifyURLs, pathEscapeSegments, toOriginUrl} from './url.ts';
|
import {linkifyURLs, pathEscape, pathEscapeSegments, toOriginUrl, urlQueryEscape} from './url.ts';
|
||||||
|
|
||||||
test('pathEscapeSegments', () => {
|
describe('escape', () => {
|
||||||
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
|
const queryNonAscii = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
||||||
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
|
test('urlQueryEscape', () => {
|
||||||
|
const expected = '+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~';
|
||||||
|
expect(urlQueryEscape(queryNonAscii)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathEscape', () => {
|
||||||
|
const expected = '%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~';
|
||||||
|
expect(pathEscape(queryNonAscii)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathEscapeSegments', () => {
|
||||||
|
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
|
||||||
|
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
|
||||||
|
expect(pathEscapeSegments('a/b+c')).toEqual('a/b+c');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('linkifyURLs', () => {
|
test('linkifyURLs', () => {
|
||||||
|
|||||||
+29
-1
@@ -1,5 +1,33 @@
|
|||||||
|
export function urlQueryEscape(s: string) {
|
||||||
|
// See "TestQueryEscape" in backend
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||||
|
return encodeURIComponent(s).replace(
|
||||||
|
/[!'()*]/g,
|
||||||
|
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
).replaceAll('%20', '+');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathEscape(s: string): string {
|
||||||
|
// See "TestPathEscape" in backend
|
||||||
|
return encodeURIComponent(s).replace(
|
||||||
|
/[!'()*]/g,
|
||||||
|
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
).replaceAll(/%(\w\w)/g, (v) => {
|
||||||
|
switch (v) {
|
||||||
|
case '%24': return '$';
|
||||||
|
case '%26': return '&';
|
||||||
|
case '%2B': return '+';
|
||||||
|
case '%3A': return ':';
|
||||||
|
case '%3D': return '=';
|
||||||
|
case '%40': return '@';
|
||||||
|
default: return v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function pathEscapeSegments(s: string): string {
|
export function pathEscapeSegments(s: string): string {
|
||||||
return s.split('/').map(encodeURIComponent).join('/');
|
// The same as backend's PathEscapeSegments
|
||||||
|
return s.split('/').map(pathEscape).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match HTML tags (to skip) or URLs (to linkify) in HTML content
|
// Match HTML tags (to skip) or URLs (to linkify) in HTML content
|
||||||
|
|||||||
Reference in New Issue
Block a user