0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-11 20:31:49 +02:00
bircni 54916f708e
feat: Add avatar stacks (#37594)
Parse `Co-authored-by:` trailers from commit messages and surface
contributors as an avatar stack across the commit page, commits list, PR
commits tab, latest-commit row, blame, graph, and dashboard feed.

- Up to 10 visible 20px avatars, GitHub-style overlap (6px first stride,
4px between subsequent), `+N` chip for the rest.
- Label: 1 → name; 2 → `<a> and <b>`; 3+ → `<N> people` opens a Tippy
popup with all participants.
- Names and avatars link to the repo's commits-by-author search; fall
back to profile or `mailto:`.
- Trailer parsing uses `net/mail.ParseAddress`, scans only the trailing
paragraph, filters out the commit's own author/committer.
- Drops the non-standard `Co-committed-by:` emission on squash merge and
web edits.

Devtest: `/devtest/coauthor-avatars`.

Fixes #25521

----
<img width="353" height="277" alt="image"
src="https://github.com/user-attachments/assets/72092ceb-97ca-4b09-9557-0b72d3c5458e"
/>

<img width="533" height="328"
src="https://github.com/user-attachments/assets/11d0c8f8-8b3f-4f2e-9993-879f1c06bcc5"
/>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2026-06-08 17:16:22 +00:00

187 lines
4.0 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"reflect"
"sync"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
type CfgSecKey struct {
Sec, Key string
}
// OptionInterface is used to overcome Golang's generic interface limitation
type OptionInterface interface {
GetDefaultValue() any
}
type Option[T any] struct {
mu sync.RWMutex
cfgSecKey CfgSecKey
dynKey string
value T
defSimple T
defFunc func() T
emptyAsDef bool
has bool
revision int
}
func (opt *Option[T]) GetDefaultValue() any {
return opt.DefaultValue()
}
func (opt *Option[T]) parse(key, valStr string) (v T) {
v = opt.DefaultValue()
if valStr != "" {
if err := json.Unmarshal(util.UnsafeStringToBytes(valStr), &v); err != nil {
log.Error("Unable to unmarshal json config for key %q, err: %v", key, err)
}
}
return v
}
func (opt *Option[T]) HasValue(ctx context.Context) bool {
_, _, has := opt.ValueRevision(ctx)
return has
}
func (opt *Option[T]) Value(ctx context.Context) (v T) {
v, _, _ = opt.ValueRevision(ctx)
return v
}
func isZeroOrEmpty(v any) bool {
if v == nil {
return true // interface itself is nil
}
r := reflect.ValueOf(v)
if r.IsZero() {
return true
}
if r.Kind() == reflect.Slice || r.Kind() == reflect.Map {
if r.IsNil() {
return true
}
return r.Len() == 0
}
return false
}
var SkipDatabaseConfig bool
func (opt *Option[T]) ValueRevision(ctx context.Context) (v T, rev int, has bool) {
dg := GetDynGetter()
if dg == nil {
// this is an edge case: the database is not initialized but the system setting is going to be used
// it should panic to avoid inconsistent config values (from config / system setting) and fix the code
if SkipDatabaseConfig {
return opt.DefaultValue(), 0, false
}
panic("no config dyn value getter")
}
rev = dg.GetRevision(ctx)
// if the revision in the database doesn't change, use the last value
opt.mu.RLock()
if rev == opt.revision {
v = opt.value
has = opt.has
opt.mu.RUnlock()
return v, rev, has
}
opt.mu.RUnlock()
// try to parse the config and cache it
var valStr *string
if dynVal, hasDbValue := dg.GetValue(ctx, opt.dynKey); hasDbValue {
valStr = &dynVal
} else if cfgVal, hasCfgValue := GetCfgSecKeyGetter().GetValue(opt.cfgSecKey.Sec, opt.cfgSecKey.Key); hasCfgValue {
valStr = &cfgVal
}
if valStr == nil {
v = opt.DefaultValue()
has = false
} else {
v = opt.parse(opt.dynKey, *valStr)
if opt.emptyAsDef && isZeroOrEmpty(v) {
v = opt.DefaultValue()
} else {
has = true
}
}
opt.mu.Lock()
opt.value = v
opt.revision = rev
opt.has = has
opt.mu.Unlock()
return v, rev, has
}
func (opt *Option[T]) DynKey() string {
return opt.dynKey
}
// WithDefaultFunc sets the default value with a function
// The "def" value might be changed during runtime (e.g.: Unmarshal with default), so it shouldn't use the same pointer or slice
func (opt *Option[T]) WithDefaultFunc(f func() T) *Option[T] {
opt.defFunc = f
return opt
}
func (opt *Option[T]) WithDefaultSimple(def T) *Option[T] {
v := any(def)
switch v.(type) {
case string, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
default:
// TODO: use reflect to support convertible basic types like `type State string`
r := reflect.ValueOf(v)
if r.Kind() != reflect.Struct {
panic("invalid type for default value, use WithDefaultFunc instead")
}
}
opt.defSimple = def
return opt
}
func (opt *Option[T]) WithEmptyAsDefault() *Option[T] {
opt.emptyAsDef = true
return opt
}
func (opt *Option[T]) DefaultValue() T {
if opt.defFunc != nil {
return opt.defFunc()
}
return opt.defSimple
}
func (opt *Option[T]) WithFileConfig(cfgSecKey CfgSecKey) *Option[T] {
opt.cfgSecKey = cfgSecKey
return opt
}
var allConfigOptions = map[string]OptionInterface{}
func NewOption[T any](dynKey string) *Option[T] {
v := &Option[T]{dynKey: dynKey}
allConfigOptions[dynKey] = v
return v
}
func GetConfigOption(dynKey string) OptionInterface {
return allConfigOptions[dynKey]
}