feat(build): Support go "fmt" format strings as masked usage patterns (#12013)

This idea is perhaps a bit more far-fetched. It implements the ability in `lint-locale-usage` to basically fully handle "printf" invocations by transforming format strings to regexps when "%" wildcards are present.

Currently, it doesn't cache the transformation from format string to compiled regex because this doesn't make a performance difference (yet), given that most of these wildcards are only hit once or twice.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12013
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Έλλεν Εμίλια Άννα Zscheile 2026-05-01 02:46:01 +02:00 committed by Gusted
commit 1acf630dbf
11 changed files with 256 additions and 155 deletions

View file

@ -6,32 +6,12 @@ translation_meta.test
# this also gets instantiated as a Messenger once # this also gets instantiated as a Messenger once
repo.migrate.migrating_failed.error repo.migrate.migrating_failed.error
# models/system/notice.go: func (n *Notice) TrStr() string
admin.notices.type_1
admin.notices.type_2
# modules/setting/ui.go # modules/setting/ui.go
themes.names. themes.names.
# services/context/context.go # services/context/context.go
relativetime. relativetime.
# templates/repo/issue/view_content.tmpl: indirection via $closeTranslationKey
repo.issues.close
repo.pulls.close
# templates/repo/issue/view_content/comments.tmpl: indirection via $refTr
repo.issues.ref_closing_from
repo.issues.ref_issue_from
repo.issues.ref_pull_from
repo.issues.ref_reopening_from
# templates/repo/issue/view_content/comments.tmpl: ctx.Locale.Tr (printf "projects.type-%d.display_name" .OldProject.Type)
projects.
projects.type-1.display_name
projects.type-2.display_name
projects.type-3.display_name
# templates/repo/settings/webhook/link_menu.tmpl, templates/webhook/new.tmpl: repo.settings.web_hook_name_ # templates/repo/settings/webhook/link_menu.tmpl, templates/webhook/new.tmpl: repo.settings.web_hook_name_
# tests/integration/repo_archive_text_test.go # tests/integration/repo_archive_text_test.go
repo.settings. repo.settings.

View file

@ -37,144 +37,172 @@ func HandleGoFile(handler llu.Handler, fname string, src any) error {
} }
ast.Inspect(node, func(n ast.Node) bool { ast.Inspect(node, func(n ast.Node) bool {
// search for function calls of the form `anything.Tr(any-string-lit, ...)` return HandleGoNode(handler, fset, fname, n)
})
switch n2 := n.(type) { return nil
case *ast.CallExpr: }
if len(n2.Args) == 0 {
return true func HandleGoNode(handler llu.Handler, fset *token.FileSet, fname string, n ast.Node) bool {
// search for function calls of the form `anything.Tr(any-string-lit, ...)`
switch n2 := n.(type) {
case *ast.CallExpr:
if len(n2.Args) == 0 {
return true
}
funSel, ok := n2.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ltf, ok := handler.LocaleTrFunctions[funSel.Sel.Name]
if !ok {
return true
}
var gotUnexpectedInvoke *int
for _, argNum := range ltf {
if len(n2.Args) <= int(argNum) {
argc := len(n2.Args)
gotUnexpectedInvoke = &argc
} else {
handler.HandleGoTrArgument(fset, n2.Args[int(argNum)], "")
} }
funSel, ok := n2.Fun.(*ast.SelectorExpr) }
if !ok {
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke)
}
case *ast.CompositeLit:
if strings.HasSuffix(fname, "models/unit/unit.go") {
lluUnit.HandleCompositeUnit(handler, fset, n2)
} else if strings.Contains(fname, "models/asymkey/") {
lluAsymKey.HandleCompositeErrorReason(handler, fset, n2)
}
case *ast.FuncDecl:
if matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKeyWeak"); matchInsPrefix != nil {
results := n2.Type.Results.List
if len(results) != 1 {
handler.OnWarning(fset, n2.Type.Func, fmt.Sprintf("function %s has unexpected return type; expected single return value", n2.Name.Name))
return true return true
} }
ltf, ok := handler.LocaleTrFunctions[funSel.Sel.Name] ast.Inspect(n2.Body, func(n ast.Node) bool {
if !ok { // search for return stmts
return true // TODO: what about nested functions?
} if ret, ok := n.(*ast.ReturnStmt); ok {
for _, res := range ret.Results {
var gotUnexpectedInvoke *int ast.Inspect(res, func(n ast.Node) bool {
if expr, ok := n.(ast.Expr); ok {
for _, argNum := range ltf { handler.HandleGoTrArgument(fset, expr, *matchInsPrefix)
if len(n2.Args) <= int(argNum) { }
argc := len(n2.Args) return true
gotUnexpectedInvoke = &argc })
} else { }
handler.HandleGoTrArgument(fset, n2.Args[int(argNum)], "") return false
} }
return true
})
}
if matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKey"); matchInsPrefix != nil {
results := n2.Type.Results.List
if len(results) != 1 {
handler.OnWarning(fset, n2.Type.Func, fmt.Sprintf("function %s has unexpected return type; expected single return value", n2.Name.Name))
return true
} }
if gotUnexpectedInvoke != nil { ast.Inspect(n2.Body, func(n ast.Node) bool {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke) // search for return stmts
} if ret, ok := n.(*ast.ReturnStmt); ok {
for _, res := range ret.Results {
case *ast.CompositeLit: handler.HandleGoTrArgument(fset, res, *matchInsPrefix)
if strings.HasSuffix(fname, "models/unit/unit.go") { }
lluUnit.HandleCompositeUnit(handler, fset, n2) return false
} else if strings.Contains(fname, "models/asymkey/") { } else if _, ok := n.(*ast.FuncDecl); ok {
lluAsymKey.HandleCompositeErrorReason(handler, fset, n2) ast.Inspect(n, func(n2 ast.Node) bool {
} return HandleGoNode(handler, fset, fname, n2)
})
case *ast.FuncDecl: // don't search inside nested functions for return stmts
matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKey") return false
if matchInsPrefix != nil {
results := n2.Type.Results.List
if len(results) != 1 {
handler.OnWarning(fset, n2.Type.Func, fmt.Sprintf("function %s has unexpected return type; expected single return value", n2.Name.Name))
return true
} }
return true
})
}
ast.Inspect(n2.Body, func(n ast.Node) bool { if strings.HasSuffix(fname, "services/migrations/migrate.go") {
// search for return stmts lluMigrate.HandleMessengerInFunc(handler, fset, n2)
// TODO: what about nested functions? }
if ret, ok := n.(*ast.ReturnStmt); ok { return true
for _, res := range ret.Results { case *ast.GenDecl:
ast.Inspect(res, func(n ast.Node) bool { switch n2.Tok {
if expr, ok := n.(ast.Expr); ok { case token.CONST, token.VAR:
handler.HandleGoTrArgument(fset, expr, *matchInsPrefix) matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, " llu:TrKeys")
} if matchInsPrefix == nil {
return true return true
}) }
} for _, spec := range n2.Specs {
// interpret all contained strings as message IDs
ast.Inspect(spec, func(n ast.Node) bool {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.HandleGoTrBasicLit(fset, argLit, *matchInsPrefix)
return false return false
} }
return true return true
}) })
} }
if strings.HasSuffix(fname, "services/migrations/migrate.go") { case token.TYPE:
lluMigrate.HandleMessengerInFunc(handler, fset, n2) // modules/web/middleware/binding.go:Validate uses the convention that structs
} // entries can have tags.
return true // In particular, `locale:$msgid` should be handled; any fields with `form:-` shouldn't.
case *ast.GenDecl: // Problem: we don't know which structs are forms, actually.
switch n2.Tok {
case token.CONST, token.VAR:
matchInsPrefix := handler.HandleGoCommentGroup(fset, n2.Doc, " llu:TrKeys")
if matchInsPrefix == nil {
return true
}
for _, spec := range n2.Specs {
// interpret all contained strings as message IDs
ast.Inspect(spec, func(n ast.Node) bool {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.HandleGoTrBasicLit(fset, argLit, *matchInsPrefix)
return false
}
return true
})
}
case token.TYPE: for _, spec := range n2.Specs {
// modules/web/middleware/binding.go:Validate uses the convention that structs tspec := spec.(*ast.TypeSpec)
// entries can have tags. structNode, ok := tspec.Type.(*ast.StructType)
// In particular, `locale:$msgid` should be handled; any fields with `form:-` shouldn't. if !ok || !(strings.HasSuffix(tspec.Name.Name, "Form") ||
// Problem: we don't know which structs are forms, actually. (tspec.Doc != nil &&
slices.ContainsFunc(tspec.Doc.List, func(c *ast.Comment) bool {
for _, spec := range n2.Specs { return c.Text == "// swagger:model"
tspec := spec.(*ast.TypeSpec) }))) {
structNode, ok := tspec.Type.(*ast.StructType) continue
if !ok || !(strings.HasSuffix(tspec.Name.Name, "Form") || }
(tspec.Doc != nil && for _, field := range structNode.Fields.List {
slices.ContainsFunc(tspec.Doc.List, func(c *ast.Comment) bool { if field.Names == nil {
return c.Text == "// swagger:model"
}))) {
continue continue
} }
for _, field := range structNode.Fields.List { if len(field.Names) != 1 {
if field.Names == nil { handler.OnWarning(fset, field.Type.Pos(), "unsupported multiple field names")
continue continue
}
if len(field.Names) != 1 {
handler.OnWarning(fset, field.Type.Pos(), "unsupported multiple field names")
continue
}
msgidPos := field.Names[0].NamePos
msgid := "form." + field.Names[0].Name
if field.Tag != nil && field.Tag.Kind == token.STRING {
rawTag, err := strconv.Unquote(field.Tag.Value)
if err != nil {
handler.OnWarning(fset, field.Tag.ValuePos, "invalid tag value encountered")
continue
}
tag := reflect.StructTag(rawTag)
if tag.Get("form") == "-" {
continue
}
tmp := tag.Get("locale")
if len(tmp) != 0 {
msgidPos = field.Tag.ValuePos
msgid = tmp
}
}
handler.OnMsgid(fset, msgidPos, msgid, true)
} }
msgidPos := field.Names[0].NamePos
msgid := "form." + field.Names[0].Name
if field.Tag != nil && field.Tag.Kind == token.STRING {
rawTag, err := strconv.Unquote(field.Tag.Value)
if err != nil {
handler.OnWarning(fset, field.Tag.ValuePos, "invalid tag value encountered")
continue
}
tag := reflect.StructTag(rawTag)
if tag.Get("form") == "-" {
continue
}
tmp := tag.Get("locale")
if len(tmp) != 0 {
msgidPos = field.Tag.ValuePos
msgid = tmp
}
}
handler.OnMsgid(fset, msgidPos, msgid, true)
} }
} }
} }
}
return true return true
})
return nil
} }

View file

@ -13,6 +13,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
@ -44,12 +45,57 @@ type StringTrie interface {
type StringTrieMap map[string]StringTrie type StringTrieMap map[string]StringTrie
func printfPatternToRegex(key string) (string, bool) {
parts := strings.Split(key, "%")
if len(parts) < 2 {
return key, false
}
var pattern strings.Builder
pattern.WriteString("^")
pattern.WriteString(parts[0])
skip := false
for _, part := range parts[1:] {
if skip {
skip = false
continue
}
if len(part) == 0 {
// "%%"
pattern.WriteString("%")
continue
}
switch part[0] {
case 'd':
pattern.WriteString("[0-9]+")
default:
pattern.WriteString("[A-Za-z0-9]*")
}
pattern.WriteString(part[1:])
}
pattern.WriteString("$")
return pattern.String(), true
}
func (m StringTrieMap) Matches(key []string) bool { func (m StringTrieMap) Matches(key []string) bool {
if len(key) == 0 || m == nil { if len(key) == 0 || m == nil {
return true return true
} }
value, ok := m[key[0]] value, ok := m[key[0]]
if !ok { if !ok {
for altKey, value := range m {
// TODO: cache mapping $printfFormatString -> $regexpCompileOutput
pattern, found := printfPatternToRegex(altKey)
if !found {
continue
}
matched, err := regexp.MatchString(pattern, key[0])
if err != nil {
panic(fmt.Sprintf("unable to compile regexp '%s': %s", pattern, err.Error()))
}
if matched && (value == nil || value.Matches(key[1:])) {
return true
}
}
return false return false
} }
if value == nil { if value == nil {
@ -101,7 +147,7 @@ func ParseAllowedMaskedUsages(fname string, usedMsgids container.Set[string], al
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
if linePrefix, found := strings.CutSuffix(line, "."); found { if linePrefix, found := strings.CutSuffix(line, "."); found || strings.Contains(line, "%") {
allowedMaskedPrefixes.Insert(strings.Split(linePrefix, ".")) allowedMaskedPrefixes.Insert(strings.Split(linePrefix, "."))
} else { } else {
if !chkMsgid(line) { if !chkMsgid(line) {
@ -145,9 +191,14 @@ func Usage() {
fmt.Fprintf(outp, "\nSpecial Go doc comments:\n") fmt.Fprintf(outp, "\nSpecial Go doc comments:\n")
for _, i := range []string{ for _, i := range []string{
"//llu:returnsTrKeyWeak",
"\tcan be used in front of functions to indicate",
"\tthat the function returns message IDs (allows nesting inside complicated function calls)",
"\tWARNING: this currently doesn't support nested functions properly",
"",
"//llu:returnsTrKey", "//llu:returnsTrKey",
"\tcan be used in front of functions to indicate", "\tcan be used in front of functions to indicate",
"\tthat the function returns message IDs", "\tthat the function returns message IDs (doesn't allow nesting inside complicated function calls)",
"\tWARNING: this currently doesn't support nested functions properly", "\tWARNING: this currently doesn't support nested functions properly",
"", "",
"//llu:returnsTrKeySuffix prefix.", "//llu:returnsTrKeySuffix prefix.",
@ -260,6 +311,10 @@ func main() {
} }
handler := llu.Handler{ handler := llu.Handler{
OnMsgidPattern: func(fset *token.FileSet, pos token.Pos, msgidPattern string) {
msgidPatternSplit := strings.Split(msgidPattern, ".")
allowedMaskedPrefixes.Insert(msgidPatternSplit)
},
OnMsgidPrefix: func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool) { OnMsgidPrefix: func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool) {
msgidPrefixSplit := strings.Split(msgidPrefix, ".") msgidPrefixSplit := strings.Split(msgidPrefix, ".")
if !truncated { if !truncated {
@ -270,6 +325,10 @@ func main() {
} }
}, },
OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string, weak bool) { OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string, weak bool) {
if strings.Contains(msgid, "%") {
fmt.Printf("%s:\tunexpected msgid pattern: %s\n", fset.Position(pos).String(), msgid)
return
}
if !msgids.Contains(msgid) { if !msgids.Contains(msgid) {
if weak && allowWeakMissingMsgids { if weak && allowWeakMissingMsgids {
return return

View file

@ -34,12 +34,14 @@ func (handler Handler) HandleGoTrBasicLit(fset *token.FileSet, argLit *ast.Basic
} }
func (handler Handler) HandleGoTrArgument(fset *token.FileSet, n ast.Expr, prefix string) { func (handler Handler) HandleGoTrArgument(fset *token.FileSet, n ast.Expr, prefix string) {
if argLit, ok := n.(*ast.BasicLit); ok { switch n := n.(type) {
handler.HandleGoTrBasicLit(fset, argLit, prefix) case *ast.BasicLit:
} else if argBinExpr, ok := n.(*ast.BinaryExpr); ok { handler.HandleGoTrBasicLit(fset, n, prefix)
if argBinExpr.Op != token.ADD {
case *ast.BinaryExpr:
if n.Op != token.ADD {
// pass // pass
} else if argLit, ok := argBinExpr.X.(*ast.BasicLit); ok && argLit.Kind == token.STRING { } else if argLit, ok := n.X.(*ast.BasicLit); ok && argLit.Kind == token.STRING {
// extract string content // extract string content
arg, err := strconv.Unquote(argLit.Value) arg, err := strconv.Unquote(argLit.Value)
if err != nil { if err != nil {
@ -53,6 +55,39 @@ func (handler Handler) HandleGoTrArgument(fset *token.FileSet, n ast.Expr, prefi
} }
handler.OnMsgidPrefix(fset, argLit.ValuePos, prep, trunc) handler.OnMsgidPrefix(fset, argLit.ValuePos, prep, trunc)
} }
case *ast.CallExpr:
if selExpr, ok := n.Fun.(*ast.SelectorExpr); ok {
if xIdent, xok := selExpr.X.(*ast.Ident); !xok || xIdent.Name != "fmt" {
return
}
if selExpr.Sel.Name != "Sprintf" {
handler.OnWarning(fset, selExpr.Sel.NamePos, fmt.Sprintf("unexpected formatting function encountered: %s", selExpr.Sel.Name))
return
}
if len(n.Args) == 0 {
handler.OnWarning(fset, selExpr.Sel.NamePos, fmt.Sprintf("unexpected formatting function invocation (no arguments) of '%s'", selExpr.Sel.Name))
return
}
if argLit, ok := n.Args[0].(*ast.BasicLit); ok && argLit.Kind == token.STRING {
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err != nil {
return
}
if strings.Contains(arg, " ") {
handler.OnWarning(fset, argLit.ValuePos, fmt.Sprintf(
"formatting function invocation of '%s' with weird msgid format string: %s",
selExpr.Sel.Name,
arg,
))
return
}
// found interesting strings
handler.OnMsgidPattern(fset, argLit.ValuePos, prefix+arg)
}
}
} }
} }

View file

@ -150,16 +150,12 @@ func (handler Handler) handleTemplateMsgid(fset *token.FileSet, node tmplParser.
handler.OnMsgid(fset, stringPos, msgidPrefix, false) handler.OnMsgid(fset, stringPos, msgidPrefix, false)
} else { } else {
if nodeIdent.Ident == "printf" { if nodeIdent.Ident == "printf" {
parts := strings.SplitN(msgidPrefix, "%", 2) // found interesting strings
if len(parts) != 2 { if !(strings.HasSuffix(msgidPrefix, ".%s") && strings.Count(msgidPrefix, "%") == 1) {
handler.OnWarning( handler.OnMsgidPattern(fset, stringPos, msgidPrefix)
fset,
stringPos,
fmt.Sprintf("unsupported invocation of locate function (format string doesn't match \"prefix%%smth\" pattern): %s", nodeString.String()),
)
return return
} }
msgidPrefix = parts[0] msgidPrefix = strings.TrimSuffix(msgidPrefix, "%s")
} }
msgidPrefixFin, truncated := PrepareMsgidPrefix(msgidPrefix) msgidPrefixFin, truncated := PrepareMsgidPrefix(msgidPrefix)

View file

@ -47,6 +47,7 @@ func InitLocaleTrFunctions() map[string][]uint {
type Handler struct { type Handler struct {
OnMsgid func(fset *token.FileSet, pos token.Pos, msgid string, weak bool) OnMsgid func(fset *token.FileSet, pos token.Pos, msgid string, weak bool)
OnMsgidPrefix func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool) OnMsgidPrefix func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool)
OnMsgidPattern func(fset *token.FileSet, pos token.Pos, msgidPattern string)
OnUnexpectedInvoke func(fset *token.FileSet, pos token.Pos, funcname string, argc int) OnUnexpectedInvoke func(fset *token.FileSet, pos token.Pos, funcname string, argc int)
OnWarning func(fset *token.FileSet, pos token.Pos, msg string) OnWarning func(fset *token.FileSet, pos token.Pos, msg string)
LocaleTrFunctions map[string][]uint LocaleTrFunctions map[string][]uint

View file

@ -184,7 +184,7 @@ func init() {
// GetCardConfig retrieves the types of configurations project column cards could have // GetCardConfig retrieves the types of configurations project column cards could have
// //
//llu:returnsTrKey //llu:returnsTrKeyWeak
func GetCardConfig() []CardConfig { func GetCardConfig() []CardConfig {
return []CardConfig{ return []CardConfig{
{CardTypeTextOnly, "repo.projects.card_type.text_only"}, {CardTypeTextOnly, "repo.projects.card_type.text_only"},

View file

@ -27,7 +27,7 @@ const (
// GetTemplateConfigs retrieves the template configs of configurations project columns could have // GetTemplateConfigs retrieves the template configs of configurations project columns could have
// //
//llu:returnsTrKey //llu:returnsTrKeyWeak
func GetTemplateConfigs() []TemplateConfig { func GetTemplateConfigs() []TemplateConfig {
return []TemplateConfig{ return []TemplateConfig{
{TemplateTypeNone, "repo.projects.type.none"}, {TemplateTypeNone, "repo.projects.type.none"},

View file

@ -38,6 +38,8 @@ func init() {
} }
// TrStr returns a translation format string. // TrStr returns a translation format string.
//
//llu:returnsTrKey
func (n *Notice) TrStr() string { func (n *Notice) TrStr() string {
return fmt.Sprintf("admin.notices.type_%d", n.Type) return fmt.Sprintf("admin.notices.type_%d", n.Type)
} }

View file

@ -110,14 +110,14 @@
</span> </span>
</button> </button>
{{else}} {{else}}
{{$closeTranslationKey := "repo.issues.close"}} {{$closeTranslation := ctx.Locale.Tr "repo.issues.close"}}
{{if .Issue.IsPull}} {{if .Issue.IsPull}}
{{$closeTranslationKey = "repo.pulls.close"}} {{$closeTranslation = ctx.Locale.Tr "repo.pulls.close"}}
{{end}} {{end}}
<button id="status-button" class="secondary button" data-status="{{ctx.Locale.Tr $closeTranslationKey}}" data-status-and-comment="{{ctx.Locale.Tr "repo.issues.close_comment_issue"}}" name="status" value="close"> <button id="status-button" class="secondary button" data-status="{{$closeTranslation}}" data-status-and-comment="{{ctx.Locale.Tr "repo.issues.close_comment_issue"}}" name="status" value="close">
{{svg "octicon-issue-closed"}} {{svg "octicon-issue-closed"}}
<span> <span>
{{ctx.Locale.Tr $closeTranslationKey}} {{$closeTranslation}}
</span> </span>
</button> </button>
{{end}} {{end}}

View file

@ -129,13 +129,13 @@
{{if ne .RefRepoID .Issue.RepoID}} {{if ne .RefRepoID .Issue.RepoID}}
{{$refFrom = ctx.Locale.Tr "repo.issues.ref_from" .RefRepo.FullName}} {{$refFrom = ctx.Locale.Tr "repo.issues.ref_from" .RefRepo.FullName}}
{{end}} {{end}}
{{$refTr := "repo.issues.ref_issue_from"}} {{$refTr := "issue"}}
{{if .Issue.IsPull}} {{if .Issue.IsPull}}
{{$refTr = "repo.issues.ref_pull_from"}} {{$refTr = "pull"}}
{{else if eq .RefAction 1}} {{else if eq .RefAction 1}}
{{$refTr = "repo.issues.ref_closing_from"}} {{$refTr = "closing"}}
{{else if eq .RefAction 2}} {{else if eq .RefAction 2}}
{{$refTr = "repo.issues.ref_reopening_from"}} {{$refTr = "reopening"}}
{{end}} {{end}}
<div class="timeline-item event" id="{{.HashTag}}"> <div class="timeline-item event" id="{{.HashTag}}">
<span class="badge">{{svg "octicon-bookmark"}}</span> <span class="badge">{{svg "octicon-bookmark"}}</span>
@ -143,7 +143,7 @@
{{if eq .RefAction 3}}<del>{{end}} {{if eq .RefAction 3}}<del>{{end}}
<span class="text grey muted-links"> <span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}} {{template "shared/user/authorlink" .Poster}}
{{ctx.Locale.Tr $refTr $createdStr (.RefCommentLink ctx) $refFrom}} {{ctx.Locale.Tr (printf "repo.issues.ref_%s_from" $refTr) $createdStr (.RefCommentLink ctx) $refFrom}}
</span> </span>
{{if eq .RefAction 3}}</del>{{end}} {{if eq .RefAction 3}}</del>{{end}}