Compare commits

...

30 Commits

Author SHA1 Message Date
Lunny Xiao
486d274be6 Add missing changelog (#35079)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-15 12:08:13 +08:00
Giteabot
ab3d2a944c Fix form property assignment edge case (#35073) (#35078)
Backport #35073 by wxiaoguang

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-15 09:40:50 +08:00
wxiaoguang
12bfa9e83d Improve submodule relative path handling (#35056) (#35075)
Backport #35056
2025-07-14 17:26:16 +00:00
Giteabot
dd661e92df Fix incorrect comment diff hunk parsing, fix github asset ID nil panic (#35046) (#35055)
Backport #35046 by lunny

* Fix missing the first char when parsing diff hunk header
* Fix #35040
* Fix #35049

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-12 15:12:02 +08:00
Giteabot
0b31272c7e Fix updating user visibility (#35036) (#35044)
Backport #35036 by @lunny

Fix #35030

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-11 02:44:06 +00:00
Giteabot
ec0c418719 Support base64-encoded agit push options (#35037) (#35041)
Backport #35037 by wxiaoguang

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-10 18:56:43 +00:00
Giteabot
6dc19fc29a Make submodule link work with relative path (#35034) (#35038)
Backport #35034 by wxiaoguang

Fix #35033

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-10 18:20:14 +00:00
Giteabot
9f1baa7d18 Fix bug when displaying git user avatar in commits list (#35006)
A quick fix for #34991

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-10 08:46:44 -07:00
wxiaoguang
e13deb7a16 Fix API response for swagger spec (#35029)
Co-authored-by: Scion <scion@studiowhy.net>
2025-07-10 15:27:34 +08:00
Lunny Xiao
e5c1b8b632 Add changelog for 1.24.3 (#34975) 2025-07-10 03:21:01 +00:00
Lunny Xiao
e931b62f33 Start automerge check again after the conflict check and the schedule (#35002)
Fix #34988
Backport #34989 

Co-authored-by: posativ

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-10 02:37:15 +00:00
wxiaoguang
81ee93e5bc Fix repo settings and protocol log problems (#35012) (#35013)
Backport #35012
2025-07-09 17:20:15 +00:00
ChristopherHX
053f9186bc Fix the response format for actions/workflows. (#35009) (#35016)
Backport #35009

This PR fixes the response format for the OpenAPI Spec of
`ActionsListRepositoryWorkflows`.
It was specified in the OpenAPI spec as returning a `[]*ActionWorkflow`,
but it actually should return a `api.ActionWorkflowResponse`.

The test already expects an `api.ActionWorkflowResponse` like expected.

Co-authored-by: Scion <Filiecs2@gmail.com>
2025-07-09 18:18:40 +02:00
wxiaoguang
68fcdb6122 Fix project images scroll (#34971) (#34972) 2025-07-07 00:30:43 +08:00
Giteabot
14ca309c39 Mark old reviews as stale on agit pr updates (#34933) (#34965)
Backport #34933 by @dcermak

Fixes: https://github.com/go-gitea/gitea/issues/34134

Co-authored-by: Dan Čermák <dan.cermak@posteo.net>
2025-07-05 20:33:26 -07:00
wxiaoguang
4aba42519d Fix git graph page (#34948) (#34949) 2025-07-04 15:33:17 +00:00
Giteabot
9adf175df0 Don't send trigger for a pending review's comment create/update/delete (#34928) (#34939)
Backport #34928 by lunny

Fix #18846 
Fix #34924

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-03 03:01:17 +00:00
wxiaoguang
c3fa2a8729 Fix issue filter (#34914) (#34915)
Backport #34914
2025-07-03 09:45:17 +08:00
Giteabot
89dfed32e0 support the open-icon of folder (#34168) (#34896)
Backport #34168 by @kerwin612

Co-authored-by: Kerwin Bryant <kerwin612@qq.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-30 13:55:24 +02:00
Giteabot
d5062d0c27 docs: fix typo in pull request merge warning message text (#34899) (#34903)
Backport #34899 by @Pavanipogula

### Description

This PR fixes two typos in the pull request merge command warning
message.

- "can not" → "cannot"
- "was not enable" → "is not enabled."

### File Updated
- `options/locale/locale_en-US.ini` (line 1972)

### Related Discussion
https://github.com/go-gitea/gitea/issues/34893

Co-authored-by: Pavanipogula <51442511+Pavanipogula@users.noreply.github.com>
2025-06-29 18:26:15 -07:00
Giteabot
90e9e79232 Optimize flex layout of release attachment area (#34885) (#34886)
Backport #34885 by kerwin612

Co-authored-by: Kerwin Bryant <kerwin612@qq.com>
2025-06-28 09:10:41 +08:00
Giteabot
c6467edcb1 Fix the issue of abnormal interface when there is no issue-item on the project page (#34791) (#34880)
Backport #34791 by @kerwin612

Co-authored-by: Kerwin Bryant <kerwin612@qq.com>
2025-06-26 23:18:02 -07:00
wxiaoguang
5d5b695527 Skip updating timestamp when sync branch (#34875) 2025-06-26 17:59:06 -07:00
Giteabot
0af7a7b79f Fix some log and UI problems (#34863) (#34868)
Backport #34863 by @wxiaoguang

Remove the misleading error log, fix #34738

Make the "search" input auto-focused, fix #34807

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-25 22:08:30 +03:00
Giteabot
9339661078 Fix archive API (#34853) (#34857)
Backport #34853 by wxiaoguang

Fix #34852

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-25 07:20:42 +00:00
Giteabot
1e69f085d6 Ignore force pushes for changed files in a PR review (#34837) (#34843)
Backport #34837 by delvh

Fixes #34832

Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-24 16:41:07 +00:00
Giteabot
0bfccd8ecf Fix SSH LFS timeout (#34838) (#34842)
Backport #34838 by wxiaoguang

Fix #34834

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-24 16:15:03 +00:00
Giteabot
534b9b35dd Fix job status aggregation logic (#34823) (#34835)
Backport #34823 by nienjiuntai

Co-authored-by: JIUN-TAI NIEN <44364165+nienjiuntai@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-24 23:23:16 +08:00
Giteabot
dbadc59b56 Fix team permissions (#34827) (#34836)
Backport #34827 by wxiaoguang

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-24 14:17:14 +00:00
Zettat123
a57e2c4bc3 Fix required contexts and commit status matching bug (#34815) (#34829)
Backport #34815

Fix #34504

Since one required context can match more than one commit statuses, we
should not directly compare the lengths of `requiredCommitStatuses` and
`requiredContexts`
2025-06-24 07:15:25 +08:00
99 changed files with 1121 additions and 447 deletions

View File

@@ -4,6 +4,38 @@ 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.24.3](https://github.com/go-gitea/gitea/releases/tag/1.24.3) - 2025-07-15
* BUGFIXES
* Fix form property assignment edge case (#35073) (#35078)
* Improve submodule relative path handling (#35056) (#35075)
* Fix incorrect comment diff hunk parsing, fix github asset ID nil panic (#35046) (#35055)
* Fix updating user visibility (#35036) (#35044)
* Support base64-encoded agit push options (#35037) (#35041)
* Make submodule link work with relative path (#35034) (#35038)
* Fix bug when displaying git user avatar in commits list (#35006)
* Fix API response for swagger spec (#35029)
* Start automerge check again after the conflict check and the schedule (#34988) (#35002)
* Fix the response format for actions/workflows (#35009) (#35016)
* Fix repo settings and protocol log problems (#35012) (#35013)
* Fix project images scroll (#34971) (#34972)
* Mark old reviews as stale on agit pr updates (#34933) (#34965)
* Fix git graph page (#34948) (#34949)
* Don't send trigger for a pending review's comment create/update/delete (#34928) (#34939)
* Fix some log and UI problems (#34863) (#34868)
* Fix archive API (#34853) (#34857)
* Ignore force pushes for changed files in a PR review (#34837) (#34843)
* Fix SSH LFS timeout (#34838) (#34842)
* Fix team permissions (#34827) (#34836)
* Fix job status aggregation logic (#34823) (#34835)
* Fix issue filter (#34914) (#34915)
* Fix typo in pull request merge warning message text (#34899) (#34903)
* Support the open-icon of folder (#34168) (#34896)
* Optimize flex layout of release attachment area (#34885) (#34886)
* Fix the issue of abnormal interface when there is no issue-item on the project page (#34791) (#34880)
* Skip updating timestamp when sync branch (#34875)
* Fix required contexts and commit status matching bug (#34815) (#34829)
## [1.24.2](https://github.com/go-gitea/gitea/releases/tag/1.24.2) - 2025-06-20 ## [1.24.2](https://github.com/go-gitea/gitea/releases/tag/1.24.2) - 2025-06-20
* BUGFIXES * BUGFIXES

View File

@@ -185,10 +185,10 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
return StatusSuccess return StatusSuccess
case hasCancelled: case hasCancelled:
return StatusCancelled return StatusCancelled
case hasFailure:
return StatusFailure
case hasRunning: case hasRunning:
return StatusRunning return StatusRunning
case hasFailure:
return StatusFailure
case hasWaiting: case hasWaiting:
return StatusWaiting return StatusWaiting
case hasBlocked: case hasBlocked:

View File

@@ -58,14 +58,14 @@ func TestAggregateJobStatus(t *testing.T) {
{[]Status{StatusCancelled, StatusRunning}, StatusCancelled}, {[]Status{StatusCancelled, StatusRunning}, StatusCancelled},
{[]Status{StatusCancelled, StatusBlocked}, StatusCancelled}, {[]Status{StatusCancelled, StatusBlocked}, StatusCancelled},
// failure with other status, fail fast // failure with other status, usually fail fast, but "running" wins to match GitHub's behavior
// Should "running" win? Maybe no: old code does make "running" win, but GitHub does fail fast. // another reason that we can't make "failure" wins over "running": it would cause a weird behavior that user cannot cancel a workflow or get current running workflows correctly by filter after a job fail.
{[]Status{StatusFailure}, StatusFailure}, {[]Status{StatusFailure}, StatusFailure},
{[]Status{StatusFailure, StatusSuccess}, StatusFailure}, {[]Status{StatusFailure, StatusSuccess}, StatusFailure},
{[]Status{StatusFailure, StatusSkipped}, StatusFailure}, {[]Status{StatusFailure, StatusSkipped}, StatusFailure},
{[]Status{StatusFailure, StatusCancelled}, StatusCancelled}, {[]Status{StatusFailure, StatusCancelled}, StatusCancelled},
{[]Status{StatusFailure, StatusWaiting}, StatusFailure}, {[]Status{StatusFailure, StatusWaiting}, StatusFailure},
{[]Status{StatusFailure, StatusRunning}, StatusFailure}, {[]Status{StatusFailure, StatusRunning}, StatusRunning},
{[]Status{StatusFailure, StatusBlocked}, StatusFailure}, {[]Status{StatusFailure, StatusBlocked}, StatusFailure},
// skipped with other status // skipped with other status

View File

@@ -91,7 +91,7 @@ func AddGPGKey(ctx context.Context, ownerID int64, content, token, signature str
signer, err = openpgp.CheckArmoredDetachedSignature(ekeys, strings.NewReader(token+"\r\n"), strings.NewReader(signature), nil) signer, err = openpgp.CheckArmoredDetachedSignature(ekeys, strings.NewReader(token+"\r\n"), strings.NewReader(signature), nil)
} }
if err != nil { if err != nil {
log.Error("Unable to validate token signature. Error: %v", err) log.Debug("AddGPGKey CheckArmoredDetachedSignature failed: %v", err)
return nil, ErrGPGInvalidTokenSignature{ return nil, ErrGPGInvalidTokenSignature{
ID: ekeys[0].PrimaryKey.KeyIdString(), ID: ekeys[0].PrimaryKey.KeyIdString(),
Wrapped: err, Wrapped: err,

View File

@@ -85,7 +85,7 @@ func VerifyGPGKey(ctx context.Context, ownerID int64, keyID, token, signature st
} }
if signer == nil { if signer == nil {
log.Error("Unable to validate token signature. Error: %v", err) log.Debug("VerifyGPGKey failed: no signer")
return "", ErrGPGInvalidTokenSignature{ return "", ErrGPGInvalidTokenSignature{
ID: key.KeyID, ID: key.KeyID,
} }

View File

@@ -35,7 +35,7 @@ func VerifySSHKey(ctx context.Context, ownerID int64, fingerprint, token, signat
// edge case for Windows based shells that will add CR LF if piped to ssh-keygen command // edge case for Windows based shells that will add CR LF if piped to ssh-keygen command
// see https://github.com/PowerShell/PowerShell/issues/5974 // see https://github.com/PowerShell/PowerShell/issues/5974
if sshsig.Verify(strings.NewReader(token+"\r\n"), []byte(signature), []byte(key.Content), "gitea") != nil { if sshsig.Verify(strings.NewReader(token+"\r\n"), []byte(signature), []byte(key.Content), "gitea") != nil {
log.Error("Unable to validate token signature. Error: %v", err) log.Debug("VerifySSHKey sshsig.Verify failed: %v", err)
return "", ErrSSHInvalidTokenSignature{ return "", ErrSSHInvalidTokenSignature{
Fingerprint: key.Fingerprint, Fingerprint: key.Fingerprint,
} }

View File

@@ -518,7 +518,7 @@ func updateTeamWhitelist(ctx context.Context, repo *repo_model.Repository, curre
return currentWhitelist, nil return currentWhitelist, nil
} }
teams, err := organization.GetTeamsWithAccessToRepo(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead) teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil { if err != nil {
return nil, fmt.Errorf("GetTeamsWithAccessToRepo [org_id: %d, repo_id: %d]: %v", repo.OwnerID, repo.ID, err) return nil, fmt.Errorf("GetTeamsWithAccessToRepo [org_id: %d, repo_id: %d]: %v", repo.OwnerID, repo.ID, err)
} }

View File

@@ -719,7 +719,8 @@ func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository
return nil return nil
} }
func (c *Comment) loadReview(ctx context.Context) (err error) { // LoadReview loads the associated review
func (c *Comment) LoadReview(ctx context.Context) (err error) {
if c.ReviewID == 0 { if c.ReviewID == 0 {
return nil return nil
} }
@@ -736,11 +737,6 @@ func (c *Comment) loadReview(ctx context.Context) (err error) {
return nil return nil
} }
// LoadReview loads the associated review
func (c *Comment) LoadReview(ctx context.Context) error {
return c.loadReview(ctx)
}
// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. // DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes.
func (c *Comment) DiffSide() string { func (c *Comment) DiffSide() string {
if c.Line < 0 { if c.Line < 0 {
@@ -860,7 +856,7 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
} }
if comment.ReviewID != 0 { if comment.ReviewID != 0 {
if comment.Review == nil { if comment.Review == nil {
if err := comment.loadReview(ctx); err != nil { if err := comment.LoadReview(ctx); err != nil {
return err return err
} }
} }

View File

@@ -602,8 +602,3 @@ func getUserTeamIDsQueryBuilder(orgID, userID int64) *builder.Builder {
"team_user.uid": userID, "team_user.uid": userID,
}) })
} }
// TeamsWithAccessToRepo returns all teams that have given access level to the repository.
func (org *Organization) TeamsWithAccessToRepo(ctx context.Context, repoID int64, mode perm.AccessMode) ([]*Team, error) {
return GetTeamsWithAccessToRepo(ctx, org.ID, repoID, mode)
}

View File

@@ -9,6 +9,8 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
"xorm.io/builder"
) )
// TeamRepo represents an team-repository relation. // TeamRepo represents an team-repository relation.
@@ -48,26 +50,27 @@ func RemoveTeamRepo(ctx context.Context, teamID, repoID int64) error {
return err return err
} }
// GetTeamsWithAccessToRepo returns all teams in an organization that have given access level to the repository. // GetTeamsWithAccessToAnyRepoUnit returns all teams in an organization that have given access level to the repository special unit.
func GetTeamsWithAccessToRepo(ctx context.Context, orgID, repoID int64, mode perm.AccessMode) ([]*Team, error) { // This function is only used for finding some teams that can be used as branch protection allowlist or reviewers, it isn't really used for access control.
// FIXME: TEAM-UNIT-PERMISSION this logic is not complete, search the fixme keyword to see more details
func GetTeamsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) ([]*Team, error) {
teams := make([]*Team, 0, 5) teams := make([]*Team, 0, 5)
return teams, db.GetEngine(ctx).Where("team.authorize >= ?", mode).
Join("INNER", "team_repo", "team_repo.team_id = team.id").
And("team_repo.org_id = ?", orgID).
And("team_repo.repo_id = ?", repoID).
OrderBy("name").
Find(&teams)
}
// GetTeamsWithAccessToRepoUnit returns all teams in an organization that have given access level to the repository special unit. sub := builder.Select("team_id").From("team_unit").
func GetTeamsWithAccessToRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type) ([]*Team, error) { Where(builder.Expr("team_unit.team_id = team.id")).
teams := make([]*Team, 0, 5) And(builder.In("team_unit.type", append([]unit.Type{unitType}, unitTypesMore...))).
return teams, db.GetEngine(ctx).Where("team_unit.access_mode >= ?", mode). And(builder.Expr("team_unit.access_mode >= ?", mode))
err := db.GetEngine(ctx).
Join("INNER", "team_repo", "team_repo.team_id = team.id"). Join("INNER", "team_repo", "team_repo.team_id = team.id").
Join("INNER", "team_unit", "team_unit.team_id = team.id").
And("team_repo.org_id = ?", orgID). And("team_repo.org_id = ?", orgID).
And("team_repo.repo_id = ?", repoID). And("team_repo.repo_id = ?", repoID).
And("team_unit.type = ?", unitType). And(builder.Or(
builder.Expr("team.authorize >= ?", mode),
builder.In("team.id", sub),
)).
OrderBy("name"). OrderBy("name").
Find(&teams) Find(&teams)
return teams, err
} }

View File

@@ -22,7 +22,7 @@ func TestGetTeamsWithAccessToRepoUnit(t *testing.T) {
org41 := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 41}) org41 := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 41})
repo61 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 61}) repo61 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 61})
teams, err := organization.GetTeamsWithAccessToRepoUnit(db.DefaultContext, org41.ID, repo61.ID, perm.AccessModeRead, unit.TypePullRequests) teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(db.DefaultContext, org41.ID, repo61.ID, perm.AccessModeRead, unit.TypePullRequests)
assert.NoError(t, err) assert.NoError(t, err)
if assert.Len(t, teams, 2) { if assert.Len(t, teams, 2) {
assert.EqualValues(t, 21, teams[0].ID) assert.EqualValues(t, 21, teams[0].ID)

View File

@@ -42,6 +42,7 @@ func (p *Permission) IsAdmin() bool {
// HasAnyUnitAccess returns true if the user might have at least one access mode to any unit of this repository. // HasAnyUnitAccess returns true if the user might have at least one access mode to any unit of this repository.
// It doesn't count the "public(anonymous/everyone) access mode". // It doesn't count the "public(anonymous/everyone) access mode".
// TODO: most calls to this function should be replaced with `HasAnyUnitAccessOrPublicAccess`
func (p *Permission) HasAnyUnitAccess() bool { func (p *Permission) HasAnyUnitAccess() bool {
for _, v := range p.unitsMode { for _, v := range p.unitsMode {
if v >= perm_model.AccessModeRead { if v >= perm_model.AccessModeRead {
@@ -267,7 +268,6 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
perm.units = repo.Units perm.units = repo.Units
// anonymous user visit private repo. // anonymous user visit private repo.
// TODO: anonymous user visit public unit of private repo???
if user == nil && repo.IsPrivate { if user == nil && repo.IsPrivate {
perm.AccessMode = perm_model.AccessModeNone perm.AccessMode = perm_model.AccessModeNone
return perm, nil return perm, nil
@@ -286,7 +286,8 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
} }
// Prevent strangers from checking out public repo of private organization/users // Prevent strangers from checking out public repo of private organization/users
// Allow user if they are collaborator of a repo within a private user or a private organization but not a member of the organization itself // Allow user if they are a collaborator of a repo within a private user or a private organization but not a member of the organization itself
// TODO: rename it to "IsOwnerVisibleToDoer"
if !organization.HasOrgOrUserVisible(ctx, repo.Owner, user) && !isCollaborator { if !organization.HasOrgOrUserVisible(ctx, repo.Owner, user) && !isCollaborator {
perm.AccessMode = perm_model.AccessModeNone perm.AccessMode = perm_model.AccessModeNone
return perm, nil return perm, nil
@@ -304,7 +305,7 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
return perm, nil return perm, nil
} }
// plain user // plain user TODO: this check should be replaced, only need to check collaborator access mode
perm.AccessMode, err = accessLevel(ctx, user, repo) perm.AccessMode, err = accessLevel(ctx, user, repo)
if err != nil { if err != nil {
return perm, err return perm, err
@@ -314,6 +315,19 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
return perm, nil return perm, nil
} }
// now: the owner is visible to doer, if the repo is public, then the min access mode is read
minAccessMode := util.Iif(!repo.IsPrivate && !user.IsRestricted, perm_model.AccessModeRead, perm_model.AccessModeNone)
perm.AccessMode = max(perm.AccessMode, minAccessMode)
// get units mode from teams
teams, err := organization.GetUserRepoTeams(ctx, repo.OwnerID, user.ID, repo.ID)
if err != nil {
return perm, err
}
if len(teams) == 0 {
return perm, nil
}
perm.unitsMode = make(map[unit.Type]perm_model.AccessMode) perm.unitsMode = make(map[unit.Type]perm_model.AccessMode)
// Collaborators on organization // Collaborators on organization
@@ -323,12 +337,6 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
} }
} }
// get units mode from teams
teams, err := organization.GetUserRepoTeams(ctx, repo.OwnerID, user.ID, repo.ID)
if err != nil {
return perm, err
}
// if user in an owner team // if user in an owner team
for _, team := range teams { for _, team := range teams {
if team.HasAdminAccess() { if team.HasAdminAccess() {
@@ -339,19 +347,12 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
} }
for _, u := range repo.Units { for _, u := range repo.Units {
var found bool
for _, team := range teams { for _, team := range teams {
unitAccessMode := minAccessMode
if teamMode, exist := team.UnitAccessModeEx(ctx, u.Type); exist { if teamMode, exist := team.UnitAccessModeEx(ctx, u.Type); exist {
perm.unitsMode[u.Type] = max(perm.unitsMode[u.Type], teamMode) unitAccessMode = max(perm.unitsMode[u.Type], unitAccessMode, teamMode)
found = true
}
}
// for a public repo on an organization, a non-restricted user has read permission on non-team defined units.
if !found && !repo.IsPrivate && !user.IsRestricted {
if _, ok := perm.unitsMode[u.Type]; !ok {
perm.unitsMode[u.Type] = perm_model.AccessModeRead
} }
perm.unitsMode[u.Type] = unitAccessMode
} }
} }

View File

@@ -6,12 +6,16 @@ package access
import ( import (
"testing" "testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
perm_model "code.gitea.io/gitea/models/perm" perm_model "code.gitea.io/gitea/models/perm"
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/models/unittest"
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 TestHasAnyUnitAccess(t *testing.T) { func TestHasAnyUnitAccess(t *testing.T) {
@@ -152,3 +156,45 @@ func TestUnitAccessMode(t *testing.T) {
} }
assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeWiki), "has unit, and map, use map") assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeWiki), "has unit, and map, use map")
} }
func TestGetUserRepoPermission(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
repo32 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32}) // org public repo
require.NoError(t, repo32.LoadOwner(ctx))
require.True(t, repo32.Owner.IsOrganization())
require.NoError(t, db.TruncateBeans(ctx, &organization.Team{}, &organization.TeamUser{}, &organization.TeamRepo{}, &organization.TeamUnit{}))
org := repo32.Owner
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
team := &organization.Team{OrgID: org.ID, LowerName: "test_team"}
require.NoError(t, db.Insert(ctx, team))
t.Run("DoerInTeamWithNoRepo", func(t *testing.T) {
require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: org.ID, TeamID: team.ID, UID: user.ID}))
perm, err := GetUserRepoPermission(ctx, repo32, user)
require.NoError(t, err)
assert.Equal(t, perm_model.AccessModeRead, perm.AccessMode)
assert.Nil(t, perm.unitsMode) // doer in the team, but has no access to the repo
})
require.NoError(t, db.Insert(ctx, &organization.TeamRepo{OrgID: org.ID, TeamID: team.ID, RepoID: repo32.ID}))
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: org.ID, TeamID: team.ID, Type: unit.TypeCode, AccessMode: perm_model.AccessModeNone}))
t.Run("DoerWithTeamUnitAccessNone", func(t *testing.T) {
perm, err := GetUserRepoPermission(ctx, repo32, user)
require.NoError(t, err)
assert.Equal(t, perm_model.AccessModeRead, perm.AccessMode)
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
})
require.NoError(t, db.TruncateBeans(ctx, &organization.TeamUnit{}))
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: org.ID, TeamID: team.ID, Type: unit.TypeCode, AccessMode: perm_model.AccessModeWrite}))
t.Run("DoerWithTeamUnitAccessWrite", func(t *testing.T) {
perm, err := GetUserRepoPermission(ctx, repo32, user)
require.NoError(t, err)
assert.Equal(t, perm_model.AccessModeRead, perm.AccessMode)
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
})
}

View File

@@ -5,12 +5,14 @@ package pull
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"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"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
) )
// AutoMerge represents a pull request scheduled for merging when checks succeed // AutoMerge represents a pull request scheduled for merging when checks succeed
@@ -76,7 +78,10 @@ func GetScheduledMergeByPullID(ctx context.Context, pullID int64) (bool, *AutoMe
return false, nil, err return false, nil, err
} }
doer, err := user_model.GetUserByID(ctx, scheduledPRM.DoerID) doer, err := user_model.GetPossibleUserByID(ctx, scheduledPRM.DoerID)
if errors.Is(err, util.ErrNotExist) {
doer, err = user_model.NewGhostUser(), nil
}
if err != nil { if err != nil {
return false, nil, err return false, nil, err
} }

View File

@@ -1176,12 +1176,14 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro
needCheckEmails := make(container.Set[string]) needCheckEmails := make(container.Set[string])
needCheckUserNames := make(container.Set[string]) needCheckUserNames := make(container.Set[string])
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
for _, email := range emails { for _, email := range emails {
if strings.HasSuffix(email, "@"+setting.Service.NoReplyAddress) { emailLower := strings.ToLower(email)
username := strings.TrimSuffix(email, "@"+setting.Service.NoReplyAddress) if noReplyUserNameLower, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
needCheckUserNames.Add(strings.ToLower(username)) needCheckUserNames.Add(noReplyUserNameLower)
needCheckEmails.Add(emailLower)
} else { } else {
needCheckEmails.Add(strings.ToLower(email)) needCheckEmails.Add(emailLower)
} }
} }

View File

@@ -85,6 +85,10 @@ func TestUserEmails(t *testing.T) {
testGetUserByEmail(t, c.Email, c.UID) testGetUserByEmail(t, c.Email, c.UID)
}) })
} }
t.Run("NoReplyConflict", func(t *testing.T) {
setting.Service.NoReplyAddress = "example.com"
testGetUserByEmail(t, "user1-2@example.COM", 1)
})
}) })
} }

View File

@@ -6,22 +6,26 @@ package fileicon
import ( import (
"html/template" "html/template"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/util"
) )
func BasicThemeIcon(entry *git.TreeEntry) template.HTML { func BasicEntryIconName(entry *EntryInfo) string {
svgName := "octicon-file" svgName := "octicon-file"
switch { switch {
case entry.IsLink(): case entry.EntryMode.IsLink():
svgName = "octicon-file-symlink-file" svgName = "octicon-file-symlink-file"
if te, err := entry.FollowLink(); err == nil && te.IsDir() { if entry.SymlinkToMode.IsDir() {
svgName = "octicon-file-directory-symlink" svgName = "octicon-file-directory-symlink"
} }
case entry.IsDir(): case entry.EntryMode.IsDir():
svgName = "octicon-file-directory-fill" svgName = util.Iif(entry.IsOpen, "octicon-file-directory-open-fill", "octicon-file-directory-fill")
case entry.IsSubModule(): case entry.EntryMode.IsSubModule():
svgName = "octicon-file-submodule" svgName = "octicon-file-submodule"
} }
return svg.RenderHTML(svgName) return svgName
}
func BasicEntryIconHTML(entry *EntryInfo) template.HTML {
return svg.RenderHTML(BasicEntryIconName(entry))
} }

31
modules/fileicon/entry.go Normal file
View File

@@ -0,0 +1,31 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package fileicon
import "code.gitea.io/gitea/modules/git"
type EntryInfo struct {
FullName string
EntryMode git.EntryMode
SymlinkToMode git.EntryMode
IsOpen bool
}
func EntryInfoFromGitTreeEntry(gitEntry *git.TreeEntry) *EntryInfo {
ret := &EntryInfo{FullName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
if gitEntry.IsLink() {
if te, err := gitEntry.FollowLink(); err == nil && te.IsDir() {
ret.SymlinkToMode = te.Mode()
}
}
return ret
}
func EntryInfoFolder() *EntryInfo {
return &EntryInfo{EntryMode: git.EntryModeTree}
}
func EntryInfoFolderOpen() *EntryInfo {
return &EntryInfo{EntryMode: git.EntryModeTree, IsOpen: true}
}

View File

@@ -9,11 +9,12 @@ import (
"strings" "strings"
"sync" "sync"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/options" "code.gitea.io/gitea/modules/options"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/util"
) )
type materialIconRulesData struct { type materialIconRulesData struct {
@@ -69,41 +70,51 @@ func (m *MaterialIconProvider) renderFileIconSVG(p *RenderedIconPool, name, svg,
} }
svgID := "svg-mfi-" + name svgID := "svg-mfi-" + name
svgCommonAttrs := `class="svg git-entry-icon ` + extraClass + `" width="16" height="16" aria-hidden="true"` svgCommonAttrs := `class="svg git-entry-icon ` + extraClass + `" width="16" height="16" aria-hidden="true"`
svgHTML := template.HTML(`<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:])
if p == nil {
return svgHTML
}
if p.IconSVGs[svgID] == "" { if p.IconSVGs[svgID] == "" {
p.IconSVGs[svgID] = template.HTML(`<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:]) p.IconSVGs[svgID] = svgHTML
} }
return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></svg>`) return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></svg>`)
} }
func (m *MaterialIconProvider) FileIcon(p *RenderedIconPool, entry *git.TreeEntry) template.HTML { func (m *MaterialIconProvider) EntryIconHTML(p *RenderedIconPool, entry *EntryInfo) template.HTML {
if m.rules == nil { if m.rules == nil {
return BasicThemeIcon(entry) return BasicEntryIconHTML(entry)
} }
if entry.IsLink() { if entry.EntryMode.IsLink() {
if te, err := entry.FollowLink(); err == nil && te.IsDir() { if entry.SymlinkToMode.IsDir() {
// keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work // keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work
return svg.RenderHTML("material-folder-symlink", 16, "octicon-file-directory-symlink") return svg.RenderHTML("material-folder-symlink", 16, "octicon-file-directory-symlink")
} }
return svg.RenderHTML("octicon-file-symlink-file") // TODO: find some better icons for them return svg.RenderHTML("octicon-file-symlink-file") // TODO: find some better icons for them
} }
name := m.findIconNameByGit(entry) name := m.FindIconName(entry)
// the material icon pack's "folder" icon doesn't look good, so use our built-in one iconSVG := m.svgs[name]
// keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work if iconSVG == "" {
if iconSVG, ok := m.svgs[name]; ok && name != "folder" && iconSVG != "" { name = "file"
// keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work if entry.EntryMode.IsDir() {
extraClass := "octicon-file" name = util.Iif(entry.IsOpen, "folder-open", "folder")
switch { }
case entry.IsDir(): iconSVG = m.svgs[name]
extraClass = "octicon-file-directory-fill" if iconSVG == "" {
case entry.IsSubModule(): setting.PanicInDevOrTesting("missing file icon for %s", name)
extraClass = "octicon-file-submodule"
} }
return m.renderFileIconSVG(p, name, iconSVG, extraClass)
} }
// TODO: use an interface or wrapper for git.Entry to make the code testable.
return BasicThemeIcon(entry) // keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work
extraClass := "octicon-file"
switch {
case entry.EntryMode.IsDir():
extraClass = BasicEntryIconName(entry)
case entry.EntryMode.IsSubModule():
extraClass = "octicon-file-submodule"
}
return m.renderFileIconSVG(p, name, iconSVG, extraClass)
} }
func (m *MaterialIconProvider) findIconNameWithLangID(s string) string { func (m *MaterialIconProvider) findIconNameWithLangID(s string) string {
@@ -118,13 +129,17 @@ func (m *MaterialIconProvider) findIconNameWithLangID(s string) string {
return "" return ""
} }
func (m *MaterialIconProvider) FindIconName(name string, isDir bool) string { func (m *MaterialIconProvider) FindIconName(entry *EntryInfo) string {
fileNameLower := strings.ToLower(path.Base(name)) if entry.EntryMode.IsSubModule() {
if isDir { return "folder-git"
}
fileNameLower := strings.ToLower(path.Base(entry.FullName))
if entry.EntryMode.IsDir() {
if s, ok := m.rules.FolderNames[fileNameLower]; ok { if s, ok := m.rules.FolderNames[fileNameLower]; ok {
return s return s
} }
return "folder" return util.Iif(entry.IsOpen, "folder-open", "folder")
} }
if s, ok := m.rules.FileNames[fileNameLower]; ok { if s, ok := m.rules.FileNames[fileNameLower]; ok {
@@ -146,10 +161,3 @@ func (m *MaterialIconProvider) FindIconName(name string, isDir bool) string {
return "file" return "file"
} }
func (m *MaterialIconProvider) findIconNameByGit(entry *git.TreeEntry) string {
if entry.IsSubModule() {
return "folder-git"
}
return m.FindIconName(entry.Name(), entry.IsDir())
}

View File

@@ -8,6 +8,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -19,8 +20,8 @@ func TestMain(m *testing.M) {
func TestFindIconName(t *testing.T) { func TestFindIconName(t *testing.T) {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
p := fileicon.DefaultMaterialIconProvider() p := fileicon.DefaultMaterialIconProvider()
assert.Equal(t, "php", p.FindIconName("foo.php", false)) assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.php", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "php", p.FindIconName("foo.PHP", false)) assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.PHP", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "javascript", p.FindIconName("foo.js", false)) assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.js", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "visualstudio", p.FindIconName("foo.vba", false)) assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.vba", EntryMode: git.EntryModeBlob}))
} }

View File

@@ -7,7 +7,6 @@ import (
"html/template" "html/template"
"strings" "strings"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
) )
@@ -34,19 +33,9 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML {
return template.HTML(sb.String()) return template.HTML(sb.String())
} }
// TODO: use an interface or struct to replace "*git.TreeEntry", to decouple the fileicon module from git module func RenderEntryIconHTML(renderedIconPool *RenderedIconPool, entry *EntryInfo) template.HTML {
func RenderEntryIcon(renderedIconPool *RenderedIconPool, entry *git.TreeEntry) template.HTML {
if setting.UI.FileIconTheme == "material" { if setting.UI.FileIconTheme == "material" {
return DefaultMaterialIconProvider().FileIcon(renderedIconPool, entry) return DefaultMaterialIconProvider().EntryIconHTML(renderedIconPool, entry)
} }
return BasicThemeIcon(entry) return BasicEntryIconHTML(entry)
}
func RenderEntryIconOpen(renderedIconPool *RenderedIconPool, entry *git.TreeEntry) template.HTML {
// TODO: add "open icon" support
if setting.UI.FileIconTheme == "material" {
return DefaultMaterialIconProvider().FileIcon(renderedIconPool, entry)
}
return BasicThemeIcon(entry)
} }

View File

@@ -3,9 +3,20 @@
package git package git
import "path"
// CommitInfo describes the first commit with the provided entry // CommitInfo describes the first commit with the provided entry
type CommitInfo struct { type CommitInfo struct {
Entry *TreeEntry Entry *TreeEntry
Commit *Commit Commit *Commit
SubmoduleFile *CommitSubmoduleFile SubmoduleFile *CommitSubmoduleFile
} }
func getCommitInfoSubmoduleFile(repoLink string, entry *TreeEntry, commit *Commit, treePathDir string) (*CommitSubmoduleFile, error) {
fullPath := path.Join(treePathDir, entry.Name())
submodule, err := commit.GetSubModule(fullPath)
if err != nil {
return nil, err
}
return NewCommitSubmoduleFile(repoLink, fullPath, submodule.URL, entry.ID.String()), nil
}

View File

@@ -16,7 +16,7 @@ import (
) )
// GetCommitsInfo gets information of all commits that are corresponding to these entries // GetCommitsInfo gets information of all commits that are corresponding to these entries
func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) { func (tes Entries) GetCommitsInfo(ctx context.Context, repoLink string, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) {
entryPaths := make([]string, len(tes)+1) entryPaths := make([]string, len(tes)+1)
// Get the commit for the treePath itself // Get the commit for the treePath itself
entryPaths[0] = "" entryPaths[0] = ""
@@ -71,22 +71,12 @@ func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath
commitsInfo[i].Commit = entryCommit commitsInfo[i].Commit = entryCommit
} }
// If the entry is a submodule add a submodule file for this // If the entry is a submodule, add a submodule file for this
if entry.IsSubModule() { if entry.IsSubModule() {
subModuleURL := "" commitsInfo[i].SubmoduleFile, err = getCommitInfoSubmoduleFile(repoLink, entry, commit, treePath)
var fullPath string if err != nil {
if len(treePath) > 0 {
fullPath = treePath + "/" + entry.Name()
} else {
fullPath = entry.Name()
}
if subModule, err := commit.GetSubModule(fullPath); err != nil {
return nil, nil, err return nil, nil, err
} else if subModule != nil {
subModuleURL = subModule.URL
} }
subModuleFile := NewCommitSubmoduleFile(subModuleURL, entry.ID.String())
commitsInfo[i].SubmoduleFile = subModuleFile
} }
} }

View File

@@ -16,7 +16,7 @@ import (
) )
// GetCommitsInfo gets information of all commits that are corresponding to these entries // GetCommitsInfo gets information of all commits that are corresponding to these entries
func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) { func (tes Entries) GetCommitsInfo(ctx context.Context, repoLink string, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) {
entryPaths := make([]string, len(tes)+1) entryPaths := make([]string, len(tes)+1)
// Get the commit for the treePath itself // Get the commit for the treePath itself
entryPaths[0] = "" entryPaths[0] = ""
@@ -65,22 +65,12 @@ func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath
log.Debug("missing commit for %s", entry.Name()) log.Debug("missing commit for %s", entry.Name())
} }
// If the entry is a submodule add a submodule file for this // If the entry is a submodule, add a submodule file for this
if entry.IsSubModule() { if entry.IsSubModule() {
subModuleURL := "" commitsInfo[i].SubmoduleFile, err = getCommitInfoSubmoduleFile(repoLink, entry, commit, treePath)
var fullPath string if err != nil {
if len(treePath) > 0 {
fullPath = treePath + "/" + entry.Name()
} else {
fullPath = entry.Name()
}
if subModule, err := commit.GetSubModule(fullPath); err != nil {
return nil, nil, err return nil, nil, err
} else if subModule != nil {
subModuleURL = subModule.URL
} }
subModuleFile := NewCommitSubmoduleFile(subModuleURL, entry.ID.String())
commitsInfo[i].SubmoduleFile = subModuleFile
} }
} }

View File

@@ -82,7 +82,7 @@ func testGetCommitsInfo(t *testing.T, repo1 *Repository) {
} }
// FIXME: Context.TODO() - if graceful has started we should use its Shutdown context otherwise use install signals in TestMain. // FIXME: Context.TODO() - if graceful has started we should use its Shutdown context otherwise use install signals in TestMain.
commitsInfo, treeCommit, err := entries.GetCommitsInfo(t.Context(), commit, testCase.Path) commitsInfo, treeCommit, err := entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, testCase.Path)
assert.NoError(t, err, "Unable to get commit information for entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err) assert.NoError(t, err, "Unable to get commit information for entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
if err != nil { if err != nil {
t.FailNow() t.FailNow()
@@ -159,7 +159,7 @@ func BenchmarkEntries_GetCommitsInfo(b *testing.B) {
b.ResetTimer() b.ResetTimer()
b.Run(benchmark.name, func(b *testing.B) { b.Run(benchmark.name, func(b *testing.B) {
for b.Loop() { for b.Loop() {
_, _, err := entries.GetCommitsInfo(b.Context(), commit, "") _, _, err := entries.GetCommitsInfo(b.Context(), "/any/repo-link", commit, "")
if err != nil { if err != nil {
b.Fatal(err) b.Fatal(err)
} }

View File

@@ -6,49 +6,61 @@ package git
import ( import (
"context" "context"
"path"
"strings"
giturl "code.gitea.io/gitea/modules/git/url" giturl "code.gitea.io/gitea/modules/git/url"
"code.gitea.io/gitea/modules/util"
) )
// CommitSubmoduleFile represents a file with submodule type. // CommitSubmoduleFile represents a file with submodule type.
type CommitSubmoduleFile struct { type CommitSubmoduleFile struct {
refURL string repoLink string
parsedURL *giturl.RepositoryURL fullPath string
parsed bool refURL string
refID string refID string
repoLink string
parsed bool
parsedTargetLink string
} }
// NewCommitSubmoduleFile create a new submodule file // NewCommitSubmoduleFile create a new submodule file
func NewCommitSubmoduleFile(refURL, refID string) *CommitSubmoduleFile { func NewCommitSubmoduleFile(repoLink, fullPath, refURL, refID string) *CommitSubmoduleFile {
return &CommitSubmoduleFile{refURL: refURL, refID: refID} return &CommitSubmoduleFile{repoLink: repoLink, fullPath: fullPath, refURL: refURL, refID: refID}
} }
func (sf *CommitSubmoduleFile) RefID() string { func (sf *CommitSubmoduleFile) RefID() string {
return sf.refID // this function is only used in templates return sf.refID
} }
// SubmoduleWebLink tries to make some web links for a submodule, it also works on "nil" receiver func (sf *CommitSubmoduleFile) getWebLinkInTargetRepo(ctx context.Context, moreLinkPath string) *SubmoduleWebLink {
func (sf *CommitSubmoduleFile) SubmoduleWebLink(ctx context.Context, optCommitID ...string) *SubmoduleWebLink {
if sf == nil { if sf == nil {
return nil return nil
} }
if strings.HasPrefix(sf.refURL, "../") {
targetLink := path.Join(sf.repoLink, path.Dir(sf.fullPath), sf.refURL)
return &SubmoduleWebLink{RepoWebLink: targetLink, CommitWebLink: targetLink + moreLinkPath}
}
if !sf.parsed { if !sf.parsed {
sf.parsed = true sf.parsed = true
parsedURL, err := giturl.ParseRepositoryURL(ctx, sf.refURL) parsedURL, err := giturl.ParseRepositoryURL(ctx, sf.refURL)
if err != nil { if err != nil {
return nil return nil
} }
sf.parsedURL = parsedURL sf.parsedTargetLink = giturl.MakeRepositoryWebLink(parsedURL)
sf.repoLink = giturl.MakeRepositoryWebLink(sf.parsedURL)
} }
var commitLink string return &SubmoduleWebLink{RepoWebLink: sf.parsedTargetLink, CommitWebLink: sf.parsedTargetLink + moreLinkPath}
if len(optCommitID) == 2 { }
commitLink = sf.repoLink + "/compare/" + optCommitID[0] + "..." + optCommitID[1]
} else if len(optCommitID) == 1 { // SubmoduleWebLinkTree tries to make the submodule's tree link in its own repo, it also works on "nil" receiver
commitLink = sf.repoLink + "/tree/" + optCommitID[0] func (sf *CommitSubmoduleFile) SubmoduleWebLinkTree(ctx context.Context, optCommitID ...string) *SubmoduleWebLink {
} else { if sf == nil {
commitLink = sf.repoLink + "/tree/" + sf.refID return nil
} }
return &SubmoduleWebLink{RepoWebLink: sf.repoLink, CommitWebLink: commitLink} return sf.getWebLinkInTargetRepo(ctx, "/tree/"+util.OptionalArg(optCommitID, sf.refID))
}
// SubmoduleWebLinkCompare tries to make the submodule's compare link in its own repo, it also works on "nil" receiver
func (sf *CommitSubmoduleFile) SubmoduleWebLinkCompare(ctx context.Context, commitID1, commitID2 string) *SubmoduleWebLink {
return sf.getWebLinkInTargetRepo(ctx, "/compare/"+commitID1+"..."+commitID2)
} }

View File

@@ -10,20 +10,29 @@ import (
) )
func TestCommitSubmoduleLink(t *testing.T) { func TestCommitSubmoduleLink(t *testing.T) {
sf := NewCommitSubmoduleFile("git@github.com:user/repo.git", "aaaa") assert.Nil(t, (*CommitSubmoduleFile)(nil).SubmoduleWebLinkTree(t.Context()))
assert.Nil(t, (*CommitSubmoduleFile)(nil).SubmoduleWebLinkCompare(t.Context(), "", ""))
wl := sf.SubmoduleWebLink(t.Context()) t.Run("GitHubRepo", func(t *testing.T) {
assert.Equal(t, "https://github.com/user/repo", wl.RepoWebLink) sf := NewCommitSubmoduleFile("/any/repo-link", "full-path", "git@github.com:user/repo.git", "aaaa")
assert.Equal(t, "https://github.com/user/repo/tree/aaaa", wl.CommitWebLink) wl := sf.SubmoduleWebLinkTree(t.Context())
assert.Equal(t, "https://github.com/user/repo", wl.RepoWebLink)
assert.Equal(t, "https://github.com/user/repo/tree/aaaa", wl.CommitWebLink)
wl = sf.SubmoduleWebLink(t.Context(), "1111") wl = sf.SubmoduleWebLinkCompare(t.Context(), "1111", "2222")
assert.Equal(t, "https://github.com/user/repo", wl.RepoWebLink) assert.Equal(t, "https://github.com/user/repo", wl.RepoWebLink)
assert.Equal(t, "https://github.com/user/repo/tree/1111", wl.CommitWebLink) assert.Equal(t, "https://github.com/user/repo/compare/1111...2222", wl.CommitWebLink)
})
wl = sf.SubmoduleWebLink(t.Context(), "1111", "2222") t.Run("RelativePath", func(t *testing.T) {
assert.Equal(t, "https://github.com/user/repo", wl.RepoWebLink) sf := NewCommitSubmoduleFile("/subpath/any/repo-home-link", "full-path", "../../user/repo", "aaaa")
assert.Equal(t, "https://github.com/user/repo/compare/1111...2222", wl.CommitWebLink) wl := sf.SubmoduleWebLinkTree(t.Context())
assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink)
assert.Equal(t, "/subpath/user/repo/tree/aaaa", wl.CommitWebLink)
wl = (*CommitSubmoduleFile)(nil).SubmoduleWebLink(t.Context()) sf = NewCommitSubmoduleFile("/subpath/any/repo-home-link", "dir/submodule", "../../../user/repo", "aaaa")
assert.Nil(t, wl) wl = sf.SubmoduleWebLinkCompare(t.Context(), "1111", "2222")
assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink)
assert.Equal(t, "/subpath/user/repo/compare/1111...2222", wl.CommitWebLink)
})
} }

View File

@@ -99,9 +99,9 @@ func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diff
return nil return nil
} }
// ParseDiffHunkString parse the diffhunk content and return // ParseDiffHunkString parse the diff hunk content and return
func ParseDiffHunkString(diffhunk string) (leftLine, leftHunk, rightLine, righHunk int) { func ParseDiffHunkString(diffHunk string) (leftLine, leftHunk, rightLine, rightHunk int) {
ss := strings.Split(diffhunk, "@@") ss := strings.Split(diffHunk, "@@")
ranges := strings.Split(ss[1][1:], " ") ranges := strings.Split(ss[1][1:], " ")
leftRange := strings.Split(ranges[0], ",") leftRange := strings.Split(ranges[0], ",")
leftLine, _ = strconv.Atoi(leftRange[0][1:]) leftLine, _ = strconv.Atoi(leftRange[0][1:])
@@ -112,14 +112,19 @@ func ParseDiffHunkString(diffhunk string) (leftLine, leftHunk, rightLine, righHu
rightRange := strings.Split(ranges[1], ",") rightRange := strings.Split(ranges[1], ",")
rightLine, _ = strconv.Atoi(rightRange[0]) rightLine, _ = strconv.Atoi(rightRange[0])
if len(rightRange) > 1 { if len(rightRange) > 1 {
righHunk, _ = strconv.Atoi(rightRange[1]) rightHunk, _ = strconv.Atoi(rightRange[1])
} }
} else { } else {
log.Debug("Parse line number failed: %v", diffhunk) log.Debug("Parse line number failed: %v", diffHunk)
rightLine = leftLine rightLine = leftLine
righHunk = leftHunk rightHunk = leftHunk
} }
return leftLine, leftHunk, rightLine, righHunk if rightLine == 0 {
// FIXME: GIT-DIFF-CUT-BUG search this tag to see details
// this is only a hacky patch, the rightLine&rightHunk might still be incorrect in some cases.
rightLine++
}
return leftLine, leftHunk, rightLine, rightHunk
} }
// Example: @@ -1,8 +1,9 @@ => [..., 1, 8, 1, 9] // Example: @@ -1,8 +1,9 @@ => [..., 1, 8, 1, 9]
@@ -270,6 +275,12 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
oldNumOfLines++ oldNumOfLines++
} }
} }
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
// FIXME: GIT-DIFF-CUT-BUG But there is a bug in CutDiffAroundLine, then the "Patch" stored in the comment model becomes "@@ -1,1 +0,4 @@"
// It may generate incorrect results for difference cases, for example: delete 2 line add 1 line, delete 2 line add 2 line etc, need to double check.
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
// construct the new hunk header // construct the new hunk header
newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@", newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
oldBegin, oldNumOfLines, newBegin, newNumOfLines) oldBegin, oldNumOfLines, newBegin, newNumOfLines)

View File

@@ -30,6 +30,31 @@ func (e EntryMode) String() string {
return strconv.FormatInt(int64(e), 8) return strconv.FormatInt(int64(e), 8)
} }
// IsSubModule if the entry is a sub module
func (e EntryMode) IsSubModule() bool {
return e == EntryModeCommit
}
// IsDir if the entry is a sub dir
func (e EntryMode) IsDir() bool {
return e == EntryModeTree
}
// IsLink if the entry is a symlink
func (e EntryMode) IsLink() bool {
return e == EntryModeSymlink
}
// IsRegular if the entry is a regular file
func (e EntryMode) IsRegular() bool {
return e == EntryModeBlob
}
// IsExecutable if the entry is an executable file (not necessarily binary)
func (e EntryMode) IsExecutable() bool {
return e == EntryModeExec
}
func ParseEntryMode(mode string) (EntryMode, error) { func ParseEntryMode(mode string) (EntryMode, error) {
switch mode { switch mode {
case "000000": case "000000":

View File

@@ -59,27 +59,27 @@ func (te *TreeEntry) Size() int64 {
// IsSubModule if the entry is a sub module // IsSubModule if the entry is a sub module
func (te *TreeEntry) IsSubModule() bool { func (te *TreeEntry) IsSubModule() bool {
return te.entryMode == EntryModeCommit return te.entryMode.IsSubModule()
} }
// IsDir if the entry is a sub dir // IsDir if the entry is a sub dir
func (te *TreeEntry) IsDir() bool { func (te *TreeEntry) IsDir() bool {
return te.entryMode == EntryModeTree return te.entryMode.IsDir()
} }
// IsLink if the entry is a symlink // IsLink if the entry is a symlink
func (te *TreeEntry) IsLink() bool { func (te *TreeEntry) IsLink() bool {
return te.entryMode == EntryModeSymlink return te.entryMode.IsLink()
} }
// IsRegular if the entry is a regular file // IsRegular if the entry is a regular file
func (te *TreeEntry) IsRegular() bool { func (te *TreeEntry) IsRegular() bool {
return te.entryMode == EntryModeBlob return te.entryMode.IsRegular()
} }
// IsExecutable if the entry is an executable file (not necessarily binary) // IsExecutable if the entry is an executable file (not necessarily binary)
func (te *TreeEntry) IsExecutable() bool { func (te *TreeEntry) IsExecutable() bool {
return te.entryMode == EntryModeExec return te.entryMode.IsExecutable()
} }
// Blob returns the blob object the entry // Blob returns the blob object the entry

View File

@@ -132,6 +132,7 @@ func newInternalRequestLFS(ctx context.Context, internalURL, method string, head
return nil return nil
} }
req := private.NewInternalRequest(ctx, internalURL, method) req := private.NewInternalRequest(ctx, internalURL, method)
req.SetReadWriteTimeout(0)
for k, v := range headers { for k, v := range headers {
req.Header(k, v) req.Header(k, v)
} }

View File

@@ -22,6 +22,13 @@ func FromPtr[T any](v *T) Option[T] {
return Some(*v) return Some(*v)
} }
func FromMapLookup[K comparable, V any](m map[K]V, k K) Option[V] {
if v, ok := m[k]; ok {
return Some(v)
}
return None[V]()
}
func FromNonDefault[T comparable](v T) Option[T] { func FromNonDefault[T comparable](v T) Option[T] {
var zero T var zero T
if v == zero { if v == zero {

View File

@@ -56,6 +56,12 @@ func TestOption(t *testing.T) {
opt3 := optional.FromNonDefault(1) opt3 := optional.FromNonDefault(1)
assert.True(t, opt3.Has()) assert.True(t, opt3.Has())
assert.Equal(t, int(1), opt3.Value()) assert.Equal(t, int(1), opt3.Value())
opt4 := optional.FromMapLookup(map[string]int{"a": 1}, "a")
assert.True(t, opt4.Has())
assert.Equal(t, 1, opt4.Value())
opt4 = optional.FromMapLookup(map[string]int{"a": 1}, "b")
assert.False(t, opt4.Has())
} }
func Test_ParseBool(t *testing.T) { func Test_ParseBool(t *testing.T) {

View File

@@ -41,11 +41,14 @@ func SyncRepoBranchesWithRepo(ctx context.Context, repo *repo_model.Repository,
if err != nil { if err != nil {
return 0, fmt.Errorf("GetObjectFormat: %w", err) return 0, fmt.Errorf("GetObjectFormat: %w", err)
} }
_, err = db.GetEngine(ctx).ID(repo.ID).Update(&repo_model.Repository{ObjectFormatName: objFmt.Name()})
if err != nil { if repo.ObjectFormatName != objFmt.Name() {
return 0, fmt.Errorf("UpdateRepository: %w", err) repo.ObjectFormatName = objFmt.Name()
_, err = db.GetEngine(ctx).ID(repo.ID).NoAutoTime().Update(&repo_model.Repository{ObjectFormatName: objFmt.Name()})
if err != nil {
return 0, fmt.Errorf("UpdateRepository: %w", err)
}
} }
repo.ObjectFormatName = objFmt.Name() // keep consistent with db
allBranches := container.Set[string]{} allBranches := container.Set[string]{}
{ {

View File

@@ -275,7 +275,7 @@ func loadServerFrom(rootCfg ConfigProvider) {
HTTPAddr = filepath.Join(AppWorkPath, HTTPAddr) HTTPAddr = filepath.Join(AppWorkPath, HTTPAddr)
} }
default: default:
log.Fatal("Invalid PROTOCOL %q", Protocol) log.Fatal("Invalid PROTOCOL %q", protocolCfg)
} }
UseProxyProtocol = sec.Key("USE_PROXY_PROTOCOL").MustBool(false) UseProxyProtocol = sec.Key("USE_PROXY_PROTOCOL").MustBool(false)
ProxyProtocolTLSBridging = sec.Key("PROXY_PROTOCOL_TLS_BRIDGING").MustBool(false) ProxyProtocolTLSBridging = sec.Key("PROXY_PROTOCOL_TLS_BRIDGING").MustBool(false)

View File

@@ -57,7 +57,7 @@ type Repository struct {
Private bool `json:"private"` Private bool `json:"private"`
Fork bool `json:"fork"` Fork bool `json:"fork"`
Template bool `json:"template"` Template bool `json:"template"`
Parent *Repository `json:"parent"` Parent *Repository `json:"parent,omitempty"`
Mirror bool `json:"mirror"` Mirror bool `json:"mirror"`
Size int `json:"size"` Size int `json:"size"`
Language string `json:"language"` Language string `json:"language"`
@@ -112,7 +112,7 @@ type Repository struct {
ObjectFormatName string `json:"object_format_name"` ObjectFormatName string `json:"object_format_name"`
// swagger:strfmt date-time // swagger:strfmt date-time
MirrorUpdated time.Time `json:"mirror_updated,omitempty"` MirrorUpdated time.Time `json:"mirror_updated,omitempty"`
RepoTransfer *RepoTransfer `json:"repo_transfer"` RepoTransfer *RepoTransfer `json:"repo_transfer,omitempty"`
Topics []string `json:"topics"` Topics []string `json:"topics"`
Licenses []string `json:"licenses"` Licenses []string `json:"licenses"`
} }

View File

@@ -1957,7 +1957,7 @@ pulls.cmd_instruction_checkout_title = Checkout
pulls.cmd_instruction_checkout_desc = From your project repository, check out a new branch and test the changes. pulls.cmd_instruction_checkout_desc = From your project repository, check out a new branch and test the changes.
pulls.cmd_instruction_merge_title = Merge pulls.cmd_instruction_merge_title = Merge
pulls.cmd_instruction_merge_desc = Merge the changes and update on Gitea. pulls.cmd_instruction_merge_desc = Merge the changes and update on Gitea.
pulls.cmd_instruction_merge_warning = Warning: This operation can not merge pull request because "autodetect manual merge" was not enable pulls.cmd_instruction_merge_warning = Warning: This operation cannot merge pull request because "autodetect manual merge" is not enabled.
pulls.clear_merge_message = Clear merge message pulls.clear_merge_message = Clear merge message
pulls.clear_merge_message_hint = Clearing the merge message will only remove the commit message content and keep generated git trailers such as "Co-Authored-By …". pulls.clear_merge_message_hint = Clearing the merge message will only remove the commit message content and keep generated git trailers such as "Co-Authored-By …".
@@ -2153,6 +2153,7 @@ settings.collaboration.write = Write
settings.collaboration.read = Read settings.collaboration.read = Read
settings.collaboration.owner = Owner settings.collaboration.owner = Owner
settings.collaboration.undefined = Undefined settings.collaboration.undefined = Undefined
settings.collaboration.per_unit = Unit Permissions
settings.hooks = Webhooks settings.hooks = Webhooks
settings.githooks = Git Hooks settings.githooks = Git Hooks
settings.basic_settings = Basic Settings settings.basic_settings = Basic Settings

View File

@@ -240,7 +240,7 @@ func EditUser(ctx *context.APIContext) {
Description: optional.FromPtr(form.Description), Description: optional.FromPtr(form.Description),
IsActive: optional.FromPtr(form.Active), IsActive: optional.FromPtr(form.Active),
IsAdmin: user_service.UpdateOptionFieldFromPtr(form.Admin), IsAdmin: user_service.UpdateOptionFieldFromPtr(form.Admin),
Visibility: optional.FromNonDefault(api.VisibilityModes[form.Visibility]), Visibility: optional.FromMapLookup(api.VisibilityModes, form.Visibility),
AllowGitHook: optional.FromPtr(form.AllowGitHook), AllowGitHook: optional.FromPtr(form.AllowGitHook),
AllowImportLocal: optional.FromPtr(form.AllowImportLocal), AllowImportLocal: optional.FromPtr(form.AllowImportLocal),
MaxRepoCreation: optional.FromPtr(form.MaxRepoCreation), MaxRepoCreation: optional.FromPtr(form.MaxRepoCreation),

View File

@@ -228,7 +228,7 @@ func repoAssignment() func(ctx *context.APIContext) {
} }
} }
if !ctx.Repo.Permission.HasAnyUnitAccess() { if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() {
ctx.APIErrorNotFound() ctx.APIErrorNotFound()
return return
} }
@@ -1241,7 +1241,7 @@ func Routes() *web.Router {
}, reqToken()) }, reqToken())
m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile) m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile)
m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS) m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS)
m.Get("/archive/*", reqRepoReader(unit.TypeCode), repo.GetArchive) m.Methods("HEAD,GET", "/archive/*", reqRepoReader(unit.TypeCode), repo.GetArchive)
m.Combo("/forks").Get(repo.ListForks). m.Combo("/forks").Get(repo.ListForks).
Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork) Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork)
m.Post("/merge-upstream", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeCode), bind(api.MergeUpstreamRequest{}), repo.MergeUpstream) m.Post("/merge-upstream", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeCode), bind(api.MergeUpstreamRequest{}), repo.MergeUpstream)
@@ -1445,7 +1445,7 @@ func Routes() *web.Router {
m.Delete("", repo.DeleteAvatar) m.Delete("", repo.DeleteAvatar)
}, reqAdmin(), reqToken()) }, reqAdmin(), reqToken())
m.Get("/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive) m.Methods("HEAD,GET", "/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive)
}, repoAssignment(), checkTokenPublicOnly()) }, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))

View File

@@ -393,7 +393,7 @@ func Edit(ctx *context.APIContext) {
Description: optional.Some(form.Description), Description: optional.Some(form.Description),
Website: optional.Some(form.Website), Website: optional.Some(form.Website),
Location: optional.Some(form.Location), Location: optional.Some(form.Location),
Visibility: optional.FromNonDefault(api.VisibilityModes[form.Visibility]), Visibility: optional.FromMapLookup(api.VisibilityModes, form.Visibility),
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess), RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
} }
if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil { if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil {

View File

@@ -44,5 +44,5 @@ type swaggerResponseActionWorkflow struct {
// swagger:response ActionWorkflowList // swagger:response ActionWorkflowList
type swaggerResponseActionWorkflowList struct { type swaggerResponseActionWorkflowList struct {
// in:body // in:body
Body []api.ActionWorkflow `json:"body"` Body api.ActionWorkflowResponse `json:"body"`
} }

View File

@@ -283,11 +283,22 @@ func NewTeam(ctx *context.Context) {
} }
// FIXME: TEAM-UNIT-PERMISSION: this design is not right, when a new unit is added in the future, // FIXME: TEAM-UNIT-PERMISSION: this design is not right, when a new unit is added in the future,
// admin team won't inherit the correct admin permission for the new unit. // The existing teams won't inherit the correct admin permission for the new unit.
// The full history is like this:
// 1. There was only "team", no "team unit", so "team.authorize" was used to determine the team permission.
// 2. Later, "team unit" was introduced, then the usage of "team.authorize" became inconsistent, and causes various bugs.
// - Sometimes, "team.authorize" is used to determine the team permission, e.g. admin, owner
// - Sometimes, "team unit" is used not really used and "team unit" is used.
// - Some functions like `GetTeamsWithAccessToAnyRepoUnit` use both.
//
// 3. After introducing "team unit" and more unclear changes, it becomes difficult to maintain team permissions.
// - Org owner need to click the permission for each unit, but can't just set a common "write" permission for all units.
//
// Ideally, "team.authorize=write" should mean the team has write access to all units including newly (future) added ones.
func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode { func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode {
unitPerms := make(map[unit_model.Type]perm.AccessMode) unitPerms := make(map[unit_model.Type]perm.AccessMode)
for _, ut := range unit_model.AllRepoUnitTypes { for _, ut := range unit_model.AllRepoUnitTypes {
// Default accessmode is none // Default access mode is none
unitPerms[ut] = perm.AccessModeNone unitPerms[ut] = perm.AccessModeNone
v, ok := forms[fmt.Sprintf("unit_%d", ut)] v, ok := forms[fmt.Sprintf("unit_%d", ut)]

View File

@@ -21,6 +21,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@@ -168,10 +169,13 @@ func Graph(ctx *context.Context) {
ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name
divOnly := ctx.FormBool("div-only")
queryParams := ctx.Req.URL.Query()
queryParams.Del("div-only")
paginator := context.NewPagination(int(graphCommitsCount), setting.UI.GraphMaxCommitNum, page, 5) paginator := context.NewPagination(int(graphCommitsCount), setting.UI.GraphMaxCommitNum, page, 5)
paginator.AddParamFromRequest(ctx.Req) paginator.AddParamFromQuery(queryParams)
ctx.Data["Page"] = paginator ctx.Data["Page"] = paginator
if ctx.FormBool("div-only") { if divOnly {
ctx.HTML(http.StatusOK, tplGraphDiv) ctx.HTML(http.StatusOK, tplGraphDiv)
return return
} }
@@ -313,7 +317,7 @@ func Diff(ctx *context.Context) {
maxLines, maxFiles = -1, -1 maxLines, maxFiles = -1, -1
} }
diff, err := gitdiff.GetDiffForRender(ctx, gitRepo, &gitdiff.DiffOptions{ diff, err := gitdiff.GetDiffForRender(ctx, ctx.Repo.RepoLink, gitRepo, &gitdiff.DiffOptions{
AfterCommitID: commitID, AfterCommitID: commitID,
SkipTo: ctx.FormString("skip-to"), SkipTo: ctx.FormString("skip-to"),
MaxLines: maxLines, MaxLines: maxLines,
@@ -369,7 +373,11 @@ func Diff(ctx *context.Context) {
return return
} }
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, nil) renderedIconPool := fileicon.NewRenderedIconPool()
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil)
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
} }
statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll) statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll)

View File

@@ -26,6 +26,7 @@ import (
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
csv_module "code.gitea.io/gitea/modules/csv" csv_module "code.gitea.io/gitea/modules/csv"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@@ -613,7 +614,7 @@ func PrepareCompareDiff(
fileOnly := ctx.FormBool("file-only") fileOnly := ctx.FormBool("file-only")
diff, err := gitdiff.GetDiffForRender(ctx, ci.HeadGitRepo, diff, err := gitdiff.GetDiffForRender(ctx, ci.HeadRepo.Link(), ci.HeadGitRepo,
&gitdiff.DiffOptions{ &gitdiff.DiffOptions{
BeforeCommitID: beforeCommitID, BeforeCommitID: beforeCommitID,
AfterCommitID: headCommitID, AfterCommitID: headCommitID,
@@ -644,7 +645,11 @@ func PrepareCompareDiff(
return false return false
} }
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, nil) renderedIconPool := fileicon.NewRenderedIconPool()
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil)
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
} }
headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID) headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID)

View File

@@ -24,6 +24,7 @@ import (
"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"
"code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
@@ -749,7 +750,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
diffOptions.BeforeCommitID = startCommitID diffOptions.BeforeCommitID = startCommitID
} }
diff, err := gitdiff.GetDiffForRender(ctx, gitRepo, diffOptions, files...) diff, err := gitdiff.GetDiffForRender(ctx, ctx.Repo.RepoLink, gitRepo, diffOptions, files...)
if err != nil { if err != nil {
ctx.ServerError("GetDiff", err) ctx.ServerError("GetDiff", err)
return return
@@ -824,7 +825,12 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
if reviewState != nil { if reviewState != nil {
filesViewedState = reviewState.UpdatedFiles filesViewedState = reviewState.UpdatedFiles
} }
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, filesViewedState)
renderedIconPool := fileicon.NewRenderedIconPool()
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, filesViewedState)
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
} }
ctx.Data["Diff"] = diff ctx.Data["Diff"] = diff

View File

@@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access" 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/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
@@ -89,7 +90,7 @@ func SettingsProtectedBranch(c *context.Context) {
c.Data["recent_status_checks"] = contexts c.Data["recent_status_checks"] = contexts
if c.Repo.Owner.IsOrganization() { if c.Repo.Owner.IsOrganization() {
teams, err := organization.OrgFromUser(c.Repo.Owner).TeamsWithAccessToRepo(c, c.Repo.Repository.ID, perm.AccessModeRead) teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(c, c.Repo.Owner.ID, c.Repo.Repository.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil { if err != nil {
c.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) c.ServerError("Repo.Owner.TeamsWithAccessToRepo", err)
return return

View File

@@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
@@ -156,7 +157,7 @@ func setTagsContext(ctx *context.Context) error {
ctx.Data["Users"] = users ctx.Data["Users"] = users
if ctx.Repo.Owner.IsOrganization() { if ctx.Repo.Owner.IsOrganization() {
teams, err := organization.OrgFromUser(ctx.Repo.Owner).TeamsWithAccessToRepo(ctx, ctx.Repo.Repository.ID, perm.AccessModeRead) teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, ctx.Repo.Owner.ID, ctx.Repo.Repository.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil { if err != nil {
ctx.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) ctx.ServerError("Repo.Owner.TeamsWithAccessToRepo", err)
return err return err

View File

@@ -4,6 +4,7 @@
package repo package repo
import ( import (
"html/template"
"net/http" "net/http"
"strings" "strings"
@@ -67,7 +68,7 @@ type WebDiffFileItem struct {
EntryMode string EntryMode string
IsViewed bool IsViewed bool
Children []*WebDiffFileItem Children []*WebDiffFileItem
// TODO: add icon support in the future FileIcon template.HTML
} }
// WebDiffFileTree is used by frontend, check the field names in frontend before changing // WebDiffFileTree is used by frontend, check the field names in frontend before changing
@@ -77,7 +78,7 @@ type WebDiffFileTree struct {
// transformDiffTreeForWeb transforms a gitdiff.DiffTree into a WebDiffFileTree for Web UI rendering // transformDiffTreeForWeb transforms a gitdiff.DiffTree into a WebDiffFileTree for Web UI rendering
// it also takes a map of file names to their viewed state, which is used to mark files as viewed // it also takes a map of file names to their viewed state, which is used to mark files as viewed
func transformDiffTreeForWeb(diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) (dft WebDiffFileTree) { func transformDiffTreeForWeb(renderedIconPool *fileicon.RenderedIconPool, diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) (dft WebDiffFileTree) {
dirNodes := map[string]*WebDiffFileItem{"": &dft.TreeRoot} dirNodes := map[string]*WebDiffFileItem{"": &dft.TreeRoot}
addItem := func(item *WebDiffFileItem) { addItem := func(item *WebDiffFileItem) {
var parentPath string var parentPath string
@@ -110,6 +111,7 @@ func transformDiffTreeForWeb(diffTree *gitdiff.DiffTree, filesViewedState map[st
item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status} item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status}
item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed
item.NameHash = git.HashFilePathForWebUI(item.FullName) item.NameHash = git.HashFilePathForWebUI(item.FullName)
item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{FullName: file.HeadPath, EntryMode: file.HeadMode})
switch file.HeadMode { switch file.HeadMode {
case git.EntryModeTree: case git.EntryModeTree:
@@ -141,7 +143,7 @@ func transformDiffTreeForWeb(diffTree *gitdiff.DiffTree, filesViewedState map[st
func TreeViewNodes(ctx *context.Context) { func TreeViewNodes(ctx *context.Context) {
renderedIconPool := fileicon.NewRenderedIconPool() renderedIconPool := fileicon.NewRenderedIconPool()
results, err := files_service.GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormString("sub_path")) results, err := files_service.GetTreeViewNodes(ctx, ctx.Repo.RepoLink, renderedIconPool, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormString("sub_path"))
if err != nil { if err != nil {
ctx.ServerError("GetTreeViewNodes", err) ctx.ServerError("GetTreeViewNodes", err)
return return

View File

@@ -4,9 +4,11 @@
package repo package repo
import ( import (
"html/template"
"testing" "testing"
pull_model "code.gitea.io/gitea/models/pull" pull_model "code.gitea.io/gitea/models/pull"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/services/gitdiff" "code.gitea.io/gitea/services/gitdiff"
@@ -14,7 +16,8 @@ import (
) )
func TestTransformDiffTreeForWeb(t *testing.T) { func TestTransformDiffTreeForWeb(t *testing.T) {
ret := transformDiffTreeForWeb(&gitdiff.DiffTree{Files: []*gitdiff.DiffTreeRecord{ renderedIconPool := fileicon.NewRenderedIconPool()
ret := transformDiffTreeForWeb(renderedIconPool, &gitdiff.DiffTree{Files: []*gitdiff.DiffTreeRecord{
{ {
Status: "changed", Status: "changed",
HeadPath: "dir-a/dir-a-x/file-deep", HeadPath: "dir-a/dir-a-x/file-deep",
@@ -29,6 +32,9 @@ func TestTransformDiffTreeForWeb(t *testing.T) {
"dir-a/dir-a-x/file-deep": pull_model.Viewed, "dir-a/dir-a-x/file-deep": pull_model.Viewed,
}) })
mockIconForFile := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
}
assert.Equal(t, WebDiffFileTree{ assert.Equal(t, WebDiffFileTree{
TreeRoot: WebDiffFileItem{ TreeRoot: WebDiffFileItem{
Children: []*WebDiffFileItem{ Children: []*WebDiffFileItem{
@@ -44,6 +50,7 @@ func TestTransformDiffTreeForWeb(t *testing.T) {
NameHash: "4acf7eef1c943a09e9f754e93ff190db8583236b", NameHash: "4acf7eef1c943a09e9f754e93ff190db8583236b",
DiffStatus: "changed", DiffStatus: "changed",
IsViewed: true, IsViewed: true,
FileIcon: mockIconForFile(`svg-mfi-file`),
}, },
}, },
}, },
@@ -53,6 +60,7 @@ func TestTransformDiffTreeForWeb(t *testing.T) {
FullName: "file1", FullName: "file1",
NameHash: "60b27f004e454aca81b0480209cce5081ec52390", NameHash: "60b27f004e454aca81b0480209cce5081ec52390",
DiffStatus: "added", DiffStatus: "added",
FileIcon: mockIconForFile(`svg-mfi-file`),
}, },
}, },
}, },

View File

@@ -257,8 +257,9 @@ func prepareDirectoryFileIcons(ctx *context.Context, files []git.CommitInfo) {
renderedIconPool := fileicon.NewRenderedIconPool() renderedIconPool := fileicon.NewRenderedIconPool()
fileIcons := map[string]template.HTML{} fileIcons := map[string]template.HTML{}
for _, f := range files { for _, f := range files {
fileIcons[f.Entry.Name()] = fileicon.RenderEntryIcon(renderedIconPool, f.Entry) fileIcons[f.Entry.Name()] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFromGitTreeEntry(f.Entry))
} }
fileIcons[".."] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.Data["FileIcons"] = fileIcons ctx.Data["FileIcons"] = fileIcons
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML() ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
} }
@@ -298,7 +299,7 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
defer cancel() defer cancel()
} }
files, latestCommit, err := allEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.Commit, ctx.Repo.TreePath) files, latestCommit, err := allEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.RepoLink, ctx.Repo.Commit, ctx.Repo.TreePath)
if err != nil { if err != nil {
ctx.ServerError("GetCommitsInfo", err) ctx.ServerError("GetCommitsInfo", err)
return nil return nil

View File

@@ -6,7 +6,6 @@ package repo
import ( import (
"bytes" "bytes"
gocontext "context"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -666,7 +665,7 @@ func WikiPages(ctx *context.Context) {
} }
allEntries.CustomSort(base.NaturalSortLess) allEntries.CustomSort(base.NaturalSortLess)
entries, _, err := allEntries.GetCommitsInfo(gocontext.Context(ctx), commit, treePath) entries, _, err := allEntries.GetCommitsInfo(ctx, ctx.Repo.RepoLink, commit, treePath)
if err != nil { if err != nil {
ctx.ServerError("GetCommitsInfo", err) ctx.ServerError("GetCommitsInfo", err)
return return

View File

@@ -5,10 +5,12 @@ package agit
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"os" "os"
"strings" "strings"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@@ -17,17 +19,30 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/private"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
) )
func parseAgitPushOptionValue(s string) string {
if base64Value, ok := strings.CutPrefix(s, "{base64}"); ok {
decoded, err := base64.StdEncoding.DecodeString(base64Value)
return util.Iif(err == nil, string(decoded), s)
}
return s
}
// ProcReceive handle proc receive work // ProcReceive handle proc receive work
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) { func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) {
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs)) results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush) forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush)
topicBranch := opts.GitPushOptions["topic"] topicBranch := opts.GitPushOptions["topic"]
title := strings.TrimSpace(opts.GitPushOptions["title"])
description := strings.TrimSpace(opts.GitPushOptions["description"]) // some options are base64-encoded with "{base64}" prefix if they contain new lines
// other agit push options like "issue", "reviewer" and "cc" are not supported
title := parseAgitPushOptionValue(opts.GitPushOptions["title"])
description := parseAgitPushOptionValue(opts.GitPushOptions["description"])
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
userName := strings.ToLower(opts.UserName) userName := strings.ToLower(opts.UserName)
@@ -199,11 +214,37 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
} }
} }
// Store old commit ID for review staleness checking
oldHeadCommitID := pr.HeadCommitID
pr.HeadCommitID = opts.NewCommitIDs[i] pr.HeadCommitID = opts.NewCommitIDs[i]
if err = pull_service.UpdateRef(ctx, pr); err != nil { if err = pull_service.UpdateRef(ctx, pr); err != nil {
return nil, fmt.Errorf("failed to update pull ref. Error: %w", err) return nil, fmt.Errorf("failed to update pull ref. Error: %w", err)
} }
// Mark existing reviews as stale when PR content changes (same as regular GitHub flow)
if oldHeadCommitID != opts.NewCommitIDs[i] {
if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); err != nil {
log.Error("MarkReviewsAsStale: %v", err)
}
// Dismiss all approval reviews if protected branch rule item enabled
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
if err != nil {
log.Error("GetFirstMatchProtectedBranchRule: %v", err)
}
if pb != nil && pb.DismissStaleApprovals {
if err := pull_service.DismissApprovalReviews(ctx, pusher, pr); err != nil {
log.Error("DismissApprovalReviews: %v", err)
}
}
// Mark reviews for the new commit as not stale
if err := issues_model.MarkReviewsAsNotStale(ctx, pr.IssueID, opts.NewCommitIDs[i]); err != nil {
log.Error("MarkReviewsAsNotStale: %v", err)
}
}
pull_service.StartPullRequestCheckImmediately(ctx, pr) pull_service.StartPullRequestCheckImmediately(ctx, pr)
err = pr.LoadIssue(ctx) err = pr.LoadIssue(ctx)
if err != nil { if err != nil {

View File

@@ -0,0 +1,16 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package agit
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseAgitPushOptionValue(t *testing.T) {
assert.Equal(t, "a", parseAgitPushOptionValue("a"))
assert.Equal(t, "a", parseAgitPushOptionValue("{base64}YQ=="))
assert.Equal(t, "{base64}invalid value", parseAgitPushOptionValue("{base64}invalid value"))
}

View File

@@ -22,23 +22,21 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/services/automergequeue"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
) )
// prAutoMergeQueue represents a queue to handle update pull request tests
var prAutoMergeQueue *queue.WorkerPoolQueue[string]
// Init runs the task queue to that handles auto merges // Init runs the task queue to that handles auto merges
func Init() error { func Init() error {
notify_service.RegisterNotifier(NewNotifier()) notify_service.RegisterNotifier(NewNotifier())
prAutoMergeQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "pr_auto_merge", handler) automergequeue.AutoMergeQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "pr_auto_merge", handler)
if prAutoMergeQueue == nil { if automergequeue.AutoMergeQueue == nil {
return errors.New("unable to create pr_auto_merge queue") return errors.New("unable to create pr_auto_merge queue")
} }
go graceful.GetManager().RunWithCancel(prAutoMergeQueue) go graceful.GetManager().RunWithCancel(automergequeue.AutoMergeQueue)
return nil return nil
} }
@@ -56,24 +54,23 @@ func handler(items ...string) []string {
return nil return nil
} }
func addToQueue(pr *issues_model.PullRequest, sha string) {
log.Trace("Adding pullID: %d to the pull requests patch checking queue with sha %s", pr.ID, sha)
if err := prAutoMergeQueue.Push(fmt.Sprintf("%d_%s", pr.ID, sha)); err != nil {
log.Error("Error adding pullID: %d to the pull requests patch checking queue %v", pr.ID, err)
}
}
// ScheduleAutoMerge if schedule is false and no error, pull can be merged directly // ScheduleAutoMerge if schedule is false and no error, pull can be merged directly
func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest, style repo_model.MergeStyle, message string, deleteBranchAfterMerge bool) (scheduled bool, err error) { func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest, style repo_model.MergeStyle, message string, deleteBranchAfterMerge bool) (scheduled bool, err error) {
err = db.WithTx(ctx, func(ctx context.Context) error { err = db.WithTx(ctx, func(ctx context.Context) error {
if err := pull_model.ScheduleAutoMerge(ctx, doer, pull.ID, style, message, deleteBranchAfterMerge); err != nil { if err := pull_model.ScheduleAutoMerge(ctx, doer, pull.ID, style, message, deleteBranchAfterMerge); err != nil {
return err return err
} }
scheduled = true
_, err = issues_model.CreateAutoMergeComment(ctx, issues_model.CommentTypePRScheduledToAutoMerge, pull, doer) _, err = issues_model.CreateAutoMergeComment(ctx, issues_model.CommentTypePRScheduledToAutoMerge, pull, doer)
return err return err
}) })
// Old code made "scheduled" to be true after "ScheduleAutoMerge", but it's not right:
// If the transaction rolls back, then the pull request is not scheduled to auto merge.
// So we should only set "scheduled" to true if there is no error.
scheduled = err == nil
if scheduled {
log.Trace("Pull request [%d] scheduled for auto merge with style [%s] and message [%s]", pull.ID, style, message)
automergequeue.StartPRCheckAndAutoMerge(ctx, pull)
}
return scheduled, err return scheduled, err
} }
@@ -99,38 +96,12 @@ func StartPRCheckAndAutoMergeBySHA(ctx context.Context, sha string, repo *repo_m
} }
for _, pr := range pulls { for _, pr := range pulls {
addToQueue(pr, sha) automergequeue.AddToQueue(pr, sha)
} }
return nil return nil
} }
// StartPRCheckAndAutoMerge start an automerge check and auto merge task for a pull request
func StartPRCheckAndAutoMerge(ctx context.Context, pull *issues_model.PullRequest) {
if pull == nil || pull.HasMerged || !pull.CanAutoMerge() {
return
}
if err := pull.LoadBaseRepo(ctx); err != nil {
log.Error("LoadBaseRepo: %v", err)
return
}
gitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo)
if err != nil {
log.Error("OpenRepository: %v", err)
return
}
defer gitRepo.Close()
commitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName())
if err != nil {
log.Error("GetRefCommitID: %v", err)
return
}
addToQueue(pull, commitID)
}
func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.Repository, filter func(*issues_model.PullRequest) bool) (map[int64]*issues_model.PullRequest, error) { func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.Repository, filter func(*issues_model.PullRequest) bool) (map[int64]*issues_model.PullRequest, error) {
gitRepo, err := gitrepo.OpenRepository(ctx, repo) gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil { if err != nil {

View File

@@ -12,6 +12,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/services/automergequeue"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
) )
@@ -45,7 +46,7 @@ func (n *automergeNotifier) PullReviewDismiss(ctx context.Context, doer *user_mo
return return
} }
// as reviews could have blocked a pending automerge let's recheck // as reviews could have blocked a pending automerge let's recheck
StartPRCheckAndAutoMerge(ctx, review.Issue.PullRequest) automergequeue.StartPRCheckAndAutoMerge(ctx, review.Issue.PullRequest)
} }
func (n *automergeNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) { func (n *automergeNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) {

View File

@@ -0,0 +1,49 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package automergequeue
import (
"context"
"fmt"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue"
)
var AutoMergeQueue *queue.WorkerPoolQueue[string]
var AddToQueue = func(pr *issues_model.PullRequest, sha string) {
log.Trace("Adding pullID: %d to the pull requests patch checking queue with sha %s", pr.ID, sha)
if err := AutoMergeQueue.Push(fmt.Sprintf("%d_%s", pr.ID, sha)); err != nil {
log.Error("Error adding pullID: %d to the pull requests patch checking queue %v", pr.ID, err)
}
}
// StartPRCheckAndAutoMerge start an automerge check and auto merge task for a pull request
func StartPRCheckAndAutoMerge(ctx context.Context, pull *issues_model.PullRequest) {
if pull == nil || pull.HasMerged || !pull.CanAutoMerge() {
return
}
if err := pull.LoadBaseRepo(ctx); err != nil {
log.Error("LoadBaseRepo: %v", err)
return
}
gitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo)
if err != nil {
log.Error("OpenRepository: %v", err)
return
}
defer gitRepo.Close()
commitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName())
if err != nil {
log.Error("GetRefCommitID: %v", err)
return
}
AddToQueue(pull, commitID)
}

View File

@@ -33,8 +33,8 @@ func (p *Pagination) WithCurRows(n int) *Pagination {
return p return p
} }
func (p *Pagination) AddParamFromRequest(req *http.Request) { func (p *Pagination) AddParamFromQuery(q url.Values) {
for key, values := range req.URL.Query() { for key, values := range q {
if key == "page" || len(values) == 0 || (len(values) == 1 && values[0] == "") { if key == "page" || len(values) == 0 || (len(values) == 1 && values[0] == "") {
continue continue
} }
@@ -45,6 +45,10 @@ func (p *Pagination) AddParamFromRequest(req *http.Request) {
} }
} }
func (p *Pagination) AddParamFromRequest(req *http.Request) {
p.AddParamFromQuery(req.URL.Query())
}
// GetParams returns the configured URL params // GetParams returns the configured URL params
func (p *Pagination) GetParams() template.URL { func (p *Pagination) GetParams() template.URL {
return template.URL(strings.Join(p.urlParams, "&")) return template.URL(strings.Join(p.urlParams, "&"))

View File

@@ -28,7 +28,6 @@ func init() {
}) })
} }
// Deadline is part of the interface for context.Context and we pass this to the request context
func (ctx *PrivateContext) Deadline() (deadline time.Time, ok bool) { func (ctx *PrivateContext) Deadline() (deadline time.Time, ok bool) {
if ctx.Override != nil { if ctx.Override != nil {
return ctx.Override.Deadline() return ctx.Override.Deadline()
@@ -36,7 +35,6 @@ func (ctx *PrivateContext) Deadline() (deadline time.Time, ok bool) {
return ctx.Base.Deadline() return ctx.Base.Deadline()
} }
// Done is part of the interface for context.Context and we pass this to the request context
func (ctx *PrivateContext) Done() <-chan struct{} { func (ctx *PrivateContext) Done() <-chan struct{} {
if ctx.Override != nil { if ctx.Override != nil {
return ctx.Override.Done() return ctx.Override.Done()
@@ -44,7 +42,6 @@ func (ctx *PrivateContext) Done() <-chan struct{} {
return ctx.Base.Done() return ctx.Base.Done()
} }
// Err is part of the interface for context.Context and we pass this to the request context
func (ctx *PrivateContext) Err() error { func (ctx *PrivateContext) Err() error {
if ctx.Override != nil { if ctx.Override != nil {
return ctx.Override.Err() return ctx.Override.Err()
@@ -52,14 +49,14 @@ func (ctx *PrivateContext) Err() error {
return ctx.Base.Err() return ctx.Base.Err()
} }
var privateContextKey any = "default_private_context" type privateContextKeyType struct{}
var privateContextKey privateContextKeyType
// GetPrivateContext returns a context for Private routes
func GetPrivateContext(req *http.Request) *PrivateContext { func GetPrivateContext(req *http.Request) *PrivateContext {
return req.Context().Value(privateContextKey).(*PrivateContext) return req.Context().Value(privateContextKey).(*PrivateContext)
} }
// PrivateContexter returns apicontext as middleware
func PrivateContexter() func(http.Handler) http.Handler { func PrivateContexter() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {

View File

@@ -143,7 +143,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs) mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs)
approvalsWhitelistUsernames := getWhitelistEntities(readers, bp.ApprovalsWhitelistUserIDs) approvalsWhitelistUsernames := getWhitelistEntities(readers, bp.ApprovalsWhitelistUserIDs)
teamReaders, err := organization.OrgFromUser(repo.Owner).TeamsWithAccessToRepo(ctx, repo.ID, perm.AccessModeRead) teamReaders, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.Owner.ID, repo.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil { if err != nil {
log.Error("Repo.Owner.TeamsWithAccessToRepo: %v", err) log.Error("Repo.Owner.TeamsWithAccessToRepo: %v", err)
} }
@@ -485,7 +485,7 @@ func ToTagProtection(ctx context.Context, pt *git_model.ProtectedTag, repo *repo
whitelistUsernames := getWhitelistEntities(readers, pt.AllowlistUserIDs) whitelistUsernames := getWhitelistEntities(readers, pt.AllowlistUserIDs)
teamReaders, err := organization.OrgFromUser(repo.Owner).TeamsWithAccessToRepo(ctx, repo.ID, perm.AccessModeRead) teamReaders, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.Owner.ID, repo.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil { if err != nil {
log.Error("Repo.Owner.TeamsWithAccessToRepo: %v", err) log.Error("Repo.Owner.TeamsWithAccessToRepo: %v", err)
} }

View File

@@ -245,7 +245,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
RepoTransfer: transfer, RepoTransfer: transfer,
Topics: util.SliceNilAsEmpty(repo.Topics), Topics: util.SliceNilAsEmpty(repo.Topics),
ObjectFormatName: repo.ObjectFormatName, ObjectFormatName: repo.ObjectFormatName,
Licenses: repoLicenses.StringList(), Licenses: util.SliceNilAsEmpty(repoLicenses.StringList()),
} }
} }

View File

@@ -179,7 +179,7 @@ func (d *DiffLine) GetExpandDirection() DiffLineExpandDirection {
} }
func getDiffLineSectionInfo(treePath, line string, lastLeftIdx, lastRightIdx int) *DiffLineSectionInfo { func getDiffLineSectionInfo(treePath, line string, lastLeftIdx, lastRightIdx int) *DiffLineSectionInfo {
leftLine, leftHunk, rightLine, righHunk := git.ParseDiffHunkString(line) leftLine, leftHunk, rightLine, rightHunk := git.ParseDiffHunkString(line)
return &DiffLineSectionInfo{ return &DiffLineSectionInfo{
Path: treePath, Path: treePath,
@@ -188,7 +188,7 @@ func getDiffLineSectionInfo(treePath, line string, lastLeftIdx, lastRightIdx int
LeftIdx: leftLine, LeftIdx: leftLine,
RightIdx: rightLine, RightIdx: rightLine,
LeftHunkSize: leftHunk, LeftHunkSize: leftHunk,
RightHunkSize: righHunk, RightHunkSize: rightHunk,
} }
} }
@@ -335,7 +335,7 @@ func (diffSection *DiffSection) GetComputedInlineDiffFor(diffLine *DiffLine, loc
// try to find equivalent diff line. ignore, otherwise // try to find equivalent diff line. ignore, otherwise
switch diffLine.Type { switch diffLine.Type {
case DiffLineSection: case DiffLineSection:
return getLineContent(diffLine.Content[1:], locale) return getLineContent(diffLine.Content, locale)
case DiffLineAdd: case DiffLineAdd:
compareDiffLine := diffSection.GetLine(diffLine.Match) compareDiffLine := diffSection.GetLine(diffLine.Match)
return diffSection.getDiffLineForRender(DiffLineAdd, compareDiffLine, diffLine, locale) return diffSection.getDiffLineForRender(DiffLineAdd, compareDiffLine, diffLine, locale)
@@ -904,6 +904,7 @@ func parseHunks(ctx context.Context, curFile *DiffFile, maxLines, maxLineCharact
lastLeftIdx = -1 lastLeftIdx = -1
curFile.Sections = append(curFile.Sections, curSection) curFile.Sections = append(curFile.Sections, curSection)
// FIXME: the "-1" can't be right, these "line idx" are all 1-based, maybe there are other bugs that covers this bug.
lineSectionInfo := getDiffLineSectionInfo(curFile.Name, line, leftLine-1, rightLine-1) lineSectionInfo := getDiffLineSectionInfo(curFile.Name, line, leftLine-1, rightLine-1)
diffLine := &DiffLine{ diffLine := &DiffLine{
Type: DiffLineSection, Type: DiffLineSection,
@@ -1232,7 +1233,7 @@ func GetDiffForAPI(ctx context.Context, gitRepo *git.Repository, opts *DiffOptio
return diff, err return diff, err
} }
func GetDiffForRender(ctx context.Context, gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff, error) { func GetDiffForRender(ctx context.Context, repoLink string, gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff, error) {
diff, beforeCommit, afterCommit, err := getDiffBasic(ctx, gitRepo, opts, files...) diff, beforeCommit, afterCommit, err := getDiffBasic(ctx, gitRepo, opts, files...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1258,7 +1259,7 @@ func GetDiffForRender(ctx context.Context, gitRepo *git.Repository, opts *DiffOp
// Populate Submodule URLs // Populate Submodule URLs
if diffFile.SubmoduleDiffInfo != nil { if diffFile.SubmoduleDiffInfo != nil {
diffFile.SubmoduleDiffInfo.PopulateURL(diffFile, beforeCommit, afterCommit) diffFile.SubmoduleDiffInfo.PopulateURL(repoLink, diffFile, beforeCommit, afterCommit)
} }
if !isVendored.Has() { if !isVendored.Has() {
@@ -1359,6 +1360,7 @@ func SyncUserSpecificDiff(ctx context.Context, userID int64, pull *issues_model.
// But as that does not work for all potential errors, we simply mark all files as unchanged and drop the error which always works, even if not as good as possible // But as that does not work for all potential errors, we simply mark all files as unchanged and drop the error which always works, even if not as good as possible
if err != nil { if err != nil {
log.Error("Could not get changed files between %s and %s for pull request %d in repo with path %s. Assuming no changes. Error: %w", review.CommitSHA, latestCommit, pull.Index, gitRepo.Path, err) log.Error("Could not get changed files between %s and %s for pull request %d in repo with path %s. Assuming no changes. Error: %w", review.CommitSHA, latestCommit, pull.Index, gitRepo.Path, err)
err = nil //nolint
} }
filesChangedSinceLastDiff := make(map[string]pull_model.ViewedState) filesChangedSinceLastDiff := make(map[string]pull_model.ViewedState)
@@ -1400,7 +1402,7 @@ outer:
} }
} }
return review, err return review, nil
} }
// CommentAsDiff returns c.Patch as *Diff // CommentAsDiff returns c.Patch as *Diff

View File

@@ -20,7 +20,7 @@ type SubmoduleDiffInfo struct {
PreviousRefID string PreviousRefID string
} }
func (si *SubmoduleDiffInfo) PopulateURL(diffFile *DiffFile, leftCommit, rightCommit *git.Commit) { func (si *SubmoduleDiffInfo) PopulateURL(repoLink string, diffFile *DiffFile, leftCommit, rightCommit *git.Commit) {
si.SubmoduleName = diffFile.Name si.SubmoduleName = diffFile.Name
submoduleCommit := rightCommit // If the submodule is added or updated, check at the right commit submoduleCommit := rightCommit // If the submodule is added or updated, check at the right commit
if diffFile.IsDeleted { if diffFile.IsDeleted {
@@ -30,18 +30,19 @@ func (si *SubmoduleDiffInfo) PopulateURL(diffFile *DiffFile, leftCommit, rightCo
return return
} }
submodule, err := submoduleCommit.GetSubModule(diffFile.GetDiffFileName()) submoduleFullPath := diffFile.GetDiffFileName()
submodule, err := submoduleCommit.GetSubModule(submoduleFullPath)
if err != nil { if err != nil {
log.Error("Unable to PopulateURL for submodule %q: GetSubModule: %v", diffFile.GetDiffFileName(), err) log.Error("Unable to PopulateURL for submodule %q: GetSubModule: %v", submoduleFullPath, err)
return // ignore the error, do not cause 500 errors for end users return // ignore the error, do not cause 500 errors for end users
} }
if submodule != nil { if submodule != nil {
si.SubmoduleFile = git.NewCommitSubmoduleFile(submodule.URL, submoduleCommit.ID.String()) si.SubmoduleFile = git.NewCommitSubmoduleFile(repoLink, submoduleFullPath, submodule.URL, submoduleCommit.ID.String())
} }
} }
func (si *SubmoduleDiffInfo) CommitRefIDLinkHTML(ctx context.Context, commitID string) template.HTML { func (si *SubmoduleDiffInfo) CommitRefIDLinkHTML(ctx context.Context, commitID string) template.HTML {
webLink := si.SubmoduleFile.SubmoduleWebLink(ctx, commitID) webLink := si.SubmoduleFile.SubmoduleWebLinkTree(ctx, commitID)
if webLink == nil { if webLink == nil {
return htmlutil.HTMLFormat("%s", base.ShortSha(commitID)) return htmlutil.HTMLFormat("%s", base.ShortSha(commitID))
} }
@@ -49,7 +50,7 @@ func (si *SubmoduleDiffInfo) CommitRefIDLinkHTML(ctx context.Context, commitID s
} }
func (si *SubmoduleDiffInfo) CompareRefIDLinkHTML(ctx context.Context) template.HTML { func (si *SubmoduleDiffInfo) CompareRefIDLinkHTML(ctx context.Context) template.HTML {
webLink := si.SubmoduleFile.SubmoduleWebLink(ctx, si.PreviousRefID, si.NewRefID) webLink := si.SubmoduleFile.SubmoduleWebLinkCompare(ctx, si.PreviousRefID, si.NewRefID)
if webLink == nil { if webLink == nil {
return htmlutil.HTMLFormat("%s...%s", base.ShortSha(si.PreviousRefID), base.ShortSha(si.NewRefID)) return htmlutil.HTMLFormat("%s...%s", base.ShortSha(si.PreviousRefID), base.ShortSha(si.NewRefID))
} }
@@ -57,7 +58,7 @@ func (si *SubmoduleDiffInfo) CompareRefIDLinkHTML(ctx context.Context) template.
} }
func (si *SubmoduleDiffInfo) SubmoduleRepoLinkHTML(ctx context.Context) template.HTML { func (si *SubmoduleDiffInfo) SubmoduleRepoLinkHTML(ctx context.Context) template.HTML {
webLink := si.SubmoduleFile.SubmoduleWebLink(ctx) webLink := si.SubmoduleFile.SubmoduleWebLinkTree(ctx)
if webLink == nil { if webLink == nil {
return htmlutil.HTMLFormat("%s", si.SubmoduleName) return htmlutil.HTMLFormat("%s", si.SubmoduleName)
} }

View File

@@ -228,7 +228,7 @@ func TestSubmoduleInfo(t *testing.T) {
assert.EqualValues(t, "aaaa...bbbb", sdi.CompareRefIDLinkHTML(ctx)) assert.EqualValues(t, "aaaa...bbbb", sdi.CompareRefIDLinkHTML(ctx))
assert.EqualValues(t, "name", sdi.SubmoduleRepoLinkHTML(ctx)) assert.EqualValues(t, "name", sdi.SubmoduleRepoLinkHTML(ctx))
sdi.SubmoduleFile = git.NewCommitSubmoduleFile("https://github.com/owner/repo", "1234") sdi.SubmoduleFile = git.NewCommitSubmoduleFile("/any/repo-link", "fullpath", "https://github.com/owner/repo", "1234")
assert.EqualValues(t, `<a href="https://github.com/owner/repo/tree/1111">1111</a>`, sdi.CommitRefIDLinkHTML(ctx, "1111")) assert.EqualValues(t, `<a href="https://github.com/owner/repo/tree/1111">1111</a>`, sdi.CommitRefIDLinkHTML(ctx, "1111"))
assert.EqualValues(t, `<a href="https://github.com/owner/repo/compare/aaaa...bbbb">aaaa...bbbb</a>`, sdi.CompareRefIDLinkHTML(ctx)) assert.EqualValues(t, `<a href="https://github.com/owner/repo/compare/aaaa...bbbb">aaaa...bbbb</a>`, sdi.CompareRefIDLinkHTML(ctx))
assert.EqualValues(t, `<a href="https://github.com/owner/repo">name</a>`, sdi.SubmoduleRepoLinkHTML(ctx)) assert.EqualValues(t, `<a href="https://github.com/owner/repo">name</a>`, sdi.SubmoduleRepoLinkHTML(ctx))

View File

@@ -304,7 +304,7 @@ func CanDoerChangeReviewRequests(ctx context.Context, doer *user_model.User, rep
// If the repo's owner is an organization, members of teams with read permission on pull requests can change reviewers // If the repo's owner is an organization, members of teams with read permission on pull requests can change reviewers
if repo.Owner.IsOrganization() { if repo.Owner.IsOrganization() {
teams, err := organization.GetTeamsWithAccessToRepo(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead) teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests)
if err != nil { if err != nil {
log.Error("GetTeamsWithAccessToRepo: %v", err) log.Error("GetTeamsWithAccessToRepo: %v", err)
return false return false

View File

@@ -322,7 +322,10 @@ func (g *GithubDownloaderV3) convertGithubRelease(ctx context.Context, rel *gith
httpClient := NewMigrationHTTPClient() httpClient := NewMigrationHTTPClient()
for _, asset := range rel.Assets { for _, asset := range rel.Assets {
assetID := *asset.ID // Don't optimize this, for closure we need a local variable assetID := asset.GetID() // Don't optimize this, for closure we need a local variable TODO: no need to do so in new Golang
if assetID == 0 {
continue
}
r.Assets = append(r.Assets, &base.ReleaseAsset{ r.Assets = append(r.Assets, &base.ReleaseAsset{
ID: asset.GetID(), ID: asset.GetID(),
Name: asset.GetName(), Name: asset.GetName(),

View File

@@ -46,10 +46,25 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
} }
} }
func shouldSendCommentChangeNotification(ctx context.Context, comment *issues_model.Comment) bool {
if err := comment.LoadReview(ctx); err != nil {
log.Error("LoadReview: %v", err)
return false
} else if comment.Review != nil && comment.Review.Type == issues_model.ReviewTypePending {
// Pending review comments updating should not triggered
return false
}
return true
}
// CreateIssueComment notifies issue comment related message to notifiers // CreateIssueComment notifies issue comment related message to notifiers
func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User,
) { ) {
if !shouldSendCommentChangeNotification(ctx, comment) {
return
}
for _, notifier := range notifiers { for _, notifier := range notifiers {
notifier.CreateIssueComment(ctx, doer, repo, issue, comment, mentions) notifier.CreateIssueComment(ctx, doer, repo, issue, comment, mentions)
} }
@@ -156,6 +171,10 @@ func PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issue
// UpdateComment notifies update comment to notifiers // UpdateComment notifies update comment to notifiers
func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) {
if !shouldSendCommentChangeNotification(ctx, c) {
return
}
for _, notifier := range notifiers { for _, notifier := range notifiers {
notifier.UpdateComment(ctx, doer, c, oldContent) notifier.UpdateComment(ctx, doer, c, oldContent)
} }
@@ -163,6 +182,10 @@ func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.C
// DeleteComment notifies delete comment to notifiers // DeleteComment notifies delete comment to notifiers
func DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) { func DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) {
if !shouldSendCommentChangeNotification(ctx, c) {
return
}
for _, notifier := range notifiers { for _, notifier := range notifiers {
notifier.DeleteComment(ctx, doer, c) notifier.DeleteComment(ctx, doer, c)
} }

View File

@@ -1,5 +1,4 @@
// Copyright 2019 The Gitea Authors. // Copyright 2019 The Gitea Authors. All rights reserved.
// All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package pull package pull
@@ -16,6 +15,7 @@ import (
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
"code.gitea.io/gitea/models/pull"
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"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@@ -29,6 +29,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
asymkey_service "code.gitea.io/gitea/services/asymkey" asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/automergequeue"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
) )
@@ -238,7 +239,7 @@ func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer
// markPullRequestAsMergeable checks if pull request is possible to leaving checking status, // markPullRequestAsMergeable checks if pull request is possible to leaving checking status,
// and set to be either conflict or mergeable. // and set to be either conflict or mergeable.
func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) { func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) {
// If status has not been changed to conflict by testPullRequestTmpRepoBranchMergeable then we are mergeable // If the status has not been changed to conflict by testPullRequestTmpRepoBranchMergeable then we are mergeable
if pr.Status == issues_model.PullRequestStatusChecking { if pr.Status == issues_model.PullRequestStatusChecking {
pr.Status = issues_model.PullRequestStatusMergeable pr.Status = issues_model.PullRequestStatusMergeable
} }
@@ -257,6 +258,16 @@ func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullReques
if err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files"); err != nil { if err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files"); err != nil {
log.Error("Update[%-v]: %v", pr, err) log.Error("Update[%-v]: %v", pr, err)
} }
// if there is a scheduled merge for this pull request, start the auto merge check (again)
exist, _, err := pull.GetScheduledMergeByPullID(ctx, pr.ID)
if err != nil {
log.Error("GetScheduledMergeByPullID[%-v]: %v", pr, err)
return
} else if !exist {
return
}
automergequeue.StartPRCheckAndAutoMerge(ctx, pr)
} }
// getMergeCommit checks if a pull request has been merged // getMergeCommit checks if a pull request has been merged

View File

@@ -1,5 +1,4 @@
// Copyright 2019 The Gitea Authors. // Copyright 2019 The Gitea Authors. All rights reserved.
// All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package pull package pull
@@ -11,11 +10,18 @@ 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"
"code.gitea.io/gitea/models/pull"
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"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/services/automergequeue"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestPullRequest_AddToTaskQueue(t *testing.T) { func TestPullRequest_AddToTaskQueue(t *testing.T) {
@@ -63,6 +69,46 @@ func TestPullRequest_AddToTaskQueue(t *testing.T) {
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
assert.Equal(t, issues_model.PullRequestStatusChecking, pr.Status) assert.Equal(t, issues_model.PullRequestStatusChecking, pr.Status)
prPatchCheckerQueue.ShutdownWait(5 * time.Second) prPatchCheckerQueue.ShutdownWait(time.Second)
prPatchCheckerQueue = nil prPatchCheckerQueue = nil
} }
func TestMarkPullRequestAsMergeable(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
prPatchCheckerQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "pr_patch_checker", func(items ...string) []string { return nil })
go prPatchCheckerQueue.Run()
defer func() {
prPatchCheckerQueue.ShutdownWait(time.Second)
prPatchCheckerQueue = nil
}()
addToQueueShaChan := make(chan string, 1)
defer test.MockVariableValue(&automergequeue.AddToQueue, func(pr *issues_model.PullRequest, sha string) {
addToQueueShaChan <- sha
})()
ctx := t.Context()
_, _ = db.GetEngine(ctx).ID(2).Update(&issues_model.PullRequest{Status: issues_model.PullRequestStatusChecking})
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
require.False(t, pr.HasMerged)
require.Equal(t, issues_model.PullRequestStatusChecking, pr.Status)
err := pull.ScheduleAutoMerge(ctx, &user_model.User{ID: 99999}, pr.ID, repo_model.MergeStyleMerge, "test msg", true)
require.NoError(t, err)
exist, scheduleMerge, err := pull.GetScheduledMergeByPullID(ctx, pr.ID)
require.NoError(t, err)
assert.True(t, exist)
assert.True(t, scheduleMerge.Doer.IsGhost())
markPullRequestAsMergeable(ctx, pr)
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
require.Equal(t, issues_model.PullRequestStatusMergeable, pr.Status)
select {
case sha := <-addToQueueShaChan:
assert.Equal(t, "985f0301dba5e7b34be866819cd15ad3d8f508ee", sha) // ref: refs/pull/3/head
case <-time.After(1 * time.Second):
assert.FailNow(t, "Timeout: nothing was added to automergequeue")
}
}

View File

@@ -35,15 +35,16 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus,
} }
for _, gp := range requiredContextsGlob { for _, gp := range requiredContextsGlob {
var targetStatus structs.CommitStatusState var targetStatuses []*git_model.CommitStatus
for _, commitStatus := range commitStatuses { for _, commitStatus := range commitStatuses {
if gp.Match(commitStatus.Context) { if gp.Match(commitStatus.Context) {
targetStatus = commitStatus.State targetStatuses = append(targetStatuses, commitStatus)
matchedCount++ matchedCount++
break
} }
} }
targetStatus := git_model.CalcCommitStatus(targetStatuses).State
// If required rule not match any action, then it is pending // If required rule not match any action, then it is pending
if targetStatus == "" { if targetStatus == "" {
if structs.CommitStatusPending.NoBetterThan(returnedStatus) { if structs.CommitStatusPending.NoBetterThan(returnedStatus) {

View File

@@ -30,6 +30,11 @@ func TestMergeRequiredContextsCommitStatus(t *testing.T) {
{Context: "Build 2", State: structs.CommitStatusSuccess}, {Context: "Build 2", State: structs.CommitStatusSuccess},
{Context: "Build 2t", State: structs.CommitStatusFailure}, {Context: "Build 2t", State: structs.CommitStatusFailure},
}, },
{
{Context: "Build 1", State: structs.CommitStatusSuccess},
{Context: "Build 2", State: structs.CommitStatusSuccess},
{Context: "Build 2t", State: structs.CommitStatusFailure},
},
{ {
{Context: "Build 1", State: structs.CommitStatusSuccess}, {Context: "Build 1", State: structs.CommitStatusSuccess},
{Context: "Build 2", State: structs.CommitStatusSuccess}, {Context: "Build 2", State: structs.CommitStatusSuccess},
@@ -45,6 +50,7 @@ func TestMergeRequiredContextsCommitStatus(t *testing.T) {
{"Build*"}, {"Build*"},
{"Build*", "Build 2t*"}, {"Build*", "Build 2t*"},
{"Build*", "Build 2t*"}, {"Build*", "Build 2t*"},
{"Build*"},
{"Build*", "Build 2t*", "Build 3*"}, {"Build*", "Build 2t*", "Build 3*"},
{"Build*", "Build *", "Build 2t*", "Build 1*"}, {"Build*", "Build *", "Build 2t*", "Build 1*"},
} }
@@ -53,6 +59,7 @@ func TestMergeRequiredContextsCommitStatus(t *testing.T) {
structs.CommitStatusSuccess, structs.CommitStatusSuccess,
structs.CommitStatusPending, structs.CommitStatusPending,
structs.CommitStatusFailure, structs.CommitStatusFailure,
structs.CommitStatusFailure,
structs.CommitStatusPending, structs.CommitStatusPending,
structs.CommitStatusSuccess, structs.CommitStatusSuccess,
} }

View File

@@ -85,5 +85,5 @@ func GetReviewerTeams(ctx context.Context, repo *repo_model.Repository) ([]*orga
return nil, nil return nil, nil
} }
return organization.GetTeamsWithAccessToRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests) return organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests)
} }

View File

@@ -158,34 +158,26 @@ func (node *TreeViewNode) sortLevel() int {
return util.Iif(node.EntryMode == "tree" || node.EntryMode == "commit", 0, 1) return util.Iif(node.EntryMode == "tree" || node.EntryMode == "commit", 0, 1)
} }
func newTreeViewNodeFromEntry(ctx context.Context, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, parentDir string, entry *git.TreeEntry) *TreeViewNode { func newTreeViewNodeFromEntry(ctx context.Context, repoLink string, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, parentDir string, entry *git.TreeEntry) *TreeViewNode {
node := &TreeViewNode{ node := &TreeViewNode{
EntryName: entry.Name(), EntryName: entry.Name(),
EntryMode: entryModeString(entry.Mode()), EntryMode: entryModeString(entry.Mode()),
FullPath: path.Join(parentDir, entry.Name()), FullPath: path.Join(parentDir, entry.Name()),
} }
if entry.IsLink() { entryInfo := fileicon.EntryInfoFromGitTreeEntry(entry)
// TODO: symlink to a folder or a file, the icon differs node.EntryIcon = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
target, err := entry.FollowLink() if entryInfo.EntryMode.IsDir() {
if err == nil { entryInfo.IsOpen = true
_ = target.IsDir() node.EntryIconOpen = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
// if target.IsDir() { } else { }
}
}
if node.EntryIcon == "" {
node.EntryIcon = fileicon.RenderEntryIcon(renderedIconPool, entry)
// TODO: no open icon support yet
// node.EntryIconOpen = fileicon.RenderEntryIconOpen(renderedIconPool, entry)
} }
if node.EntryMode == "commit" { if node.EntryMode == "commit" {
if subModule, err := commit.GetSubModule(node.FullPath); err != nil { if subModule, err := commit.GetSubModule(node.FullPath); err != nil {
log.Error("GetSubModule: %v", err) log.Error("GetSubModule: %v", err)
} else if subModule != nil { } else if subModule != nil {
submoduleFile := git.NewCommitSubmoduleFile(subModule.URL, entry.ID.String()) submoduleFile := git.NewCommitSubmoduleFile(repoLink, node.FullPath, subModule.URL, entry.ID.String())
webLink := submoduleFile.SubmoduleWebLink(ctx) webLink := submoduleFile.SubmoduleWebLinkTree(ctx)
node.SubmoduleURL = webLink.CommitWebLink node.SubmoduleURL = webLink.CommitWebLink
} }
} }
@@ -204,7 +196,7 @@ func sortTreeViewNodes(nodes []*TreeViewNode) {
}) })
} }
func listTreeNodes(ctx context.Context, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, tree *git.Tree, treePath, subPath string) ([]*TreeViewNode, error) { func listTreeNodes(ctx context.Context, repoLink string, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, tree *git.Tree, treePath, subPath string) ([]*TreeViewNode, error) {
entries, err := tree.ListEntries() entries, err := tree.ListEntries()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -213,14 +205,14 @@ func listTreeNodes(ctx context.Context, renderedIconPool *fileicon.RenderedIconP
subPathDirName, subPathRemaining, _ := strings.Cut(subPath, "/") subPathDirName, subPathRemaining, _ := strings.Cut(subPath, "/")
nodes := make([]*TreeViewNode, 0, len(entries)) nodes := make([]*TreeViewNode, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
node := newTreeViewNodeFromEntry(ctx, renderedIconPool, commit, treePath, entry) node := newTreeViewNodeFromEntry(ctx, repoLink, renderedIconPool, commit, treePath, entry)
nodes = append(nodes, node) nodes = append(nodes, node)
if entry.IsDir() && subPathDirName == entry.Name() { if entry.IsDir() && subPathDirName == entry.Name() {
subTreePath := treePath + "/" + node.EntryName subTreePath := treePath + "/" + node.EntryName
if subTreePath[0] == '/' { if subTreePath[0] == '/' {
subTreePath = subTreePath[1:] subTreePath = subTreePath[1:]
} }
subNodes, err := listTreeNodes(ctx, renderedIconPool, commit, entry.Tree(), subTreePath, subPathRemaining) subNodes, err := listTreeNodes(ctx, repoLink, renderedIconPool, commit, entry.Tree(), subTreePath, subPathRemaining)
if err != nil { if err != nil {
log.Error("listTreeNodes: %v", err) log.Error("listTreeNodes: %v", err)
} else { } else {
@@ -232,10 +224,10 @@ func listTreeNodes(ctx context.Context, renderedIconPool *fileicon.RenderedIconP
return nodes, nil return nodes, nil
} }
func GetTreeViewNodes(ctx context.Context, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, treePath, subPath string) ([]*TreeViewNode, error) { func GetTreeViewNodes(ctx context.Context, repoLink string, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, treePath, subPath string) ([]*TreeViewNode, error) {
entry, err := commit.GetTreeEntryByPath(treePath) entry, err := commit.GetTreeEntryByPath(treePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return listTreeNodes(ctx, renderedIconPool, commit, entry.Tree(), treePath, subPath) return listTreeNodes(ctx, repoLink, renderedIconPool, commit, entry.Tree(), treePath, subPath)
} }

View File

@@ -64,6 +64,7 @@ func TestGetTreeViewNodes(t *testing.T) {
contexttest.LoadGitRepo(t, ctx) contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
curRepoLink := "/any/repo-link"
renderedIconPool := fileicon.NewRenderedIconPool() renderedIconPool := fileicon.NewRenderedIconPool()
mockIconForFile := func(id string) template.HTML { mockIconForFile := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`) return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
@@ -71,25 +72,30 @@ func TestGetTreeViewNodes(t *testing.T) {
mockIconForFolder := func(id string) template.HTML { mockIconForFolder := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file-directory-fill" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`) return template.HTML(`<svg class="svg git-entry-icon octicon-file-directory-fill" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
} }
treeNodes, err := GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, "", "") mockOpenIconForFolder := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file-directory-open-fill" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
}
treeNodes, err := GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "", "")
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []*TreeViewNode{ assert.Equal(t, []*TreeViewNode{
{ {
EntryName: "docs", EntryName: "docs",
EntryMode: "tree", EntryMode: "tree",
FullPath: "docs", FullPath: "docs",
EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`), EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`),
EntryIconOpen: mockOpenIconForFolder(`svg-mfi-folder-docs`),
}, },
}, treeNodes) }, treeNodes)
treeNodes, err = GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, "", "docs/README.md") treeNodes, err = GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "", "docs/README.md")
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []*TreeViewNode{ assert.Equal(t, []*TreeViewNode{
{ {
EntryName: "docs", EntryName: "docs",
EntryMode: "tree", EntryMode: "tree",
FullPath: "docs", FullPath: "docs",
EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`), EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`),
EntryIconOpen: mockOpenIconForFolder(`svg-mfi-folder-docs`),
Children: []*TreeViewNode{ Children: []*TreeViewNode{
{ {
EntryName: "README.md", EntryName: "README.md",
@@ -101,7 +107,7 @@ func TestGetTreeViewNodes(t *testing.T) {
}, },
}, treeNodes) }, treeNodes)
treeNodes, err = GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, "docs", "README.md") treeNodes, err = GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "docs", "README.md")
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []*TreeViewNode{ assert.Equal(t, []*TreeViewNode{
{ {

View File

@@ -16,7 +16,7 @@
<td class="author"> <td class="author">
<div class="tw-flex"> <div class="tw-flex">
{{$userName := .Author.Name}} {{$userName := .Author.Name}}
{{if .User}} {{if and .User (gt .User.ID 0)}}
{{if and .User.FullName DefaultShowFullName}} {{if and .User.FullName DefaultShowFullName}}
{{$userName = .User.FullName}} {{$userName = .User.FullName}}
{{end}} {{end}}

View File

@@ -60,6 +60,7 @@
{{end}} {{end}}
<div id="diff-container"> <div id="diff-container">
{{if $showFileTree}} {{if $showFileTree}}
{{$.FileIconPoolHTML}}
<div id="diff-file-tree" class="tw-hidden not-mobile"></div> <div id="diff-file-tree" class="tw-hidden not-mobile"></div>
<script> <script>
if (diffTreeVisible) document.getElementById('diff-file-tree').classList.remove('tw-hidden'); if (diffTreeVisible) document.getElementById('diff-file-tree').classList.remove('tw-hidden');

View File

@@ -22,7 +22,7 @@
<span class="label-filter-exclude-info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span> <span class="label-filter-exclude-info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span>
<div class="divider"></div> <div class="divider"></div>
<a class="item label-filter-query-default" href="{{QueryBuild $queryLink "labels" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a> <a class="item label-filter-query-default" href="{{QueryBuild $queryLink "labels" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a>
<a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" 0}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a> <a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" "0"}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a>
{{/* The logic here is not the same as the label selector in the issue sidebar. {{/* The logic here is not the same as the label selector in the issue sidebar.
The one in the issue sidebar renders "repo labels | divider | org labels". The one in the issue sidebar renders "repo labels | divider | org labels".
Maybe the logic should be updated to be consistent.*/}} Maybe the logic should be updated to be consistent.*/}}

View File

@@ -15,7 +15,7 @@
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_milestone"}}"> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_milestone"}}">
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<a class="{{if not $.MilestoneID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" 0}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_all"}}</a> <a class="{{if not $.MilestoneID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_all"}}</a>
<a class="{{if $.MilestoneID}}{{if eq $.MilestoneID -1}}active selected {{end}}{{end}}item" href="{{QueryBuild $queryLink "milestone" -1}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_none"}}</a> <a class="{{if $.MilestoneID}}{{if eq $.MilestoneID -1}}active selected {{end}}{{end}}item" href="{{QueryBuild $queryLink "milestone" -1}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_none"}}</a>
{{if .OpenMilestones}} {{if .OpenMilestones}}
<div class="divider"></div> <div class="divider"></div>

View File

@@ -92,7 +92,7 @@
{{end}} {{end}}
{{range $att := $release.Attachments}} {{range $att := $release.Attachments}}
<li class="item"> <li class="item">
<a target="_blank" class="tw-flex-grow-[2] gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}"> <a target="_blank" class="tw-flex-1 gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}">
<strong class="flex-text-inline">{{svg "octicon-package" 16 "download-icon"}}<span class="gt-ellipsis">{{$att.Name}}</span></strong> <strong class="flex-text-inline">{{svg "octicon-package" 16 "download-icon"}}<span class="gt-ellipsis">{{$att.Name}}</span></strong>
</a> </a>
<div class="attachment-right-info flex-text-inline"> <div class="attachment-right-info flex-text-inline">

View File

@@ -63,13 +63,33 @@
{{.Name}} {{.Name}}
</a> </a>
<div class="flex-item-body flex-text-block"> <div class="flex-item-body flex-text-block">
{{/*FIXME: TEAM-UNIT-PERMISSION this display is not right, search the fixme keyword to see more details */}}
{{svg "octicon-shield-lock"}} {{svg "octicon-shield-lock"}}
{{if eq .AccessMode 1}}{{ctx.Locale.Tr "repo.settings.collaboration.read"}}{{else if eq .AccessMode 2}}{{ctx.Locale.Tr "repo.settings.collaboration.write"}}{{else if eq .AccessMode 3}}{{ctx.Locale.Tr "repo.settings.collaboration.admin"}}{{else if eq .AccessMode 4}}{{ctx.Locale.Tr "repo.settings.collaboration.owner"}}{{else}}{{ctx.Locale.Tr "repo.settings.collaboration.undefined"}}{{end}} {{if eq .AccessMode 0}}
{{ctx.Locale.Tr "repo.settings.collaboration.per_unit"}}
{{else if eq .AccessMode 1}}
{{ctx.Locale.Tr "repo.settings.collaboration.read"}}
{{else if eq .AccessMode 2}}
{{ctx.Locale.Tr "repo.settings.collaboration.write"}}
{{else if eq .AccessMode 3}}
{{ctx.Locale.Tr "repo.settings.collaboration.admin"}}
{{else if eq .AccessMode 4}}
{{ctx.Locale.Tr "repo.settings.collaboration.owner"}}
{{else}}
{{ctx.Locale.Tr "repo.settings.collaboration.undefined"}}
{{end}}
</div> </div>
{{if or (eq .AccessMode 1) (eq .AccessMode 2)}} {{if or (eq .AccessMode 0) (eq .AccessMode 1) (eq .AccessMode 2)}}
{{$first := true}} {{$first := true}}
<div class="flex-item-body" data-tooltip-content="{{ctx.Locale.Tr "repo.settings.change_team_permission_tip"}}"> <div class="flex-item-body" data-tooltip-content="{{ctx.Locale.Tr "repo.settings.change_team_permission_tip"}}">
Sections: {{range $u, $unit := $.Units}}{{if and ($.Repo.UnitEnabled ctx $unit.Type) ($team.UnitEnabled ctx $unit.Type)}}{{if $first}}{{$first = false}}{{else}}, {{end}}{{ctx.Locale.Tr $unit.NameKey}}{{end}}{{end}} {{if $first}}None{{end}} Units:
{{range $u, $unit := $.Units}}
{{- if and ($.Repo.UnitEnabled ctx $unit.Type) ($team.UnitEnabled ctx $unit.Type) -}}
{{- Iif $first "" ", "}}{{ctx.Locale.Tr $unit.NameKey -}}
{{- $first = false -}}
{{- end -}}
{{end}}
{{if $first}}None{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>

View File

@@ -4,12 +4,12 @@
{{template "repo/latest_commit" .}} {{template "repo/latest_commit" .}}
<div>{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}</div> <div>{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}</div>
</div> </div>
{{$.FileIconPoolHTML}}
{{if .HasParentPath}} {{if .HasParentPath}}
<a class="repo-file-line parent-link silenced" href="{{.BranchLink}}{{if .ParentPath}}{{PathEscapeSegments .ParentPath}}{{end}}"> <a class="repo-file-line parent-link silenced" href="{{.BranchLink}}{{if .ParentPath}}{{PathEscapeSegments .ParentPath}}{{end}}">
{{svg "octicon-file-directory-fill"}} .. {{index $.FileIcons ".."}} ..
</a> </a>
{{end}} {{end}}
{{$.FileIconPoolHTML}}
{{range $item := .Files}} {{range $item := .Files}}
<div class="repo-file-item"> <div class="repo-file-item">
{{$entry := $item.Entry}} {{$entry := $item.Entry}}
@@ -18,7 +18,7 @@
<div class="repo-file-cell name muted-links {{if not $commit}}notready{{end}}"> <div class="repo-file-cell name muted-links {{if not $commit}}notready{{end}}">
{{index $.FileIcons $entry.Name}} {{index $.FileIcons $entry.Name}}
{{if $entry.IsSubModule}} {{if $entry.IsSubModule}}
{{$submoduleLink := $submoduleFile.SubmoduleWebLink ctx}} {{$submoduleLink := $submoduleFile.SubmoduleWebLinkTree ctx}}
{{if $submoduleLink}} {{if $submoduleLink}}
<a class="entry-name" href="{{$submoduleLink.RepoWebLink}}" title="{{$entry.Name}}">{{$entry.Name}}</a> <a class="entry-name" href="{{$submoduleLink.RepoWebLink}}" title="{{$entry.Name}}">{{$entry.Name}}</a>
@ <a class="text primary" href="{{$submoduleLink.CommitWebLink}}">{{ShortSha $submoduleFile.RefID}}</a> @ <a class="text primary" href="{{$submoduleLink.CommitWebLink}}">{{ShortSha $submoduleFile.RefID}}</a>

View File

@@ -20327,6 +20327,25 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"ActionWorkflowResponse": {
"description": "ActionWorkflowResponse returns a ActionWorkflow",
"type": "object",
"properties": {
"total_count": {
"type": "integer",
"format": "int64",
"x-go-name": "TotalCount"
},
"workflows": {
"type": "array",
"items": {
"$ref": "#/definitions/ActionWorkflow"
},
"x-go-name": "Workflows"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"ActionWorkflowRun": { "ActionWorkflowRun": {
"description": "ActionWorkflowRun represents a WorkflowRun", "description": "ActionWorkflowRun represents a WorkflowRun",
"type": "object", "type": "object",
@@ -27464,10 +27483,7 @@
"ActionWorkflowList": { "ActionWorkflowList": {
"description": "ActionWorkflowList", "description": "ActionWorkflowList",
"schema": { "schema": {
"type": "array", "$ref": "#/definitions/ActionWorkflowResponse"
"items": {
"$ref": "#/definitions/ActionWorkflow"
}
} }
}, },
"ActivityFeedsList": { "ActivityFeedsList": {

View File

@@ -12,7 +12,9 @@ import (
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm"
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"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@@ -58,9 +60,12 @@ func TestAPIDownloadArchive(t *testing.T) {
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name)) link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name))
MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest) MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest)
t.Run("GitHubStyle", testAPIDownloadArchiveGitHubStyle)
t.Run("PrivateRepo", testAPIDownloadArchivePrivateRepo)
} }
func TestAPIDownloadArchive2(t *testing.T) { func testAPIDownloadArchiveGitHubStyle(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
@@ -95,7 +100,13 @@ func TestAPIDownloadArchive2(t *testing.T) {
bs, err = io.ReadAll(resp.Body) bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, bs, 382) assert.Len(t, bs, 382)
}
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name))
MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest) func testAPIDownloadArchivePrivateRepo(t *testing.T) {
_ = repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: 1, IsPrivate: true}, "is_private")
MakeRequest(t, NewRequest(t, "HEAD", "/api/v1/repos/user2/repo1/archive/master.zip"), http.StatusNotFound)
MakeRequest(t, NewRequest(t, "HEAD", "/api/v1/repos/user2/repo1/zipball/master"), http.StatusNotFound)
_ = repo_model.UpdateRepoUnitPublicAccess(t.Context(), &repo_model.RepoUnit{RepoID: 1, Type: unit.TypeCode, AnonymousAccessMode: perm.AccessModeRead})
MakeRequest(t, NewRequest(t, "HEAD", "/api/v1/repos/user2/repo1/archive/master.zip"), http.StatusOK)
MakeRequest(t, NewRequest(t, "HEAD", "/api/v1/repos/user2/repo1/zipball/master"), http.StatusOK)
} }

View File

@@ -16,6 +16,7 @@ import (
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
@@ -488,40 +489,60 @@ func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *tes
} }
func doProtectBranch(ctx APITestContext, branch, userToWhitelistPush, userToWhitelistForcePush, unprotectedFilePatterns, protectedFilePatterns string) func(t *testing.T) { func doProtectBranch(ctx APITestContext, branch, userToWhitelistPush, userToWhitelistForcePush, unprotectedFilePatterns, protectedFilePatterns string) func(t *testing.T) {
return doProtectBranchExt(ctx, branch, doProtectBranchOptions{
UserToWhitelistPush: userToWhitelistPush,
UserToWhitelistForcePush: userToWhitelistForcePush,
UnprotectedFilePatterns: unprotectedFilePatterns,
ProtectedFilePatterns: protectedFilePatterns,
})
}
type doProtectBranchOptions struct {
UserToWhitelistPush, UserToWhitelistForcePush, UnprotectedFilePatterns, ProtectedFilePatterns string
StatusCheckPatterns []string
}
func doProtectBranchExt(ctx APITestContext, ruleName string, opts doProtectBranchOptions) func(t *testing.T) {
// We are going to just use the owner to set the protection. // We are going to just use the owner to set the protection.
return func(t *testing.T) { return func(t *testing.T) {
csrf := GetUserCSRFToken(t, ctx.Session) csrf := GetUserCSRFToken(t, ctx.Session)
formData := map[string]string{ formData := map[string]string{
"_csrf": csrf, "_csrf": csrf,
"rule_name": branch, "rule_name": ruleName,
"unprotected_file_patterns": unprotectedFilePatterns, "unprotected_file_patterns": opts.UnprotectedFilePatterns,
"protected_file_patterns": protectedFilePatterns, "protected_file_patterns": opts.ProtectedFilePatterns,
} }
if userToWhitelistPush != "" { if opts.UserToWhitelistPush != "" {
user, err := user_model.GetUserByName(db.DefaultContext, userToWhitelistPush) user, err := user_model.GetUserByName(db.DefaultContext, opts.UserToWhitelistPush)
assert.NoError(t, err) assert.NoError(t, err)
formData["whitelist_users"] = strconv.FormatInt(user.ID, 10) formData["whitelist_users"] = strconv.FormatInt(user.ID, 10)
formData["enable_push"] = "whitelist" formData["enable_push"] = "whitelist"
formData["enable_whitelist"] = "on" formData["enable_whitelist"] = "on"
} }
if userToWhitelistForcePush != "" { if opts.UserToWhitelistForcePush != "" {
user, err := user_model.GetUserByName(db.DefaultContext, userToWhitelistForcePush) user, err := user_model.GetUserByName(db.DefaultContext, opts.UserToWhitelistForcePush)
assert.NoError(t, err) assert.NoError(t, err)
formData["force_push_allowlist_users"] = strconv.FormatInt(user.ID, 10) formData["force_push_allowlist_users"] = strconv.FormatInt(user.ID, 10)
formData["enable_force_push"] = "whitelist" formData["enable_force_push"] = "whitelist"
formData["enable_force_push_allowlist"] = "on" formData["enable_force_push_allowlist"] = "on"
} }
if len(opts.StatusCheckPatterns) > 0 {
formData["enable_status_check"] = "on"
formData["status_check_contexts"] = strings.Join(opts.StatusCheckPatterns, "\n")
}
// Send the request to update branch protection settings // Send the request to update branch protection settings
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), formData) req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), formData)
ctx.Session.MakeRequest(t, req, http.StatusSeeOther) ctx.Session.MakeRequest(t, req, http.StatusSeeOther)
// Check if master branch has been locked successfully // Check if the "master" branch has been locked successfully
flashMsg := ctx.Session.GetCookieFlashMessage() flashMsg := ctx.Session.GetCookieFlashMessage()
assert.Equal(t, `Branch protection for rule "`+branch+`" has been updated.`, flashMsg.SuccessMsg) assert.Equal(t, `Branch protection for rule "`+ruleName+`" has been updated.`, flashMsg.SuccessMsg)
} }
} }
@@ -687,6 +708,10 @@ func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository) ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
// automerge will merge immediately if the PR is mergeable and there is no "status check" because no status check also means "all checks passed"
// so we must set up a status check to test the auto merge feature
doProtectBranchExt(ctx, "protected", doProtectBranchOptions{StatusCheckPatterns: []string{"*"}})(t)
t.Run("CheckoutProtected", doGitCheckoutBranch(dstPath, "protected")) t.Run("CheckoutProtected", doGitCheckoutBranch(dstPath, "protected"))
t.Run("PullProtected", doGitPull(dstPath, "origin", "protected")) t.Run("PullProtected", doGitPull(dstPath, "origin", "protected"))
t.Run("GenerateCommit", func(t *testing.T) { t.Run("GenerateCommit", func(t *testing.T) {
@@ -727,13 +752,13 @@ func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
// Cancel not existing auto merge // Cancel not existing auto merge
ctx.ExpectedCode = http.StatusNotFound ctx.ExpectedCode = http.StatusNotFound
t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) t.Run("CancelAutoMergePRNotExist", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
// Add auto merge request // Add auto merge request
ctx.ExpectedCode = http.StatusCreated ctx.ExpectedCode = http.StatusCreated
t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
// Can not create schedule twice // Cannot create schedule twice
ctx.ExpectedCode = http.StatusConflict ctx.ExpectedCode = http.StatusConflict
t.Run("AutoMergePRTwice", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) t.Run("AutoMergePRTwice", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))

View File

@@ -135,3 +135,105 @@ func TestAgitPullPush(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
}) })
} }
func TestAgitReviewStaleness(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
baseAPITestContext := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
u.Path = baseAPITestContext.GitPath()
u.User = url.UserPassword("user2", userPassword)
dstPath := t.TempDir()
doGitClone(dstPath, u)(t)
gitRepo, err := git.OpenRepository(t.Context(), dstPath)
assert.NoError(t, err)
defer gitRepo.Close()
doGitCreateBranch(dstPath, "test-agit-review")
// Create initial commit
_, err = generateCommitWithNewData(testFileSizeSmall, dstPath, "user2@example.com", "User Two", "initial-")
assert.NoError(t, err)
// create PR via agit
err = git.NewCommand("push", "origin",
"-o", "title=Test agit Review Staleness", "-o", "description=Testing review staleness",
"HEAD:refs/for/master/test-agit-review",
).Run(git.DefaultContext, &git.RunOpts{Dir: dstPath})
assert.NoError(t, err)
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
BaseRepoID: 1,
Flow: issues_model.PullRequestFlowAGit,
HeadBranch: "user2/test-agit-review",
})
assert.NoError(t, pr.LoadIssue(db.DefaultContext))
// Get initial commit ID for the review
initialCommitID := pr.HeadCommitID
t.Logf("Initial commit ID: %s", initialCommitID)
// Create a review on the PR (as user1 reviewing user2's PR)
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
review, err := issues_model.CreateReview(db.DefaultContext, issues_model.CreateReviewOptions{
Type: issues_model.ReviewTypeApprove,
Reviewer: reviewer,
Issue: pr.Issue,
CommitID: initialCommitID,
Content: "LGTM! Looks good to merge.",
Official: false,
})
assert.NoError(t, err)
assert.False(t, review.Stale, "New review should not be stale")
// Verify review exists and is not stale
reviews, err := issues_model.FindReviews(db.DefaultContext, issues_model.FindReviewOptions{
IssueID: pr.IssueID,
})
assert.NoError(t, err)
assert.Len(t, reviews, 1)
assert.Equal(t, initialCommitID, reviews[0].CommitID)
assert.False(t, reviews[0].Stale, "Review should not be stale initially")
// Create a new commit and update the agit PR
_, err = generateCommitWithNewData(testFileSizeSmall, dstPath, "user2@example.com", "User Two", "updated-")
assert.NoError(t, err)
err = git.NewCommand("push", "origin", "HEAD:refs/for/master/test-agit-review").Run(git.DefaultContext, &git.RunOpts{Dir: dstPath})
assert.NoError(t, err)
// Reload PR to get updated commit ID
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
BaseRepoID: 1,
Flow: issues_model.PullRequestFlowAGit,
HeadBranch: "user2/test-agit-review",
})
assert.NoError(t, pr.LoadIssue(db.DefaultContext))
// For AGit PRs, HeadCommitID must be loaded from git references
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo)
assert.NoError(t, err)
defer baseGitRepo.Close()
updatedCommitID, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
assert.NoError(t, err)
t.Logf("Updated commit ID: %s", updatedCommitID)
// Verify the PR was updated with new commit
assert.NotEqual(t, initialCommitID, updatedCommitID, "PR should have new commit ID after update")
// Check that the review is now marked as stale
reviews, err = issues_model.FindReviews(db.DefaultContext, issues_model.FindReviewOptions{
IssueID: pr.IssueID,
})
assert.NoError(t, err)
assert.Len(t, reviews, 1)
assert.True(t, reviews[0].Stale, "Review should be marked as stale after AGit PR update")
// The review commit ID should remain the same (pointing to the original commit)
assert.Equal(t, initialCommitID, reviews[0].CommitID, "Review commit ID should remain unchanged and point to original commit")
})
}

View File

@@ -10,12 +10,17 @@ import (
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
"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"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestOrgRepos(t *testing.T) { func TestOrgRepos(t *testing.T) {
@@ -217,4 +222,32 @@ func TestTeamSearch(t *testing.T) {
session = loginUser(t, user5.Name) session = loginUser(t, user5.Name)
req = NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "team") req = NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "team")
session.MakeRequest(t, req, http.StatusNotFound) session.MakeRequest(t, req, http.StatusNotFound)
t.Run("SearchWithPermission", func(t *testing.T) {
ctx := t.Context()
const testOrgID int64 = 500
const testRepoID int64 = 2000
testTeam := &organization.Team{OrgID: testOrgID, LowerName: "test_team", AccessMode: perm.AccessModeNone}
require.NoError(t, db.Insert(ctx, testTeam))
require.NoError(t, db.Insert(ctx, &organization.TeamRepo{OrgID: testOrgID, TeamID: testTeam.ID, RepoID: testRepoID}))
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: testOrgID, TeamID: testTeam.ID, Type: unit.TypeCode, AccessMode: perm.AccessModeRead}))
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: testOrgID, TeamID: testTeam.ID, Type: unit.TypeIssues, AccessMode: perm.AccessModeWrite}))
teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, testOrgID, testRepoID, perm.AccessModeRead, unit.TypeCode, unit.TypeIssues)
require.NoError(t, err)
assert.Len(t, teams, 1) // can read "code" or "issues"
teams, err = organization.GetTeamsWithAccessToAnyRepoUnit(ctx, testOrgID, testRepoID, perm.AccessModeWrite, unit.TypeCode)
require.NoError(t, err)
assert.Empty(t, teams) // cannot write "code"
teams, err = organization.GetTeamsWithAccessToAnyRepoUnit(ctx, testOrgID, testRepoID, perm.AccessModeWrite, unit.TypeIssues)
require.NoError(t, err)
assert.Len(t, teams, 1) // can write "issues"
_, _ = db.GetEngine(ctx).ID(testTeam.ID).Update(&organization.Team{AccessMode: perm.AccessModeWrite})
teams, err = organization.GetTeamsWithAccessToAnyRepoUnit(ctx, testOrgID, testRepoID, perm.AccessModeWrite, unit.TypeCode)
require.NoError(t, err)
assert.Len(t, teams, 1) // team permission is "write", so can write "code"
})
} }

View File

@@ -34,6 +34,7 @@ import (
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/automerge"
"code.gitea.io/gitea/services/automergequeue"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
@@ -726,7 +727,7 @@ func TestPullAutoMergeAfterCommitStatusSucceed(t *testing.T) {
// add protected branch for commit status // add protected branch for commit status
csrf := GetUserCSRFToken(t, session) csrf := GetUserCSRFToken(t, session)
// Change master branch to protected // Change the "master" branch to "protected"
req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
"_csrf": csrf, "_csrf": csrf,
"rule_name": "master", "rule_name": "master",
@@ -736,10 +737,22 @@ func TestPullAutoMergeAfterCommitStatusSucceed(t *testing.T) {
}) })
session.MakeRequest(t, req, http.StatusSeeOther) session.MakeRequest(t, req, http.StatusSeeOther)
oldAutoMergeAddToQueue := automergequeue.AddToQueue
addToQueueShaChan := make(chan string, 1)
automergequeue.AddToQueue = func(pr *issues_model.PullRequest, sha string) {
addToQueueShaChan <- sha
}
// first time insert automerge record, return true // first time insert automerge record, return true
scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false) scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, scheduled) assert.True(t, scheduled)
// and the pr should be added to automergequeue, in case it is already "mergeable"
select {
case <-addToQueueShaChan:
case <-time.After(time.Second):
assert.FailNow(t, "Timeout: nothing was added to automergequeue")
}
automergequeue.AddToQueue = oldAutoMergeAddToQueue
// second time insert automerge record, return false because it does exist // second time insert automerge record, return false because it does exist
scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false) scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test", false)
@@ -774,13 +787,11 @@ func TestPullAutoMergeAfterCommitStatusSucceed(t *testing.T) {
}) })
assert.NoError(t, err) assert.NoError(t, err)
time.Sleep(2 * time.Second) assert.Eventually(t, func() bool {
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
// realod pr again return pr.HasMerged
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) }, 2*time.Second, 100*time.Millisecond)
assert.True(t, pr.HasMerged)
assert.NotEmpty(t, pr.MergedCommitID) assert.NotEmpty(t, pr.MergedCommitID)
unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{PullID: pr.ID}) unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{PullID: pr.ID})
}) })
} }

View File

@@ -23,39 +23,59 @@ import (
func TestRepoCommits(t *testing.T) { func TestRepoCommits(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2") session := loginUser(t, "user2")
// Request repository commits page t.Run("CommitList", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master") req := NewRequest(t, "GET", "/user2/repo16/commits/branch/master")
resp := session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body) var commits, userHrefs []string
commitURL, exists := doc.doc.Find("#commits-table .commit-id-short").Attr("href") doc := NewHTMLParser(t, resp.Body)
assert.True(t, exists) doc.doc.Find("#commits-table .commit-id-short").Each(func(i int, s *goquery.Selection) {
assert.NotEmpty(t, commitURL) commits = append(commits, path.Base(s.AttrOr("href", "")))
} })
doc.doc.Find("#commits-table .author-wrapper").Each(func(i int, s *goquery.Selection) {
func Test_ReposGitCommitListNotMaster(t *testing.T) { userHrefs = append(userHrefs, s.AttrOr("href", ""))
defer tests.PrepareTestEnv(t)() })
session := loginUser(t, "user2") assert.Equal(t, []string{"69554a64c1e6030f051e5c3f94bfbd773cd6a324", "27566bd5738fc8b4e3fef3c5e72cce608537bd95", "5099b81332712fe655e34e8dd63574f503f61811"}, commits)
req := NewRequest(t, "GET", "/user2/repo16/commits/branch/master") assert.Equal(t, []string{"/user2", "/user21", "/user2"}, userHrefs)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
var commits []string
doc.doc.Find("#commits-table .commit-id-short").Each(func(i int, s *goquery.Selection) {
commitURL, _ := s.Attr("href")
commits = append(commits, path.Base(commitURL))
}) })
assert.Equal(t, []string{"69554a64c1e6030f051e5c3f94bfbd773cd6a324", "27566bd5738fc8b4e3fef3c5e72cce608537bd95", "5099b81332712fe655e34e8dd63574f503f61811"}, commits)
var userHrefs []string t.Run("LastCommit", func(t *testing.T) {
doc.doc.Find("#commits-table .author-wrapper").Each(func(i int, s *goquery.Selection) { req := NewRequest(t, "GET", "/user2/repo16")
userHref, _ := s.Attr("href") resp := session.MakeRequest(t, req, http.StatusOK)
userHrefs = append(userHrefs, userHref) doc := NewHTMLParser(t, resp.Body)
commitHref := doc.doc.Find(".latest-commit .commit-id-short").AttrOr("href", "")
authorHref := doc.doc.Find(".latest-commit .author-wrapper").AttrOr("href", "")
assert.Equal(t, "/user2/repo16/commit/69554a64c1e6030f051e5c3f94bfbd773cd6a324", commitHref)
assert.Equal(t, "/user2", authorHref)
})
t.Run("CommitListNonExistingCommiter", func(t *testing.T) {
// check the commit list for a repository with no gitea user
// * commit 985f0301dba5e7b34be866819cd15ad3d8f508ee (branch2)
// * Author: 6543 <6543@obermui.de>
req := NewRequest(t, "GET", "/user2/repo1/commits/branch/branch2")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
commitHref := doc.doc.Find("#commits-table tr:first-child .commit-id-short").AttrOr("href", "")
assert.Equal(t, "/user2/repo1/commit/985f0301dba5e7b34be866819cd15ad3d8f508ee", commitHref)
authorElem := doc.doc.Find("#commits-table tr:first-child .author-wrapper")
assert.Equal(t, "6543", authorElem.Text())
assert.Equal(t, "span", authorElem.Nodes[0].Data)
})
t.Run("LastCommitNonExistingCommiter", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/src/branch/branch2")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
commitHref := doc.doc.Find(".latest-commit .commit-id-short").AttrOr("href", "")
assert.Equal(t, "/user2/repo1/commit/985f0301dba5e7b34be866819cd15ad3d8f508ee", commitHref)
authorElem := doc.doc.Find(".latest-commit .author-wrapper")
assert.Equal(t, "6543", authorElem.Text())
assert.Equal(t, "span", authorElem.Nodes[0].Data)
}) })
assert.Equal(t, []string{"/user2", "/user21", "/user2"}, userHrefs)
} }
func doTestRepoCommitWithStatus(t *testing.T, state string, classes ...string) { func doTestRepoCommitWithStatus(t *testing.T, state string, classes ...string) {

View File

@@ -71,11 +71,13 @@
#git-graph-container li .author { #git-graph-container li .author {
color: var(--color-text-light); color: var(--color-text-light);
flex-shrink: 0;
} }
#git-graph-container li .time { #git-graph-container li .time {
color: var(--color-text-light-3); color: var(--color-text-light-3);
font-size: 80%; font-size: 80%;
flex-shrink: 0;
} }
#git-graph-container li a:not(.ui):hover { #git-graph-container li a:not(.ui):hover {
@@ -109,6 +111,10 @@
background-color: var(--color-secondary-alpha-30); background-color: var(--color-secondary-alpha-30);
} }
#git-graph-container #rev-list .commit-refs {
flex-shrink: 0;
}
#git-graph-container #rev-list .commit-refs .button { #git-graph-container #rev-list .commit-refs .button {
padding: 2px 4px; padding: 2px 4px;
margin-right: 0.25em; margin-right: 0.25em;

View File

@@ -5,6 +5,7 @@
flex-wrap: nowrap; flex-wrap: nowrap;
overflow: auto; overflow: auto;
margin: 0 0.5em; margin: 0 0.5em;
min-height: max(calc(100vh - 400px), 300px);
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
} }
@@ -70,7 +71,7 @@
.card-attachment-images { .card-attachment-images {
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;
overflow: scroll; overflow: auto;
cursor: default; cursor: default;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
text-align: center; text-align: center;
@@ -84,6 +85,7 @@
scroll-snap-align: center; scroll-snap-align: center;
margin-right: 2px; margin-right: 2px;
aspect-ratio: 1; aspect-ratio: 1;
object-fit: contain;
} }
.card-attachment-images img:only-child { .card-attachment-images img:only-child {

View File

@@ -71,7 +71,7 @@
#repo-files-table .repo-file-cell.name .entry-name { #repo-files-table .repo-file-cell.name .entry-name {
flex-shrink: 1; flex-shrink: 1;
min-width: 3em; min-width: 1ch; /* leave about one letter space when shrinking, need to fine tune the "shrinks" in this grid in the future */
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {

View File

@@ -70,8 +70,12 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
#release-list .release-entry .attachment-list > .item a {
min-width: 300px;
}
#release-list .release-entry .attachment-list .attachment-right-info { #release-list .release-entry .attachment-list .attachment-right-info {
flex-grow: 1; flex-shrink: 0;
min-width: 300px; min-width: 300px;
} }

View File

@@ -38,7 +38,7 @@ export default defineComponent({
return { return {
tab, tab,
repos: [], repos: [],
reposTotalCount: 0, reposTotalCount: null,
reposFilter, reposFilter,
archivedFilter, archivedFilter,
privateFilter, privateFilter,
@@ -112,9 +112,6 @@ export default defineComponent({
const el = document.querySelector('#dashboard-repo-list'); const el = document.querySelector('#dashboard-repo-list');
this.changeReposFilter(this.reposFilter); this.changeReposFilter(this.reposFilter);
fomanticQuery(el.querySelector('.ui.dropdown')).dropdown(); fomanticQuery(el.querySelector('.ui.dropdown')).dropdown();
nextTick(() => {
this.$refs.search?.focus();
});
this.textArchivedFilterTitles = { this.textArchivedFilterTitles = {
'archived': this.textShowOnlyArchived, 'archived': this.textShowOnlyArchived,
@@ -242,12 +239,20 @@ export default defineComponent({
let response, json; let response, json;
try { try {
const firstLoad = this.reposTotalCount === null;
if (!this.reposTotalCount) { if (!this.reposTotalCount) {
const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
response = await GET(totalCountSearchURL); response = await GET(totalCountSearchURL);
this.reposTotalCount = parseInt(response.headers.get('X-Total-Count') ?? '0'); this.reposTotalCount = parseInt(response.headers.get('X-Total-Count') ?? '0');
} }
if (firstLoad && this.reposTotalCount) {
nextTick(() => {
// MDN: If there's no focused element, this is the Document.body or Document.documentElement.
if ((document.activeElement === document.body || document.activeElement === document.documentElement)) {
this.$refs.search.focus({preventScroll: true});
}
});
}
response = await GET(searchedURL); response = await GET(searchedURL);
json = await response.json(); json = await response.json();
} catch { } catch {
@@ -349,7 +354,7 @@ export default defineComponent({
<h4 class="ui top attached header tw-flex tw-items-center"> <h4 class="ui top attached header tw-flex tw-items-center">
<div class="tw-flex-1 tw-flex tw-items-center"> <div class="tw-flex-1 tw-flex tw-items-center">
{{ textMyRepos }} {{ textMyRepos }}
<span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> <span v-if="reposTotalCount" class="ui grey label tw-ml-2">{{ reposTotalCount }}</span>
</div> </div>
<a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo"> <a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo">
<svg-icon name="octicon-plus"/> <svg-icon name="octicon-plus"/>
@@ -420,7 +425,7 @@ export default defineComponent({
</div> </div>
<div v-if="repos.length" class="ui attached table segment tw-rounded-b"> <div v-if="repos.length" class="ui attached table segment tw-rounded-b">
<ul class="repo-owner-name-list"> <ul class="repo-owner-name-list">
<li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id"> <li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id">
<a class="repo-list-link muted" :href="repo.link"> <a class="repo-list-link muted" :href="repo.link">
<svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/> <svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/>
<div class="text truncate">{{ repo.full_name }}</div> <div class="text truncate">{{ repo.full_name }}</div>

View File

@@ -22,13 +22,6 @@ function getIconForDiffStatus(pType: DiffStatus) {
}; };
return diffTypes[pType] ?? diffTypes['']; return diffTypes[pType] ?? diffTypes[''];
} }
function entryIcon(entry: DiffTreeEntry) {
if (entry.EntryMode === 'commit') {
return 'octicon-file-submodule';
}
return 'octicon-file';
}
</script> </script>
<template> <template>
@@ -36,10 +29,8 @@ function entryIcon(entry: DiffTreeEntry) {
<div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed"> <div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed">
<!-- directory --> <!-- directory -->
<SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/> <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
<SvgIcon <!-- eslint-disable-next-line vue/no-v-html -->
class="text primary" <span class="tw-contents" v-html="collapsed ? store.folderIcon : store.folderOpenIcon"/>
:name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"
/>
<span class="gt-ellipsis">{{ item.DisplayName }}</span> <span class="gt-ellipsis">{{ item.DisplayName }}</span>
</div> </div>
@@ -53,7 +44,8 @@ function entryIcon(entry: DiffTreeEntry) {
:title="item.DisplayName" :href="'#diff-' + item.NameHash" :title="item.DisplayName" :href="'#diff-' + item.NameHash"
> >
<!-- file --> <!-- file -->
<SvgIcon :name="entryIcon(item)"/> <!-- eslint-disable-next-line vue/no-v-html -->
<span class="tw-contents" v-html="item.FileIcon"/>
<span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span> <span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span>
<SvgIcon <SvgIcon
:name="getIconForDiffStatus(item.DiffStatus).name" :name="getIconForDiffStatus(item.DiffStatus).name"

View File

@@ -1,12 +1,23 @@
import {assignElementProperty} from './common-button.ts'; import {assignElementProperty, type ElementWithAssignableProperties} from './common-button.ts';
test('assignElementProperty', () => { test('assignElementProperty', () => {
const elForm = document.createElement('form'); const elForm = document.createElement('form');
assignElementProperty(elForm, 'action', '/test-link'); assignElementProperty(elForm, 'action', '/test-link');
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
expect(elForm.getAttribute('action')).eq('/test-link');
assignElementProperty(elForm, 'text-content', 'dummy'); assignElementProperty(elForm, 'text-content', 'dummy');
expect(elForm.textContent).toBe('dummy'); expect(elForm.textContent).toBe('dummy');
// mock a form with its property "action" overwritten by an input element
const elFormWithAction = new class implements ElementWithAssignableProperties {
action = document.createElement('input'); // now "form.action" is not string, but an input element
_attrs: Record<string, string> = {};
setAttribute(name: string, value: string) { this._attrs[name] = value }
getAttribute(name: string): string | null { return this._attrs[name] }
}();
assignElementProperty(elFormWithAction, 'action', '/bar');
expect(elFormWithAction.getAttribute('action')).eq('/bar');
const elInput = document.createElement('input'); const elInput = document.createElement('input');
expect(elInput.readOnly).toBe(false); expect(elInput.readOnly).toBe(false);
assignElementProperty(elInput, 'read-only', 'true'); assignElementProperty(elInput, 'read-only', 'true');

View File

@@ -102,18 +102,26 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
} }
export function assignElementProperty(el: any, name: string, val: string) { export type ElementWithAssignableProperties = {
name = camelize(name); getAttribute: (name: string) => string | null;
const old = el[name]; setAttribute: (name: string, value: string) => void;
} & Record<string, any>
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
const camelizedName = camelize(kebabName);
const old = el[camelizedName];
if (typeof old === 'boolean') { if (typeof old === 'boolean') {
el[name] = val === 'true'; el[camelizedName] = val === 'true';
} else if (typeof old === 'number') { } else if (typeof old === 'number') {
el[name] = parseFloat(val); el[camelizedName] = parseFloat(val);
} else if (typeof old === 'string') { } else if (typeof old === 'string') {
el[name] = val; el[camelizedName] = val;
} else if (old?.nodeName) {
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
el.setAttribute(kebabName, val);
} else { } else {
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."` // in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
throw new Error(`cannot assign element property ${name} by value ${val}`); throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
} }
} }

View File

@@ -84,9 +84,9 @@ export function initRepoGraphGit() {
const flowSelectRefsDropdown = document.querySelector('#flow-select-refs-dropdown'); const flowSelectRefsDropdown = document.querySelector('#flow-select-refs-dropdown');
const $dropdown = fomanticQuery(flowSelectRefsDropdown); const $dropdown = fomanticQuery(flowSelectRefsDropdown);
$dropdown.dropdown({clearable: true});
$dropdown.dropdown('set selected', dropdownSelected);
$dropdown.dropdown({ $dropdown.dropdown({
clearable: true,
fullTextSeach: 'exact',
onRemove(toRemove: string) { onRemove(toRemove: string) {
if (toRemove === '...flow-hide-pr-refs') { if (toRemove === '...flow-hide-pr-refs') {
params.delete('hide-pr-refs'); params.delete('hide-pr-refs');
@@ -110,7 +110,6 @@ export function initRepoGraphGit() {
updateGraph(); updateGraph();
}, },
}); });
$dropdown.dropdown('set selected', dropdownSelected);
graphContainer.addEventListener('mouseenter', (e: DOMEvent<MouseEvent>) => { graphContainer.addEventListener('mouseenter', (e: DOMEvent<MouseEvent>) => {
if (e.target.matches('#rev-list li')) { if (e.target.matches('#rev-list li')) {

View File

@@ -1,6 +1,6 @@
import {minimatch} from 'minimatch'; import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.ts'; import {createMonaco} from './codeeditor.ts';
import {onInputDebounce, queryElems, toggleClass, toggleElem} from '../utils/dom.ts'; import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts'; import {POST} from '../modules/fetch.ts';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts'; import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts';
@@ -124,14 +124,18 @@ function initRepoSettingsOptions() {
const pageContent = document.querySelector('.page-content.repository.settings.options'); const pageContent = document.querySelector('.page-content.repository.settings.options');
if (!pageContent) return; if (!pageContent) return;
// Enable or select internal/external wiki system and issue tracker. // toggle related panels for the checkbox/radio inputs, the "selector" may not exist
const toggleTargetContextPanel = (selector: string, enabled: boolean) => {
if (!selector) return;
queryElems(document, selector, (el) => el.classList.toggle('disabled', !enabled));
};
queryElems<HTMLInputElement>(pageContent, '.enable-system', (el) => el.addEventListener('change', () => { queryElems<HTMLInputElement>(pageContent, '.enable-system', (el) => el.addEventListener('change', () => {
toggleClass(el.getAttribute('data-target'), 'disabled', !el.checked); toggleTargetContextPanel(el.getAttribute('data-target'), el.checked);
toggleClass(el.getAttribute('data-context'), 'disabled', el.checked); toggleTargetContextPanel(el.getAttribute('data-context'), !el.checked);
})); }));
queryElems<HTMLInputElement>(pageContent, '.enable-system-radio', (el) => el.addEventListener('change', () => { queryElems<HTMLInputElement>(pageContent, '.enable-system-radio', (el) => el.addEventListener('change', () => {
toggleClass(el.getAttribute('data-target'), 'disabled', el.value === 'false'); toggleTargetContextPanel(el.getAttribute('data-target'), el.value === 'true');
toggleClass(el.getAttribute('data-context'), 'disabled', el.value === 'true'); toggleTargetContextPanel(el.getAttribute('data-context'), el.value === 'false');
})); }));
queryElems<HTMLInputElement>(pageContent, '.js-tracker-issue-style', (el) => el.addEventListener('change', () => { queryElems<HTMLInputElement>(pageContent, '.js-tracker-issue-style', (el) => el.addEventListener('change', () => {

View File

@@ -9,6 +9,7 @@ test('diff-tree', () => {
'IsViewed': false, 'IsViewed': false,
'NameHash': '....', 'NameHash': '....',
'DiffStatus': '', 'DiffStatus': '',
'FileIcon': '',
'Children': [ 'Children': [
{ {
'FullName': 'dir1', 'FullName': 'dir1',
@@ -17,6 +18,7 @@ test('diff-tree', () => {
'IsViewed': false, 'IsViewed': false,
'NameHash': '....', 'NameHash': '....',
'DiffStatus': '', 'DiffStatus': '',
'FileIcon': '',
'Children': [ 'Children': [
{ {
'FullName': 'dir1/test.txt', 'FullName': 'dir1/test.txt',
@@ -25,6 +27,7 @@ test('diff-tree', () => {
'NameHash': '....', 'NameHash': '....',
'EntryMode': '', 'EntryMode': '',
'IsViewed': false, 'IsViewed': false,
'FileIcon': '',
'Children': null, 'Children': null,
}, },
], ],
@@ -36,11 +39,12 @@ test('diff-tree', () => {
'DiffStatus': 'added', 'DiffStatus': 'added',
'EntryMode': '', 'EntryMode': '',
'IsViewed': false, 'IsViewed': false,
'FileIcon': '',
'Children': null, 'Children': null,
}, },
], ],
}, },
}); }, '', '');
diffTreeStoreSetViewed(store, 'dir1/test.txt', true); diffTreeStoreSetViewed(store, 'dir1/test.txt', true);
expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true); expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true);
expect(store.fullNameMap['dir1'].IsViewed).toBe(true); expect(store.fullNameMap['dir1'].IsViewed).toBe(true);

View File

@@ -13,7 +13,7 @@ export type DiffTreeEntry = {
EntryMode: string, EntryMode: string,
IsViewed: boolean, IsViewed: boolean,
Children: DiffTreeEntry[], Children: DiffTreeEntry[],
FileIcon: string,
ParentEntry?: DiffTreeEntry, ParentEntry?: DiffTreeEntry,
} }
@@ -22,6 +22,8 @@ type DiffFileTreeData = {
}; };
type DiffFileTree = { type DiffFileTree = {
folderIcon: string;
folderOpenIcon: string;
diffFileTree: DiffFileTreeData; diffFileTree: DiffFileTreeData;
fullNameMap?: Record<string, DiffTreeEntry> fullNameMap?: Record<string, DiffTreeEntry>
fileTreeIsVisible: boolean; fileTreeIsVisible: boolean;
@@ -31,7 +33,7 @@ type DiffFileTree = {
let diffTreeStoreReactive: Reactive<DiffFileTree>; let diffTreeStoreReactive: Reactive<DiffFileTree>;
export function diffTreeStore() { export function diffTreeStore() {
if (!diffTreeStoreReactive) { if (!diffTreeStoreReactive) {
diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree); diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree, pageData.FolderIcon, pageData.FolderOpenIcon);
} }
return diffTreeStoreReactive; return diffTreeStoreReactive;
} }
@@ -55,9 +57,11 @@ function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntr
} }
} }
export function reactiveDiffTreeStore(data: DiffFileTreeData): Reactive<DiffFileTree> { export function reactiveDiffTreeStore(data: DiffFileTreeData, folderIcon: string, folderOpenIcon: string): Reactive<DiffFileTree> {
const store = reactive({ const store = reactive({
diffFileTree: data, diffFileTree: data,
folderIcon,
folderOpenIcon,
fileTreeIsVisible: false, fileTreeIsVisible: false,
selectedItem: '', selectedItem: '',
fullNameMap: {}, fullNameMap: {},