feat(ui): add links to review request targets in issue comments (#8239)

- Add links to review request targets in issue comments
- Fix links to ghost users/orgs/teams to be empty

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8239
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Robert Wolff <mahlzahn@posteo.de>
Co-committed-by: Robert Wolff <mahlzahn@posteo.de>
This commit is contained in:
Robert Wolff 2025-07-23 04:45:58 +02:00 committed by Gusted
commit 7643bdd2b5
11 changed files with 256 additions and 21 deletions

View file

@ -4,6 +4,7 @@
package issues
import (
"context"
"slices"
"forgejo.org/models/organization"
@ -374,3 +375,10 @@ func (t *RequestReviewTarget) Type() string {
}
return "team"
}
func (t *RequestReviewTarget) Link(ctx context.Context) string {
if t.User != nil {
return t.User.HomeLink()
}
return t.Team.Link(ctx)
}

View file

@ -0,0 +1,37 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package issues
import (
"testing"
"forgejo.org/models/db"
org_model "forgejo.org/models/organization"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"github.com/stretchr/testify/assert"
)
func TestRequestReviewTarget(t *testing.T) {
unittest.PrepareTestEnv(t)
target := RequestReviewTarget{User: &user_model.User{ID: 1, Name: "user1"}}
assert.Equal(t, int64(1), target.ID())
assert.Equal(t, "user1", target.Name())
assert.Equal(t, "user", target.Type())
assert.Equal(t, "/user1", target.Link(db.DefaultContext))
target = RequestReviewTarget{Team: &org_model.Team{ID: 2, Name: "Collaborators", OrgID: 3}}
assert.Equal(t, int64(2), target.ID())
assert.Equal(t, "Collaborators", target.Name())
assert.Equal(t, "team", target.Type())
assert.Equal(t, "/org/org3/teams/Collaborators", target.Link(db.DefaultContext))
target = RequestReviewTarget{Team: org_model.NewGhostTeam()}
assert.Equal(t, int64(-1), target.ID())
assert.Equal(t, "Ghost team", target.Name())
assert.Equal(t, "team", target.Type())
assert.Empty(t, target.Link(db.DefaultContext))
}

View file

@ -186,6 +186,11 @@ func (org *Organization) CanCreateRepo() bool {
return org.AsUser().CanCreateRepo()
}
// IsGhost returns if the organization is a ghost
func (org *Organization) IsGhost() bool {
return org.AsUser().IsGhost()
}
// FindOrgMembersOpts represensts find org members conditions
type FindOrgMembersOpts struct {
db.ListOptions

View file

@ -1,5 +1,6 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2016 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package organization
@ -7,6 +8,7 @@ package organization
import (
"context"
"fmt"
"net/url"
"strings"
"forgejo.org/models/db"
@ -20,13 +22,6 @@ import (
"xorm.io/builder"
)
// ___________
// \__ ___/___ _____ _____
// | |_/ __ \\__ \ / \
// | |\ ___/ / __ \| Y Y \
// |____| \___ >____ /__|_| /
// \/ \/ \/
// ErrTeamAlreadyExist represents a "TeamAlreadyExist" kind of error.
type ErrTeamAlreadyExist struct {
OrgID int64
@ -193,6 +188,27 @@ func (t *Team) UnitAccessMode(ctx context.Context, tp unit.Type) perm.AccessMode
return perm.AccessModeNone
}
// GetOrg returns the team's organization
func (t *Team) GetOrg(ctx context.Context) *Organization {
org, err := GetOrgByID(ctx, t.OrgID)
if err != nil {
return OrgFromUser(user_model.NewGhostUser())
}
return org
}
// Link returns the team's page link
func (t *Team) Link(ctx context.Context) string {
if t.IsGhost() {
return ""
}
org := t.GetOrg(ctx)
if org.IsGhost() {
return ""
}
return org.OrganisationLink() + "/teams/" + url.PathEscape(t.Name)
}
// IsUsableTeamName tests if a name could be as team name
func IsUsableTeamName(name string) error {
switch name {
@ -293,10 +309,22 @@ func FixInconsistentOwnerTeams(ctx context.Context) (int64, error) {
return int64(len(teamIDs)), nil
}
const (
GhostTeamID = -1
GhostTeamName = "Ghost team"
GhostTeamLowerName = "ghost team"
)
// NewGhostTeam creates ghost team (for deleted team)
func NewGhostTeam() *Team {
return &Team{
ID: -1,
Name: "Ghost team",
LowerName: "ghost team",
ID: GhostTeamID,
Name: GhostTeamName,
LowerName: GhostTeamLowerName,
}
}
// IsGhost returns if a team is a ghost team
func (t *Team) IsGhost() bool {
return t.ID == GhostTeamID
}

View file

@ -1,4 +1,5 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package organization_test
@ -15,14 +16,33 @@ import (
"github.com/stretchr/testify/require"
)
func TestTeam_IsOwnerTeam(t *testing.T) {
func TestTeam(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
assert.True(t, team.IsOwnerTeam())
owners := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
assert.Equal(t, int64(3), owners.GetOrg(db.DefaultContext).ID)
assert.Equal(t, "/org/org3/teams/Owners", owners.Link(db.DefaultContext))
assert.False(t, owners.IsGhost())
assert.True(t, owners.IsOwnerTeam())
team = unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
assert.False(t, team.IsOwnerTeam())
team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
assert.Equal(t, int64(3), team1.GetOrg(db.DefaultContext).ID)
assert.Equal(t, "/org/org3/teams/team1", team1.Link(db.DefaultContext))
assert.False(t, team1.IsGhost())
assert.False(t, team1.IsOwnerTeam())
ghost := organization.NewGhostTeam()
assert.Equal(t, int64(-1), ghost.ID)
assert.Equal(t, int64(-1), ghost.GetOrg(db.DefaultContext).ID)
assert.Empty(t, ghost.Link(db.DefaultContext))
assert.True(t, ghost.IsGhost())
assert.False(t, ghost.IsOwnerTeam())
ghosted := organization.Team{ID: 10, Name: "Ghosted"}
assert.Equal(t, int64(-1), ghosted.GetOrg(db.DefaultContext).ID)
assert.Empty(t, ghosted.Link(db.DefaultContext))
assert.False(t, ghosted.IsGhost())
assert.False(t, ghosted.IsOwnerTeam())
}
func TestTeam_IsMember(t *testing.T) {

View file

@ -296,6 +296,9 @@ func (u *User) CanImportLocal() bool {
// DashboardLink returns the user dashboard page link.
func (u *User) DashboardLink() string {
if u.IsGhost() {
return ""
}
if u.IsOrganization() {
return u.OrganisationLink() + "/dashboard"
}
@ -304,16 +307,25 @@ func (u *User) DashboardLink() string {
// HomeLink returns the user or organization home page link.
func (u *User) HomeLink() string {
if u.IsGhost() {
return ""
}
return setting.AppSubURL + "/" + url.PathEscape(u.Name)
}
// HTMLURL returns the user or organization's full link.
func (u *User) HTMLURL() string {
if u.IsGhost() {
return ""
}
return setting.AppURL + url.PathEscape(u.Name)
}
// OrganisationLink returns the organization sub page link.
func (u *User) OrganisationLink() string {
if u.IsGhost() || !u.IsOrganization() {
return ""
}
return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)
}

View file

@ -46,6 +46,28 @@ func TestIsValidUserID(t *testing.T) {
assert.True(t, user_model.IsValidUserID(200))
}
func TestUserLinks(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.Equal(t, "/", user1.DashboardLink())
assert.Equal(t, "/user1", user1.HomeLink())
assert.Equal(t, "https://try.gitea.io/user1", user1.HTMLURL())
assert.Empty(t, user1.OrganisationLink())
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
assert.Equal(t, "/org/org3/dashboard", org3.DashboardLink())
assert.Equal(t, "/org3", org3.HomeLink())
assert.Equal(t, "https://try.gitea.io/org3", org3.HTMLURL())
assert.Equal(t, "/org/org3", org3.OrganisationLink())
ghost := user_model.NewGhostUser()
assert.Empty(t, ghost.DashboardLink())
assert.Empty(t, ghost.HomeLink())
assert.Empty(t, ghost.HTMLURL())
assert.Empty(t, ghost.OrganisationLink())
}
func TestGetUserFromMap(t *testing.T) {
id := int64(200)
idMap := map[int64]*user_model.User{