feat(package): gitea: add private heatmap opt-in

This commit is contained in:
2026-06-06 20:06:21 +00:00
parent 593c0d9abc
commit 9906f71c5d
11 changed files with 215 additions and 42 deletions
+6 -4
View File
@@ -80,8 +80,9 @@ type UserSettings struct {
Theme string `json:"theme"` Theme string `json:"theme"`
DiffViewStyle string `json:"diff_view_style"` DiffViewStyle string `json:"diff_view_style"`
// Privacy // Privacy
HideEmail bool `json:"hide_email"` HideEmail bool `json:"hide_email"`
HideActivity bool `json:"hide_activity"` HideActivity bool `json:"hide_activity"`
IncludePrivateContributions bool `json:"include_private_contributions"`
} }
// UserSettingsOptions represents options to change user settings // UserSettingsOptions represents options to change user settings
@@ -95,8 +96,9 @@ type UserSettingsOptions struct {
Theme *string `json:"theme"` Theme *string `json:"theme"`
DiffViewStyle *string `json:"diff_view_style"` DiffViewStyle *string `json:"diff_view_style"`
// Privacy // Privacy
HideEmail *bool `json:"hide_email"` HideEmail *bool `json:"hide_email"`
HideActivity *bool `json:"hide_activity"` HideActivity *bool `json:"hide_activity"`
IncludePrivateContributions *bool `json:"include_private_contributions"`
} }
// RenameUserOption options when renaming a user // RenameUserOption options when renaming a user
@@ -708,6 +708,8 @@
"settings.privacy": "Privacy", "settings.privacy": "Privacy",
"settings.keep_activity_private": "Hide Activity from profile page", "settings.keep_activity_private": "Hide Activity from profile page",
"settings.keep_activity_private_popup": "Makes the activity visible only for you and the admins", "settings.keep_activity_private_popup": "Makes the activity visible only for you and the admins",
"settings.include_private_contributions": "Show private contributions on profile heatmap",
"settings.include_private_contributions_popup": "Publicly reveals only contribution dates and counts from non-public repositories, never repository or commit details.",
"settings.lookup_avatar_by_mail": "Look Up Avatar by Email Address", "settings.lookup_avatar_by_mail": "Look Up Avatar by Email Address",
"settings.federated_avatar_lookup": "Federated Avatar Lookup", "settings.federated_avatar_lookup": "Federated Avatar Lookup",
"settings.enable_custom_avatar": "Use Custom Avatar", "settings.enable_custom_avatar": "Use Custom Avatar",
@@ -24,7 +24,12 @@ func GetUserSettings(ctx *context.APIContext) {
// responses: // responses:
// "200": // "200":
// "$ref": "#/responses/UserSettings" // "$ref": "#/responses/UserSettings"
ctx.JSON(http.StatusOK, convert.User2UserSettings(ctx.Doer)) settings, err := convert.User2UserSettings(ctx, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, settings)
} }
// UpdateUserSettings returns user settings // UpdateUserSettings returns user settings
@@ -46,20 +51,26 @@ func UpdateUserSettings(ctx *context.APIContext) {
form := web.GetForm(ctx).(*api.UserSettingsOptions) form := web.GetForm(ctx).(*api.UserSettingsOptions)
opts := &user_service.UpdateOptions{ opts := &user_service.UpdateOptions{
FullName: optional.FromPtr(form.FullName), FullName: optional.FromPtr(form.FullName),
Description: optional.FromPtr(form.Description), Description: optional.FromPtr(form.Description),
Website: optional.FromPtr(form.Website), Website: optional.FromPtr(form.Website),
Location: optional.FromPtr(form.Location), Location: optional.FromPtr(form.Location),
Language: optional.FromPtr(form.Language), Language: optional.FromPtr(form.Language),
Theme: optional.FromPtr(form.Theme), Theme: optional.FromPtr(form.Theme),
DiffViewStyle: optional.FromPtr(form.DiffViewStyle), DiffViewStyle: optional.FromPtr(form.DiffViewStyle),
KeepEmailPrivate: optional.FromPtr(form.HideEmail), KeepEmailPrivate: optional.FromPtr(form.HideEmail),
KeepActivityPrivate: optional.FromPtr(form.HideActivity), KeepActivityPrivate: optional.FromPtr(form.HideActivity),
IncludePrivateContributions: optional.FromPtr(form.IncludePrivateContributions),
} }
if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
ctx.JSON(http.StatusOK, convert.User2UserSettings(ctx.Doer)) settings, err := convert.User2UserSettings(ctx, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, settings)
} }
@@ -48,16 +48,32 @@ func Profile(ctx *context.Context) {
ctx.Data["PageIsSettingsProfile"] = true ctx.Data["PageIsSettingsProfile"] = true
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
if !loadProfilePrivateContributionSetting(ctx) {
return
}
ctx.HTML(http.StatusOK, tplSettingsProfile) ctx.HTML(http.StatusOK, tplSettingsProfile)
} }
func loadProfilePrivateContributionSetting(ctx *context.Context) bool {
includePrivateContributions, err := user_model.GetIncludePrivateContributions(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetIncludePrivateContributions", err)
return false
}
ctx.Data["IncludePrivateContributions"] = includePrivateContributions
return true
}
// ProfilePost response for change user's profile // ProfilePost response for change user's profile
func ProfilePost(ctx *context.Context) { func ProfilePost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings_title") ctx.Data["Title"] = ctx.Tr("settings_title")
ctx.Data["PageIsSettingsProfile"] = true ctx.Data["PageIsSettingsProfile"] = true
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
if !loadProfilePrivateContributionSetting(ctx) {
return
}
if ctx.HasError() { if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSettingsProfile) ctx.HTML(http.StatusOK, tplSettingsProfile)
@@ -94,12 +110,13 @@ func ProfilePost(ctx *context.Context) {
} }
opts := &user_service.UpdateOptions{ opts := &user_service.UpdateOptions{
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate), KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
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.Some(form.Visibility), Visibility: optional.Some(form.Visibility),
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate), KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
IncludePrivateContributions: optional.Some(form.IncludePrivateContributions),
} }
if form.FullName != "" { if form.FullName != "" {
+16 -11
View File
@@ -86,18 +86,23 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap
} }
// User2UserSettings return UserSettings based on a user // User2UserSettings return UserSettings based on a user
func User2UserSettings(user *user_model.User) api.UserSettings { func User2UserSettings(ctx context.Context, user *user_model.User) (api.UserSettings, error) {
return api.UserSettings{ includePrivateContributions, err := user_model.GetIncludePrivateContributions(ctx, user.ID)
FullName: user.FullName, if err != nil {
Website: user.Website, return api.UserSettings{}, err
Location: user.Location,
Language: user.Language,
Description: user.Description,
Theme: user.Theme,
HideEmail: user.KeepEmailPrivate,
HideActivity: user.KeepActivityPrivate,
DiffViewStyle: user.DiffViewStyle,
} }
return api.UserSettings{
FullName: user.FullName,
Website: user.Website,
Location: user.Location,
Language: user.Language,
Description: user.Description,
Theme: user.Theme,
HideEmail: user.KeepEmailPrivate,
HideActivity: user.KeepActivityPrivate,
IncludePrivateContributions: includePrivateContributions,
DiffViewStyle: user.DiffViewStyle,
}, nil
} }
// ToUserAndPermission return User and its collaboration permission for a repository // ToUserAndPermission return User and its collaboration permission for a repository
@@ -208,14 +208,15 @@ func (f *IntrospectTokenForm) Validate(req *http.Request, errs binding.Errors) b
// UpdateProfileForm form for updating profile // UpdateProfileForm form for updating profile
type UpdateProfileForm struct { type UpdateProfileForm struct {
Name string `binding:"Username;MaxSize(40)"` Name string `binding:"Username;MaxSize(40)"`
FullName string `binding:"MaxSize(100)"` FullName string `binding:"MaxSize(100)"`
KeepEmailPrivate bool KeepEmailPrivate bool
Website string `binding:"ValidSiteUrl;MaxSize(255)"` Website string `binding:"ValidSiteUrl;MaxSize(255)"`
Location string `binding:"MaxSize(50)"` Location string `binding:"MaxSize(50)"`
Description string `binding:"MaxSize(255)"` Description string `binding:"MaxSize(255)"`
Visibility structs.VisibleType Visibility structs.VisibleType
KeepActivityPrivate bool KeepActivityPrivate bool
IncludePrivateContributions bool
} }
// Validate validates the fields // Validate validates the fields
+19 -1
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
password_module "code.gitea.io/gitea/modules/auth/password" password_module "code.gitea.io/gitea/modules/auth/password"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
@@ -47,6 +48,7 @@ type UpdateOptions struct {
IsRestricted optional.Option[bool] IsRestricted optional.Option[bool]
Visibility optional.Option[structs.VisibleType] Visibility optional.Option[structs.VisibleType]
KeepActivityPrivate optional.Option[bool] KeepActivityPrivate optional.Option[bool]
IncludePrivateContributions optional.Option[bool]
Language optional.Option[string] Language optional.Option[string]
Theme optional.Option[string] Theme optional.Option[string]
DiffViewStyle optional.Option[string] DiffViewStyle optional.Option[string]
@@ -182,7 +184,23 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
cols = append(cols, "last_login_unix") cols = append(cols, "last_login_unix")
} }
return user_model.UpdateUserCols(ctx, u, cols...) if len(cols) > 0 || opts.IncludePrivateContributions.Has() {
return db.WithTx(ctx, func(ctx context.Context) error {
if len(cols) > 0 {
if err := user_model.UpdateUserCols(ctx, u, cols...); err != nil {
return err
}
}
if opts.IncludePrivateContributions.Has() {
return user_model.SetIncludePrivateContributions(ctx, u.ID, opts.IncludePrivateContributions.Value())
}
return nil
})
}
return nil
} }
type UpdateAuthOptions struct { type UpdateAuthOptions struct {
@@ -44,6 +44,7 @@ func TestUpdateUser(t *testing.T) {
IsAdmin: UpdateOptionFieldFromValue(true), IsAdmin: UpdateOptionFieldFromValue(true),
Visibility: optional.Some(structs.VisibleTypePrivate), Visibility: optional.Some(structs.VisibleTypePrivate),
KeepActivityPrivate: optional.Some(true), KeepActivityPrivate: optional.Some(true),
IncludePrivateContributions: optional.Some(true),
Language: optional.Some("lang"), Language: optional.Some("lang"),
Theme: optional.Some("theme"), Theme: optional.Some("theme"),
DiffViewStyle: optional.Some("split"), DiffViewStyle: optional.Some("split"),
@@ -66,6 +67,9 @@ func TestUpdateUser(t *testing.T) {
assert.Equal(t, opts.IsAdmin.Value().FieldValue, user.IsAdmin) assert.Equal(t, opts.IsAdmin.Value().FieldValue, user.IsAdmin)
assert.Equal(t, opts.Visibility.Value(), user.Visibility) assert.Equal(t, opts.Visibility.Value(), user.Visibility)
assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate) assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
includePrivateContributions, err := user_model.GetIncludePrivateContributions(t.Context(), user.ID)
assert.NoError(t, err)
assert.Equal(t, opts.IncludePrivateContributions.Value(), includePrivateContributions)
assert.Equal(t, opts.Language.Value(), user.Language) assert.Equal(t, opts.Language.Value(), user.Language)
assert.Equal(t, opts.Theme.Value(), user.Theme) assert.Equal(t, opts.Theme.Value(), user.Theme)
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle) assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
@@ -86,6 +90,9 @@ func TestUpdateUser(t *testing.T) {
assert.Equal(t, opts.IsAdmin.Value().FieldValue, user.IsAdmin) assert.Equal(t, opts.IsAdmin.Value().FieldValue, user.IsAdmin)
assert.Equal(t, opts.Visibility.Value(), user.Visibility) assert.Equal(t, opts.Visibility.Value(), user.Visibility)
assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate) assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
includePrivateContributions, err = user_model.GetIncludePrivateContributions(t.Context(), user.ID)
assert.NoError(t, err)
assert.Equal(t, opts.IncludePrivateContributions.Value(), includePrivateContributions)
assert.Equal(t, opts.Language.Value(), user.Language) assert.Equal(t, opts.Language.Value(), user.Language)
assert.Equal(t, opts.Theme.Value(), user.Theme) assert.Equal(t, opts.Theme.Value(), user.Theme)
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle) assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
+8
View File
@@ -29655,6 +29655,10 @@
"type": "boolean", "type": "boolean",
"x-go-name": "HideEmail" "x-go-name": "HideEmail"
}, },
"include_private_contributions": {
"type": "boolean",
"x-go-name": "IncludePrivateContributions"
},
"language": { "language": {
"type": "string", "type": "string",
"x-go-name": "Language" "x-go-name": "Language"
@@ -29699,6 +29703,10 @@
"type": "boolean", "type": "boolean",
"x-go-name": "HideEmail" "x-go-name": "HideEmail"
}, },
"include_private_contributions": {
"type": "boolean",
"x-go-name": "IncludePrivateContributions"
},
"language": { "language": {
"type": "string", "type": "string",
"x-go-name": "Language" "x-go-name": "Language"
@@ -88,6 +88,13 @@
</div> </div>
</div> </div>
<div class="field">
<div class="ui checkbox" id="include-private-contributions">
<label data-tooltip-content="{{ctx.Locale.Tr "settings.include_private_contributions_popup"}}"><strong>{{ctx.Locale.Tr "settings.include_private_contributions"}}</strong></label>
<input name="include_private_contributions" type="checkbox" {{if .IncludePrivateContributions}}checked{{end}}>
</div>
</div>
<div class="divider"></div> <div class="divider"></div>
<div class="field"> <div class="field">
@@ -17,6 +17,7 @@ import (
"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"
) )
const ( const (
@@ -56,6 +57,26 @@ func testPrivateActivityHelperEnablePrivateActivity(t *testing.T) {
session.MakeRequest(t, req, http.StatusSeeOther) session.MakeRequest(t, req, http.StatusSeeOther)
} }
func testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t *testing.T, session *TestSession, username string, include bool) {
values := map[string]string{
"name": username,
"email": username + "@example.com",
"language": "en-US",
}
if include {
values["include_private_contributions"] = "1"
}
req := NewRequestWithValues(t, "POST", "/user/settings", values)
session.MakeRequest(t, req, http.StatusSeeOther)
}
func testPrivateActivityHelperAssertIncludePrivateContributions(t *testing.T, username string, expected bool) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
includePrivateContributions, err := user_model.GetIncludePrivateContributions(t.Context(), user.ID)
require.NoError(t, err)
assert.Equal(t, expected, includePrivateContributions)
}
func testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc *HTMLDoc) bool { func testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc *HTMLDoc) bool {
return htmlDoc.doc.Find("#activity-feed").Find(".flex-item").Length() > 0 return htmlDoc.doc.Find("#activity-feed").Find(".flex-item").Length() > 0
} }
@@ -136,6 +157,80 @@ func testPrivateActivityHelperHasHeatmapContentFromSession(t *testing.T, session
return len(items) != 0 return len(items) != 0
} }
// check private contribution opt-in settings persistence and ownership
func TestPrivateActivityIncludePrivateContributionsWebSettingsPersistence(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, privateActivityTestUser)
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t, session, privateActivityTestUser, true)
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, true)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: privateActivityTestUser})
assert.False(t, user.KeepActivityPrivate, "private contribution opt-in must not hide the whole activity heatmap")
req := NewRequest(t, "GET", "/user/settings")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Equal(t, 1, htmlDoc.doc.Find("#include-private-contributions input[name='include_private_contributions']:checked").Length())
testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t, session, privateActivityTestUser, false)
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
}
func TestPrivateActivityIncludePrivateContributionsAPISettingsPersistence(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, privateActivityTestUser)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeWriteUser)
includePrivateContributions := true
req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
IncludePrivateContributions: &includePrivateContributions,
}).AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
settings := DecodeJSON(t, resp, &api.UserSettings{})
assert.True(t, settings.IncludePrivateContributions)
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, true)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: privateActivityTestUser})
assert.False(t, user.KeepActivityPrivate, "API opt-in must not set KeepActivityPrivate")
req = NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token)
resp = session.MakeRequest(t, req, http.StatusOK)
settings = DecodeJSON(t, resp, &api.UserSettings{})
assert.True(t, settings.IncludePrivateContributions)
includePrivateContributions = false
req = NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
IncludePrivateContributions: &includePrivateContributions,
}).AddTokenAuth(token)
resp = session.MakeRequest(t, req, http.StatusOK)
settings = DecodeJSON(t, resp, &api.UserSettings{})
assert.False(t, settings.IncludePrivateContributions)
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
}
func TestPrivateActivityIncludePrivateContributionsWebSettingsAuthBoundary(t *testing.T) {
defer tests.PrepareTestEnv(t)()
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
otherSession := loginUser(t, privateActivityTestOtherUser)
testPrivateActivityHelperSetIncludePrivateContributionsViaWeb(t, otherSession, privateActivityTestOtherUser, true)
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestOtherUser, true)
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": privateActivityTestUser,
"email": privateActivityTestUser + "@example.com",
"language": "en-US",
"include_private_contributions": "1",
})
MakeRequest(t, req, http.StatusSeeOther)
testPrivateActivityHelperAssertIncludePrivateContributions(t, privateActivityTestUser, false)
}
// check activity visibility if the visibility is enabled // check activity visibility if the visibility is enabled
func TestPrivateActivityNoVisibleForPublic(t *testing.T) { func TestPrivateActivityNoVisibleForPublic(t *testing.T) {