Compare commits

...

10 commits

Author SHA1 Message Date
Antonin Delpeuch
9d896028bd tests: Disable Create review from commit flaky e2e test (#9049)
Some checks failed
/ release (push) Waiting to run
testing-integration / test-unit (push) Failing after 0s
testing-integration / test-sqlite (push) Failing after 0s
testing-integration / test-mariadb (v10.6) (push) Failing after 0s
testing-integration / test-mariadb (v11.8) (push) Failing after 0s
testing / backend-checks (push) Failing after 0s
testing / frontend-checks (push) Failing after 0s
testing / test-unit (push) Failing after 0s
testing / test-e2e (push) Failing after 0s
testing / test-mysql (push) Failing after 0s
testing / test-pgsql (push) Failing after 0s
testing / test-sqlite (push) Failing after 0s
testing / test-remote-cacher (redis) (push) Failing after 0s
testing / test-remote-cacher (valkey) (push) Failing after 0s
testing / test-remote-cacher (garnet) (push) Failing after 0s
testing / test-remote-cacher (redict) (push) Failing after 0s
testing / security-check (push) Failing after 0s
This test fails on unrelated changes (see for instance https://codeberg.org/forgejo/forgejo/actions/runs/98626/jobs/3 or #9047).
This defeats the purpose of such a test and hinders development for all Forgejo contributors, so I am proposing to disable it.

I have notified PR #7155 (which introduced it) about the need to fix it.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9049
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: Antonin Delpeuch <antonin@delpeuch.eu>
Co-committed-by: Antonin Delpeuch <antonin@delpeuch.eu>
2025-08-28 12:50:23 +02:00
Ellen Εμιλία Άννα Zscheile
f447661345 feat(build): improve lint-locale-usage further (#8736)
Print out a list of all unused msgids
Handle Go files that make calls to translation.
Handle `models/unit/unit.go`, which stores msgids in `$Unit.NameKey`
Handle .locale.Tr in templates
Handle simple dynamically constructed `Tr("msgid-prefix." + SomeFunctionCall())`.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8736
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Ellen Εμιλία Άννα Zscheile <fogti+devel@ytrizja.de>
Co-committed-by: Ellen Εμιλία Άννα Zscheile <fogti+devel@ytrizja.de>
2025-08-27 23:47:34 +02:00
Beowulf
e101a8e2dd fix: hide edit button on tag releases, improve ghost user display, fix tag signature banner (#7703)
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7703
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-by: Beowulf <beowulf@beocode.eu>
2025-08-27 22:33:17 +02:00
Lucas Schwiderski
20f6639f11
Hide edit button on tag releases
When the release is "just" a tag, there is no release object to edit.

Closes: #3589
2025-08-27 19:29:12 +02:00
Lucas Schwiderski
33b9bf20bc
Consolidate tag release user display with issue comments
Increase consistency of rendering the ghost user, by re-using
the same template.
Also add a tooltip to the shared template, to explain the ghost user.

Closes: #5630
2025-08-27 19:29:11 +02:00
Lucas Schwiderski
dfe64e53d0
Fix verified logo used with unverified signature 2025-08-27 19:29:09 +02:00
Lucas Schwiderski
6ad7d5759d
Fix release signature border
Closes: #7322
2025-08-27 19:29:08 +02:00
Earl Warren
558b79aa9c fix: use run ID instead of run Index in artifacts download web views (#9023)
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9023
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
2025-08-27 18:21:13 +02:00
Earl Warren
b047a60a09
fix!: use run ID instead of run Index in artifacts download web views
- the run ID used to download artifacts is absolute (ID) instead of being
  relative to the repository (Index) for compatibility with the url built
  and returned as `artifact-url` by the the upload-artifact@v4 action.
- this is a breaking change because URLs to download artifacts
  previous saved/bookmarked and not yet expired expired are no
  longer working, they need to be looked up again by visiting the job
  web page.

- add unit tests for getRunByID().
- RepoActionView.test.js verifies the download URL is built using the
  run ID.
- lAdd integration tests to verify the RunID is set as expected in
  the template used by RepoActionView.vue.

Refs https://code.forgejo.org/forgejo/runner/issues/187
2025-08-27 08:53:20 +02:00
Earl Warren
f7b0eb16c8
chore: refactor the web UI tests for the actions run
- create tests/integration/actions_view_test.go
- extract TestActionsArtifactDeletion from actions_route_test.go
- extract tests misplaced in
  - api_actions_artifact_test.go
  - api_actions_artifact_v4_test.go
- add a tests for the /{owner}/{repo}/actions/runs/{run_index}/artifacts
  because it is useful for debugging
2025-08-27 08:34:19 +02:00
36 changed files with 1134 additions and 453 deletions

2
.gitignore vendored
View file

@ -55,6 +55,8 @@ cpu.out
*.log
*.log.*.gz
/build/lint-locale/lint-locale
/build/lint-locale-usage/lint-locale-usage
/gitea
/gitea-vet
/debug

View file

@ -39,4 +39,5 @@ options/locale/.* @0ko
options/locale_next/.* @0ko
# Personal interest
build/lint-locale-usage/.* @fogti
.*/webhook.* @oliverpool

View file

@ -463,7 +463,7 @@ lint-locale:
.PHONY: lint-locale-usage
lint-locale-usage:
$(GO) run build/lint-locale-usage/lint-locale-usage.go
$(GO) run ./build/lint-locale-usage --allow-masked-usages-from=build/lint-locale-usage/allowed-masked-usage.txt
.PHONY: lint-md
lint-md: node_modules

View file

@ -0,0 +1,50 @@
# translation tooling test keys
meta.last_line
translation_meta.test
# models/admin/task.go: instances of $TranslatableMessage.Format
# this also gets instantiated as a Messenger once
repo.migrate.migrating_failed.error
# models/asymkey/gpg_key_object_verification.go: $ObjectVerification.Reason
# unfortunately, it is non-trivial to parse all the occurences
gpg.error.extract_sign
gpg.error.failed_retrieval_gpg_keys
gpg.error.generate_hash
gpg.error.no_committer_account
# models/system/notice.go: func (n *Notice) TrStr() string
admin.notices.type_1
admin.notices.type_2
# modules/setting/ui.go
themes.names.
# services/context/context.go
relativetime.
# templates/mail/issue/default.tmpl: $.locale.Tr
mail.issue.in_tree_path
# templates/package/metadata/arch.tmpl: $.locale.Tr
packages.details.license
# 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_
# tests/integration/repo_archive_text_test.go
repo.settings.

View file

@ -0,0 +1,217 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package main
import (
"fmt"
"go/ast"
goParser "go/parser"
"go/token"
"strconv"
"strings"
)
func (handler Handler) handleGoTrBasicLit(fset *token.FileSet, argLit *ast.BasicLit, prefix string) {
if argLit.Kind == token.STRING {
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err != nil {
return
}
// found interesting strings
arg = prefix + arg
if strings.HasSuffix(arg, ".") || strings.HasSuffix(arg, "_") {
prep, trunc := PrepareMsgidPrefix(arg)
if trunc {
handler.OnWarning(fset, argLit.ValuePos, fmt.Sprintf("needed to truncate message id prefix: %s", arg))
}
handler.OnMsgidPrefix(fset, argLit.ValuePos, prep, trunc)
} else {
handler.OnMsgid(fset, argLit.ValuePos, arg)
}
}
}
func (handler Handler) handleGoTrArgument(fset *token.FileSet, n ast.Expr, prefix string) {
if argLit, ok := n.(*ast.BasicLit); ok {
handler.handleGoTrBasicLit(fset, argLit, prefix)
} else if argBinExpr, ok := n.(*ast.BinaryExpr); ok {
if argBinExpr.Op != token.ADD {
// pass
} else if argLit, ok := argBinExpr.X.(*ast.BasicLit); ok && argLit.Kind == token.STRING {
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err != nil {
return
}
// found interesting strings
arg = prefix + arg
prep, trunc := PrepareMsgidPrefix(arg)
if trunc {
handler.OnWarning(fset, argLit.ValuePos, fmt.Sprintf("needed to truncate message id prefix: %s", arg))
}
handler.OnMsgidPrefix(fset, argLit.ValuePos, prep, trunc)
}
}
}
func (handler Handler) handleGoCommentGroup(fset *token.FileSet, cg *ast.CommentGroup, commentPrefix string) *string {
if cg == nil {
return nil
}
var matches []token.Pos
matchInsPrefix := ""
commentPrefix = "//" + commentPrefix
for _, comment := range cg.List {
ctxt := strings.TrimSpace(comment.Text)
if ctxt == commentPrefix {
matches = append(matches, comment.Slash)
} else if after, found := strings.CutPrefix(ctxt, commentPrefix+"Suffix "); found {
matches = append(matches, comment.Slash)
matchInsPrefix = strings.TrimSpace(after)
}
}
switch len(matches) {
case 0:
return nil
case 1:
return &matchInsPrefix
default:
handler.OnWarning(
fset,
matches[0],
fmt.Sprintf("encountered multiple %s... directives, ignoring", strings.TrimSpace(commentPrefix)),
)
return &matchInsPrefix
}
}
// the `Handle*File` functions follow the following calling convention:
// * `fname` is the name of the input file
// * `src` is either `nil` (then the function invokes `ReadFile` to read the file)
// or the contents of the file as {`[]byte`, or a `string`}
func (handler Handler) HandleGoFile(fname string, src any) error {
fset := token.NewFileSet()
node, err := goParser.ParseFile(fset, fname, src, goParser.SkipObjectResolution|goParser.ParseComments)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Go parser",
Err: err,
}
}
ast.Inspect(node, func(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)], "")
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke)
}
case *ast.CompositeLit:
ident, ok := n2.Type.(*ast.Ident)
if !ok {
return true
}
// special case: models/unit/unit.go
if strings.HasSuffix(fname, "unit.go") && ident.Name == "Unit" {
if len(n2.Elts) != 6 {
handler.OnWarning(fset, n2.Pos(), "unexpected initialization of 'Unit' (unexpected number of arguments)")
}
// NameKey has index 2
// invoked like '{{ctx.Locale.Tr $unit.NameKey}}'
nameKey, ok := n2.Elts[2].(*ast.BasicLit)
if !ok || nameKey.Kind != token.STRING {
handler.OnWarning(fset, n2.Elts[2].Pos(), "unexpected initialization of 'Unit' (expected string literal as NameKey)")
return true
}
// extract string content
arg, err := strconv.Unquote(nameKey.Value)
if err == nil {
// found interesting strings
handler.OnMsgid(fset, nameKey.ValuePos, arg)
}
}
case *ast.FuncDecl:
matchInsPrefix := handler.handleGoCommentGroup(fset, n2.Doc, "llu:returnsTrKey")
if matchInsPrefix == nil {
return true
}
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
}
ast.Inspect(n2.Body, func(n ast.Node) bool {
// search for return stmts
// TODO: what about nested functions?
if ret, ok := n.(*ast.ReturnStmt); ok {
for _, res := range ret.Results {
ast.Inspect(res, func(n ast.Node) bool {
if expr, ok := n.(ast.Expr); ok {
handler.handleGoTrArgument(fset, expr, *matchInsPrefix)
}
return true
})
}
return false
}
return true
})
return true
case *ast.GenDecl:
if !(n2.Tok == token.CONST || n2.Tok == token.VAR) {
return true
}
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
})
}
}
return true
})
return nil
}

View file

@ -0,0 +1,233 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package main
import (
"fmt"
"go/token"
"os"
"strings"
"text/template"
tmplParser "text/template/parse"
fjTemplates "forgejo.org/modules/templates"
"forgejo.org/modules/util"
)
// derived from source: modules/templates/scopedtmpl/scopedtmpl.go, L169-L213
func (handler Handler) handleTemplateNode(fset *token.FileSet, node tmplParser.Node) {
switch node.Type() {
case tmplParser.NodeAction:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.ActionNode).Pipe)
case tmplParser.NodeList:
nodeList := node.(*tmplParser.ListNode)
handler.handleTemplateFileNodes(fset, nodeList.Nodes)
case tmplParser.NodePipe:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.PipeNode))
case tmplParser.NodeTemplate:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.TemplateNode).Pipe)
case tmplParser.NodeIf:
nodeIf := node.(*tmplParser.IfNode)
handler.handleTemplateBranchNode(fset, nodeIf.BranchNode)
case tmplParser.NodeRange:
nodeRange := node.(*tmplParser.RangeNode)
handler.handleTemplateBranchNode(fset, nodeRange.BranchNode)
case tmplParser.NodeWith:
nodeWith := node.(*tmplParser.WithNode)
handler.handleTemplateBranchNode(fset, nodeWith.BranchNode)
case tmplParser.NodeCommand:
nodeCommand := node.(*tmplParser.CommandNode)
handler.handleTemplateFileNodes(fset, nodeCommand.Args)
if len(nodeCommand.Args) < 2 {
return
}
funcname := ""
if nodeChain, ok := nodeCommand.Args[0].(*tmplParser.ChainNode); ok {
if nodeIdent, ok := nodeChain.Node.(*tmplParser.IdentifierNode); ok {
if nodeIdent.Ident != "ctx" || len(nodeChain.Field) != 2 || nodeChain.Field[0] != "Locale" {
return
}
funcname = nodeChain.Field[1]
}
} else if nodeField, ok := nodeCommand.Args[0].(*tmplParser.FieldNode); ok {
if len(nodeField.Ident) != 2 || !(nodeField.Ident[0] == "locale" || nodeField.Ident[0] == "Locale") {
return
}
funcname = nodeField.Ident[1]
}
var gotUnexpectedInvoke *int
ltf, ok := handler.LocaleTrFunctions[funcname]
if !ok {
return
}
for _, argNum := range ltf {
if len(nodeCommand.Args) >= int(argNum+2) {
handler.handleTemplateMsgid(fset, nodeCommand.Args[int(argNum+1)])
} else {
argc := len(nodeCommand.Args) - 1
gotUnexpectedInvoke = &argc
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, token.Pos(nodeCommand.Pos), funcname, *gotUnexpectedInvoke)
}
default:
}
}
func (handler Handler) handleTemplateMsgid(fset *token.FileSet, node tmplParser.Node) {
// the column numbers are a bit "off", but much better than nothing
pos := token.Pos(node.Position())
switch node.Type() {
case tmplParser.NodeString:
nodeString := node.(*tmplParser.StringNode)
// found interesting strings
handler.OnMsgid(fset, pos, nodeString.Text)
case tmplParser.NodePipe:
nodePipe := node.(*tmplParser.PipeNode)
handler.handleTemplatePipeNode(fset, nodePipe)
if len(nodePipe.Cmds) == 0 {
handler.OnWarning(fset, pos, fmt.Sprintf("unsupported invocation of locate function (no commands): %s", node.String()))
} else if len(nodePipe.Cmds) != 1 {
handler.OnWarning(fset, pos, fmt.Sprintf("unsupported invocation of locate function (too many commands): %s", node.String()))
return
}
nodeCommand := nodePipe.Cmds[0]
if len(nodeCommand.Args) < 2 {
handler.OnWarning(fset, pos, fmt.Sprintf("unsupported invocation of locate function (not enough arguments): %s", node.String()))
return
}
nodeIdent, ok := nodeCommand.Args[0].(*tmplParser.IdentifierNode)
if !ok || (nodeIdent.Ident != "print" && nodeIdent.Ident != "printf") {
// handler.OnWarning(fset, pos, fmt.Sprintf("unsupported invocation of locate function (bad command): %s", node.String()))
return
}
nodeString, ok := nodeCommand.Args[1].(*tmplParser.StringNode)
if !ok {
//handler.OnWarning(
// fset,
// pos,
// fmt.Sprintf("unsupported invocation of locate function (string should be first argument to %s): %s", nodeIdent.Ident, node.String()),
//)
return
}
msgidPrefix := nodeString.Text
stringPos := token.Pos(nodeString.Pos)
if len(nodeCommand.Args) == 2 {
// found interesting strings
handler.OnMsgid(fset, stringPos, msgidPrefix)
} else {
if nodeIdent.Ident == "printf" {
parts := strings.SplitN(msgidPrefix, "%", 2)
if len(parts) != 2 {
handler.OnWarning(
fset,
stringPos,
fmt.Sprintf("unsupported invocation of locate function (format string doesn't match \"prefix%%smth\" pattern): %s", nodeString.String()),
)
return
}
msgidPrefix = parts[0]
}
msgidPrefixFin, truncated := PrepareMsgidPrefix(msgidPrefix)
if truncated {
handler.OnWarning(fset, stringPos, fmt.Sprintf("needed to truncate message id prefix: %s", msgidPrefix))
}
// found interesting strings
handler.OnMsgidPrefix(fset, stringPos, msgidPrefixFin, truncated)
}
default:
// handler.OnWarning(fset, pos, fmt.Sprintf("unknown invocation of locate function: %s", node.String()))
}
}
func (handler Handler) handleTemplatePipeNode(fset *token.FileSet, pipeNode *tmplParser.PipeNode) {
if pipeNode == nil {
return
}
// NOTE: we can't pass `pipeNode.Cmds` to handleTemplateFileNodes due to incompatible argument types
for _, node := range pipeNode.Cmds {
handler.handleTemplateNode(fset, node)
}
}
func (handler Handler) handleTemplateBranchNode(fset *token.FileSet, branchNode tmplParser.BranchNode) {
handler.handleTemplatePipeNode(fset, branchNode.Pipe)
handler.handleTemplateFileNodes(fset, branchNode.List.Nodes)
if branchNode.ElseList != nil {
handler.handleTemplateFileNodes(fset, branchNode.ElseList.Nodes)
}
}
func (handler Handler) handleTemplateFileNodes(fset *token.FileSet, nodes []tmplParser.Node) {
for _, node := range nodes {
handler.handleTemplateNode(fset, node)
}
}
// the `Handle*File` functions follow the following calling convention:
// * `fname` is the name of the input file
// * `src` is either `nil` (then the function invokes `ReadFile` to read the file)
// or the contents of the file as {`[]byte`, or a `string`}
func (handler Handler) HandleTemplateFile(fname string, src any) error {
var tmplContent []byte
switch src2 := src.(type) {
case nil:
var err error
tmplContent, err = os.ReadFile(fname)
if err != nil {
return LocatedError{
Location: fname,
Kind: "ReadFile",
Err: err,
}
}
case []byte:
tmplContent = src2
case string:
// SAFETY: we do not modify tmplContent below
tmplContent = util.UnsafeStringToBytes(src2)
default:
panic("invalid type for 'src'")
}
fset := token.NewFileSet()
fset.AddFile(fname, 1, len(tmplContent)).SetLinesForContent(tmplContent)
// SAFETY: we do not modify tmplContent2 below
tmplContent2 := util.UnsafeBytesToString(tmplContent)
tmpl := template.New(fname)
tmpl.Funcs(fjTemplates.NewFuncMap())
tmplParsed, err := tmpl.Parse(tmplContent2)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Template parser",
Err: err,
}
}
handler.handleTemplateFileNodes(fset, tmplParsed.Root.Nodes)
return nil
}

View file

@ -5,22 +5,19 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"go/ast"
goParser "go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"strconv"
"sort"
"strings"
"text/template"
tmplParser "text/template/parse"
"forgejo.org/modules/container"
fjTemplates "forgejo.org/modules/templates"
"forgejo.org/modules/translation/localeiter"
"forgejo.org/modules/util"
)
// this works by first gathering all valid source string IDs from `en-US` reference files
@ -63,241 +60,180 @@ func InitLocaleTrFunctions() map[string][]uint {
type Handler struct {
OnMsgid func(fset *token.FileSet, pos token.Pos, msgid string)
OnMsgidPrefix func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool)
OnUnexpectedInvoke func(fset *token.FileSet, pos token.Pos, funcname string, argc int)
OnWarning func(fset *token.FileSet, pos token.Pos, msg string)
LocaleTrFunctions map[string][]uint
}
// the `Handle*File` functions follow the following calling convention:
// * `fname` is the name of the input file
// * `src` is either `nil` (then the function invokes `ReadFile` to read the file)
// or the contents of the file as {`[]byte`, or a `string`}
type StringTrie interface {
Matches(key []string) bool
}
func (handler Handler) HandleGoFile(fname string, src any) error {
fset := token.NewFileSet()
node, err := goParser.ParseFile(fset, fname, src, goParser.SkipObjectResolution)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Go parser",
Err: err,
}
}
ast.Inspect(node, func(n ast.Node) bool {
// search for function calls of the form `anything.Tr(any-string-lit, ...)`
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) < 1 {
return true
}
funSel, ok := call.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(call.Args) >= int(argNum+1) {
argLit, ok := call.Args[int(argNum)].(*ast.BasicLit)
if !ok || argLit.Kind != token.STRING {
continue
}
// extract string content
arg, err := strconv.Unquote(argLit.Value)
if err == nil {
// found interesting strings
handler.OnMsgid(fset, argLit.ValuePos, arg)
}
} else {
argc := len(call.Args)
gotUnexpectedInvoke = &argc
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, funSel.Sel.NamePos, funSel.Sel.Name, *gotUnexpectedInvoke)
}
type StringTrieMap map[string]StringTrie
func (m StringTrieMap) Matches(key []string) bool {
if len(key) == 0 || m == nil {
return true
})
return nil
}
// derived from source: modules/templates/scopedtmpl/scopedtmpl.go, L169-L213
func (handler Handler) handleTemplateNode(fset *token.FileSet, node tmplParser.Node) {
switch node.Type() {
case tmplParser.NodeAction:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.ActionNode).Pipe)
case tmplParser.NodeList:
nodeList := node.(*tmplParser.ListNode)
handler.handleTemplateFileNodes(fset, nodeList.Nodes)
case tmplParser.NodePipe:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.PipeNode))
case tmplParser.NodeTemplate:
handler.handleTemplatePipeNode(fset, node.(*tmplParser.TemplateNode).Pipe)
case tmplParser.NodeIf:
nodeIf := node.(*tmplParser.IfNode)
handler.handleTemplateBranchNode(fset, nodeIf.BranchNode)
case tmplParser.NodeRange:
nodeRange := node.(*tmplParser.RangeNode)
handler.handleTemplateBranchNode(fset, nodeRange.BranchNode)
case tmplParser.NodeWith:
nodeWith := node.(*tmplParser.WithNode)
handler.handleTemplateBranchNode(fset, nodeWith.BranchNode)
case tmplParser.NodeCommand:
nodeCommand := node.(*tmplParser.CommandNode)
handler.handleTemplateFileNodes(fset, nodeCommand.Args)
if len(nodeCommand.Args) < 2 {
return
}
nodeChain, ok := nodeCommand.Args[0].(*tmplParser.ChainNode)
if !ok {
return
}
nodeIdent, ok := nodeChain.Node.(*tmplParser.IdentifierNode)
if !ok || nodeIdent.Ident != "ctx" || len(nodeChain.Field) != 2 || nodeChain.Field[0] != "Locale" {
return
}
ltf, ok := handler.LocaleTrFunctions[nodeChain.Field[1]]
if !ok {
return
}
var gotUnexpectedInvoke *int
for _, argNum := range ltf {
if len(nodeCommand.Args) >= int(argNum+2) {
nodeString, ok := nodeCommand.Args[int(argNum+1)].(*tmplParser.StringNode)
if ok {
// found interesting strings
// the column numbers are a bit "off", but much better than nothing
handler.OnMsgid(fset, token.Pos(nodeString.Pos), nodeString.Text)
}
} else {
argc := len(nodeCommand.Args) - 1
gotUnexpectedInvoke = &argc
}
}
if gotUnexpectedInvoke != nil {
handler.OnUnexpectedInvoke(fset, token.Pos(nodeChain.Pos), nodeChain.Field[1], *gotUnexpectedInvoke)
}
default:
}
value, ok := m[key[0]]
if !ok {
return false
}
if value == nil {
return true
}
return value.Matches(key[1:])
}
func (handler Handler) handleTemplatePipeNode(fset *token.FileSet, pipeNode *tmplParser.PipeNode) {
if pipeNode == nil {
func (m StringTrieMap) Insert(key []string) {
if m == nil {
return
}
// NOTE: we can't pass `pipeNode.Cmds` to handleTemplateFileNodes due to incompatible argument types
for _, node := range pipeNode.Cmds {
handler.handleTemplateNode(fset, node)
}
}
switch len(key) {
case 0:
return
func (handler Handler) handleTemplateBranchNode(fset *token.FileSet, branchNode tmplParser.BranchNode) {
handler.handleTemplatePipeNode(fset, branchNode.Pipe)
handler.handleTemplateFileNodes(fset, branchNode.List.Nodes)
if branchNode.ElseList != nil {
handler.handleTemplateFileNodes(fset, branchNode.ElseList.Nodes)
}
}
case 1:
m[key[0]] = nil
func (handler Handler) handleTemplateFileNodes(fset *token.FileSet, nodes []tmplParser.Node) {
for _, node := range nodes {
handler.handleTemplateNode(fset, node)
}
}
func (handler Handler) HandleTemplateFile(fname string, src any) error {
var tmplContent []byte
switch src2 := src.(type) {
case nil:
var err error
tmplContent, err = os.ReadFile(fname)
if err != nil {
return LocatedError{
Location: fname,
Kind: "ReadFile",
Err: err,
}
}
case []byte:
tmplContent = src2
case string:
// SAFETY: we do not modify tmplContent below
tmplContent = util.UnsafeStringToBytes(src2)
default:
panic("invalid type for 'src'")
if value, ok := m[key[0]]; ok {
if value == nil {
return
}
} else {
m[key[0]] = make(StringTrieMap)
}
m[key[0]].(StringTrieMap).Insert(key[1:])
}
}
fset := token.NewFileSet()
fset.AddFile(fname, 1, len(tmplContent)).SetLinesForContent(tmplContent)
// SAFETY: we do not modify tmplContent2 below
tmplContent2 := util.UnsafeBytesToString(tmplContent)
tmpl := template.New(fname)
tmpl.Funcs(fjTemplates.NewFuncMap())
tmplParsed, err := tmpl.Parse(tmplContent2)
func ParseAllowedMaskedUsages(fname string, usedMsgids container.Set[string], allowedMaskedPrefixes StringTrieMap, chkMsgid func(msgid string) bool) error {
file, err := os.Open(fname)
if err != nil {
return LocatedError{
Location: fname,
Kind: "Template parser",
Kind: "Open",
Err: err,
}
}
defer file.Close()
scanner := bufio.NewScanner(file)
lno := 0
for scanner.Scan() {
lno++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if linePrefix, found := strings.CutSuffix(line, "."); found {
allowedMaskedPrefixes.Insert(strings.Split(linePrefix, "."))
} else {
if !chkMsgid(line) {
return LocatedError{
Location: fmt.Sprintf("%s: line %d", fname, lno),
Kind: "undefined msgid",
Err: errors.New(line),
}
}
usedMsgids.Add(line)
}
}
if err := scanner.Err(); err != nil {
return LocatedError{
Location: fname,
Kind: "Scanner",
Err: err,
}
}
handler.handleTemplateFileNodes(fset, tmplParsed.Root.Nodes)
return nil
}
// This command assumes that we get started from the project root directory
//
// Possible command line flags:
//
// --allow-missing-msgids don't return an error code if missing message IDs are found
//
// EXIT CODES:
//
// 0 success, no issues found
// 1 unable to walk directory tree
// 2 unable to parse locale ini/json files
// 3 unable to parse go or text/template files
// 4 found missing message IDs
//
// Truncating a message id prefix to the last dot
func PrepareMsgidPrefix(s string) (string, bool) {
index := strings.LastIndexByte(s, 0x2e)
if index == -1 {
return "", true
}
return s[:index], index != len(s)-1
}
func Usage() {
outp := flag.CommandLine.Output()
fmt.Fprintf(outp, "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintf(outp, "\nThis command assumes that it gets started from the project root directory.\n")
fmt.Fprintf(outp, "\nExit codes:\n")
for _, i := range []string{
"0\tsuccess, no issues found",
"1\tunable to walk directory tree",
"2\tunable to parse locale ini/json files",
"3\tunable to parse go or text/template files",
"4\tfound missing message IDs",
"5\tfound unused message IDs",
} {
fmt.Fprintf(outp, "\t%s\n", i)
}
fmt.Fprintf(outp, "\nSpecial Go doc comments:\n")
for _, i := range []string{
"//llu:returnsTrKey",
"\tcan be used in front of functions to indicate",
"\tthat the function returns message IDs",
"\tWARNING: this currently doesn't support nested functions properly",
"",
"//llu:returnsTrKeySuffix prefix.",
"\tsimilar to llu:returnsTrKey, but the given prefix is prepended",
"\tto the found strings before interpreting them as msgids",
"",
"// llu:TrKeys",
"\tcan be used in front of 'const' and 'var' blocks",
"\tin order to mark all contained strings as message IDs",
"",
"// llu:TrKeysSuffix prefix.",
"\tlike llu:returnsTrKeySuffix, but for 'const' and 'var' blocks",
} {
if i == "" {
fmt.Fprintf(outp, "\n")
} else {
fmt.Fprintf(outp, "\t%s\n", i)
}
}
}
//nolint:forbidigo
func main() {
allowMissingMsgids := false
for _, arg := range os.Args[1:] {
if arg == "--allow-missing-msgids" {
allowMissingMsgids = true
}
}
allowUnusedMsgids := false
usedMsgids := make(container.Set[string])
allowedMaskedPrefixes := make(StringTrieMap)
onError := func(err error) {
if err == nil {
return
}
fmt.Println(err.Error())
os.Exit(3)
// It's possible for execl to hand us an empty os.Args.
if len(os.Args) == 0 {
flag.CommandLine = flag.NewFlagSet("lint-locale-usage", flag.ExitOnError)
} else {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
}
flag.CommandLine.Usage = Usage
flag.Usage = Usage
flag.BoolVar(
&allowMissingMsgids,
"allow-missing-msgids",
false,
"don't return an error code if missing message IDs are found",
)
flag.BoolVar(
&allowUnusedMsgids,
"allow-unused-msgids",
false,
"don't return an error code if unused message IDs are found",
)
msgids := make(container.Set[string])
@ -334,17 +270,50 @@ func main() {
gotAnyMsgidError := false
flag.Func(
"allow-masked-usages-from",
"supply a file containing a newline-separated list of allowed masked usages",
func(argval string) error {
return ParseAllowedMaskedUsages(argval, usedMsgids, allowedMaskedPrefixes, func(msgid string) bool {
return msgids.Contains(msgid)
})
},
)
flag.Parse()
onError := func(err error) {
if err == nil {
return
}
fmt.Println(err.Error())
os.Exit(3)
}
handler := Handler{
OnMsgidPrefix: func(fset *token.FileSet, pos token.Pos, msgidPrefix string, truncated bool) {
msgidPrefixSplit := strings.Split(msgidPrefix, ".")
if !truncated {
allowedMaskedPrefixes.Insert(msgidPrefixSplit)
} else if !allowedMaskedPrefixes.Matches(msgidPrefixSplit) {
gotAnyMsgidError = true
fmt.Printf("%s:\tmissing msgid prefix: %s\n", fset.Position(pos).String(), msgidPrefix)
}
},
OnMsgid: func(fset *token.FileSet, pos token.Pos, msgid string) {
if !msgids.Contains(msgid) {
gotAnyMsgidError = true
fmt.Printf("%s:\tmissing msgid: %s\n", fset.Position(pos).String(), msgid)
} else {
usedMsgids.Add(msgid)
}
},
OnUnexpectedInvoke: func(fset *token.FileSet, pos token.Pos, funcname string, argc int) {
gotAnyMsgidError = true
fmt.Printf("%s:\tunexpected invocation of %s with %d arguments\n", fset.Position(pos).String(), funcname, argc)
},
OnWarning: func(fset *token.FileSet, pos token.Pos, msg string) {
fmt.Printf("%s:\tWARNING: %s\n", fset.Position(pos).String(), msg)
},
LocaleTrFunctions: InitLocaleTrFunctions(),
}
@ -377,7 +346,27 @@ func main() {
os.Exit(1)
}
unusedMsgids := []string{}
for msgid := range msgids {
if !usedMsgids.Contains(msgid) && !allowedMaskedPrefixes.Matches(strings.Split(msgid, ".")) {
unusedMsgids = append(unusedMsgids, msgid)
}
}
sort.Strings(unusedMsgids)
if len(unusedMsgids) != 0 {
fmt.Printf("=== unused msgids (%d): ===\n", len(unusedMsgids))
for _, msgid := range unusedMsgids {
fmt.Printf("- %s\n", msgid)
}
}
if !allowMissingMsgids && gotAnyMsgidError {
os.Exit(4)
}
if !allowUnusedMsgids && len(unusedMsgids) != 0 {
os.Exit(5)
}
}

View file

@ -309,15 +309,26 @@ func GetLatestRunForBranchAndWorkflow(ctx context.Context, repoID int64, branch,
}
func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) {
var run ActionRun
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)
run, has, err := GetRunByIDWithHas(ctx, id)
if err != nil {
return nil, err
} else if !has {
return nil, fmt.Errorf("run with id %d: %w", id, util.ErrNotExist)
}
return &run, nil
return run, nil
}
func GetRunByIDWithHas(ctx context.Context, id int64) (*ActionRun, bool, error) {
var run ActionRun
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)
if err != nil {
return nil, false, err
} else if !has {
return nil, false, nil
}
return &run, true, nil
}
func GetRunByIndex(ctx context.Context, repoID, index int64) (*ActionRun, error) {

View file

@ -36,6 +36,7 @@ type ObjectVerification struct {
TrustStatus string
}
// llu:TrKeys
const (
// BadSignature is used as the reason when the signature has a KeyID that is in the db
// but no key that has that ID verifies the signature. This is a suspicious failure.

View file

@ -469,6 +469,8 @@ func (issue *Issue) GetLastEventTimestamp() timeutil.TimeStamp {
}
// GetLastEventLabel returns the localization label for the current issue.
//
//llu:returnsTrKey
func (issue *Issue) GetLastEventLabel() string {
if issue.IsClosed {
if issue.IsPull && issue.PullRequest.HasMerged {
@ -494,6 +496,8 @@ func (issue *Issue) GetLastComment(ctx context.Context) (*Comment, error) {
}
// GetLastEventLabelFake returns the localization label for the current issue without providing a link in the username.
//
//llu:returnsTrKey
func (issue *Issue) GetLastEventLabelFake() string {
if issue.IsClosed {
if issue.IsPull && issue.PullRequest.HasMerged {

View file

@ -48,6 +48,7 @@ const (
AbuseCategoryTypeIllegalContent // 4
)
// llu:TrKeys
var AbuseCategoriesTranslationKeys = map[AbuseCategoryType]string{
AbuseCategoryTypeSpam: "moderation.abuse_category.spam",
AbuseCategoryTypeMalware: "moderation.abuse_category.malware",

View file

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

View file

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

View file

@ -271,7 +271,6 @@ type Unit struct {
Name string
NameKey string
URI string
DescKey string
Idx int
MaxAccessMode perm.AccessMode // The max access mode of the unit. i.e. Read means this unit can only be read.
}
@ -299,7 +298,6 @@ var (
"code",
"repo.code",
"/",
"repo.code.desc",
0,
perm.AccessModeOwner,
}
@ -309,7 +307,6 @@ var (
"issues",
"repo.issues",
"/issues",
"repo.issues.desc",
1,
perm.AccessModeOwner,
}
@ -319,7 +316,6 @@ var (
"ext_issues",
"repo.ext_issues",
"/issues",
"repo.ext_issues.desc",
1,
perm.AccessModeRead,
}
@ -329,7 +325,6 @@ var (
"pulls",
"repo.pulls",
"/pulls",
"repo.pulls.desc",
2,
perm.AccessModeOwner,
}
@ -339,7 +334,6 @@ var (
"releases",
"repo.releases",
"/releases",
"repo.releases.desc",
3,
perm.AccessModeOwner,
}
@ -349,7 +343,6 @@ var (
"wiki",
"repo.wiki",
"/wiki",
"repo.wiki.desc",
4,
perm.AccessModeOwner,
}
@ -359,7 +352,6 @@ var (
"ext_wiki",
"repo.ext_wiki",
"/wiki",
"repo.ext_wiki.desc",
4,
perm.AccessModeRead,
}
@ -369,7 +361,6 @@ var (
"projects",
"repo.projects",
"/projects",
"repo.projects.desc",
5,
perm.AccessModeOwner,
}
@ -379,7 +370,6 @@ var (
"packages",
"repo.packages",
"/packages",
"packages.desc",
6,
perm.AccessModeRead,
}
@ -389,7 +379,6 @@ var (
"actions",
"repo.actions",
"/actions",
"actions.unit.desc",
7,
perm.AccessModeOwner,
}

View file

@ -15,7 +15,6 @@ version = Version
powered_by = Powered by %s
page = Page
template = Template
language = Language
notifications = Notifications
active_stopwatch = Active time tracker
tracked_time_summary = Summary of tracked time based on filters of issue list
@ -53,11 +52,7 @@ webauthn_error_empty = You must set a name for this key.
webauthn_error_timeout = Timeout reached before your key could be read. Please reload this page and retry.
repository = Repository
organization = Organization
mirror = Mirror
new_mirror = New mirror
new_fork = New repository fork
new_project = New project
new_project_column = New column
admin_panel = Site administration
settings = Settings
@ -104,7 +99,6 @@ disabled = Disabled
locked = Locked
copy = Copy
copy_generic = Copy to clipboard
copy_url = Copy URL
copy_hash = Copy hash
copy_path = Copy path
@ -168,14 +162,6 @@ filter.private = Private
[search]
search = Search…
type_tooltip = Search type
fuzzy = Fuzzy
fuzzy_tooltip = Include results that also match the search term closely
union = Union
union_tooltip = Include results that match any of the whitespace separated keywords
exact = Exact
exact_tooltip = Include only results that match the exact search term
regexp = RegExp
regexp_tooltip = Interpret the search term as a regular expression
repo_kind = Search repos…
user_kind = Search users…
org_kind = Search orgs…
@ -367,7 +353,6 @@ save_config_failed = Failed to save configuration: %v
enable_update_checker_helper_forgejo = It will periodically check for new Forgejo versions by checking a TXT DNS record at release.forgejo.org.
invalid_admin_setting = Administrator account setting is invalid: %v
invalid_log_root_path = The log path is invalid: %v
allow_dots_in_usernames = Allow users to use dots in their usernames. Doesn't affect existing accounts.
no_reply_address = Hidden email domain
no_reply_address_helper = Domain name for users with a hidden email address. For example, the username "joe" will be logged in Git as "joe@noreply.example.org" if the hidden email domain is set to "noreply.example.org".
password_algorithm = Password hash algorithm
@ -535,8 +520,6 @@ totp_enrolled.subject = You have activated TOTP as 2FA method
totp_enrolled.text_1.no_webauthn = You have just enabled TOTP for your account. This means that for all future logins to your account, you must use TOTP as a 2FA method.
totp_enrolled.text_1.has_webauthn = You have just enabled TOTP for your account. This means that for all future logins to your account, you could use TOTP as a 2FA method or use any of your security keys.
register_success = Registration successful
issue_assigned.pull = @%[1]s assigned you to pull request %[2]s in repository %[3]s.
issue_assigned.issue = @%[1]s assigned you to issue %[2]s in repository %[3]s.
@ -581,7 +564,6 @@ yes = Yes
no = No
confirm = Confirm
cancel = Cancel
modify = Update
[form]
UserName = Username
@ -733,17 +715,14 @@ form.name_chars_not_allowed = Username "%s" contains invalid characters.
profile = Profile
account = Account
appearance = Appearance
password = Password
security = Security
avatar = Avatar
ssh_gpg_keys = SSH / GPG keys
applications = Applications
orgs = Organizations
repos = Repositories
delete = Delete account
twofa = Two-factor authentication (TOTP)
organization = Organizations
uid = UID
webauthn = Two-factor authentication (Security keys)
blocked_users = Blocked users
storage_overview = Storage overview
@ -765,12 +744,10 @@ update_language = Change language
update_language_not_found = Language "%s" is not available.
update_language_success = Language has been updated.
update_profile_success = Your profile has been updated.
change_username = Your username has been changed.
change_username_prompt = Note: Changing your username also changes your account URL.
change_username_redirect_prompt = The old username will redirect until someone claims it.
change_username_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day. You can still reclaim the old username during the cooldown period.
change_username_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days. You can still reclaim the old username during the cooldown period.
continue = Continue
cancel = Cancel
language = Language
language.title = Default language
@ -961,9 +938,7 @@ permissions_list = Permissions:
manage_oauth2_applications = Manage OAuth2 applications
edit_oauth2_application = Edit OAuth2 Application
oauth2_applications_desc = OAuth2 applications enables your third-party application to securely authenticate users at this Forgejo instance.
remove_oauth2_application = Remove OAuth2 Application
remove_oauth2_application_desc = Removing an OAuth2 application will revoke access to all signed access tokens. Continue?
remove_oauth2_application_success = The application has been deleted.
create_oauth2_application = Create a new OAuth2 application
create_oauth2_application_button = Create application
@ -977,7 +952,6 @@ oauth2_client_id = Client ID
oauth2_client_secret = Client secret
oauth2_regenerate_secret = Regenerate secret
oauth2_regenerate_secret_hint = Lost your secret?
oauth2_client_secret_hint = The secret will not be shown again after you leave or refresh this page. Please ensure that you have saved it.
oauth2_application_edit = Edit
oauth2_application_create_description = OAuth2 applications gives your third-party application access to user accounts on this instance.
oauth2_application_remove_description = Removing an OAuth2 application will prevent it from accessing authorized user accounts on this instance. Continue?
@ -1118,11 +1092,8 @@ open_with_editor = Open with %s
download_zip = Download ZIP
download_tar = Download TAR.GZ
download_bundle = Download BUNDLE
generate_repo = Generate repository
generate_from = Generate from
repo_desc = Description
repo_desc_helper = Enter short description (optional)
repo_lang = Language
repo_gitignore_helper = Select .gitignore templates
repo_gitignore_helper_desc = Choose which files not to track from a list of templates for common languages. Typical artifacts generated by each language's build tools are included on .gitignore by default.
issue_labels = Labels
@ -1160,7 +1131,6 @@ mirror_lfs = Large File Storage (LFS)
mirror_lfs_desc = Activate mirroring of LFS data.
mirror_lfs_endpoint = LFS endpoint
mirror_lfs_endpoint_desc = Sync will attempt to use the clone url to <a target="_blank" rel="noopener noreferrer" href="%s">determine the LFS server</a>. You can also specify a custom endpoint if the repository LFS data is stored somewhere else.
mirror_last_synced = Last synchronized
mirror_password_placeholder = (Unchanged)
mirror_password_blank_placeholder = (Unset)
mirror_password_help = Change the username to erase a stored password.
@ -1169,7 +1139,6 @@ stargazers = Stargazers
stars_remove_warning = This will remove all stars from this repository.
forks = Forks
stars = Stars
reactions_more = and %d more
unit_disabled = The site administrator has disabled this repository section.
language_other = Other
adopt_search = Enter username to search for unadopted repositories… (leave blank to find all)
@ -1187,9 +1156,9 @@ blame.ignore_revs.failed = Failed to ignore revisions in <a href="%s">.git-blame
author_search_tooltip = Shows a maximum of 30 users
summary_card_alt = Summary card of repository %s
tree_path_not_found_commit = Path %[1]s doesn't exist in commit %[2]s
tree_path_not_found_branch = Path %[1]s doesn't exist in branch %[2]s
tree_path_not_found_tag = Path %[1]s doesn't exist in tag %[2]s
tree_path_not_found.commit = Path %[1]s doesn't exist in commit %[2]s
tree_path_not_found.branch = Path %[1]s doesn't exist in branch %[2]s
tree_path_not_found.tag = Path %[1]s doesn't exist in tag %[2]s
transfer.accept = Accept transfer
transfer.accept_desc = Transfer to "%s"
@ -1199,7 +1168,6 @@ transfer.no_permission_to_accept = You do not have permission to accept this tra
transfer.no_permission_to_reject = You do not have permission to reject this transfer.
desc.private = Private
desc.public = Public
desc.template = Template
desc.internal = Internal
desc.archived = Archived
@ -1301,7 +1269,6 @@ unwatch = Unwatch
star = Star
unstar = Unstar
fork = Fork
download_archive = Download repository
more_operations = More operations
no_desc = No description
@ -1315,29 +1282,22 @@ broken_message = The Git data underlying this repository cannot be read. Contact
code = Code
code.desc = Access source code, files, commits and branches.
branch = Branch
tree = Tree
clear_ref = `Clear current reference`
filter_branch_and_tag = Filter branch or tag
find_tag = Find tag
branches = Branches
tag = Tag
tags = Tags
issues = Issues
pulls = Pull requests
project = Projects
packages = Packages
actions = Actions
release = Release
releases = Releases
labels = Labels
milestones = Milestones
org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
org_labels_desc_manage = manage
commits = Commits
commit = Commit
n_commit_one=%s commit
n_commit_few=%s commits
n_branch_one=%s branch
@ -1431,7 +1391,6 @@ editor.propose_file_change = Propose file change
editor.new_branch_name = Name the new branch for this commit
editor.new_branch_name_desc = New branch name…
editor.cancel = Cancel
editor.filename_cannot_be_empty = The filename cannot be empty.
editor.filename_is_invalid = The filename is invalid: "%s".
editor.invalid_commit_mail = Invalid mail for creating a commit.
editor.branch_does_not_exist = Branch "%s" does not exist in this repository.
@ -1465,7 +1424,6 @@ editor.cherry_pick = Cherry-pick %s onto:
editor.revert = Revert %s onto:
editor.commit_email = Commit email
commits.desc = Browse source code change history.
commits.commits = Commits
commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories.
commits.nothing_to_compare = These branches are equal.
@ -1477,8 +1435,6 @@ commits.message = Message
commits.browse_further = Browse further
commits.renamed_from = Renamed from %s
commits.date = Date
commits.older = Older
commits.newer = Newer
commits.signed_by = Signed by
commits.signed_by_untrusted_user = Signed by untrusted user
commits.signed_by_untrusted_user_unmatched = Signed by untrusted user who does not match committer
@ -1503,7 +1459,6 @@ commitstatus.success = Success
ext_issues = External issues
projects = Projects
projects.desc = Manage issues and pulls in project boards.
projects.description = Description (optional)
projects.description_placeholder = Description
projects.create = Create project
@ -1540,7 +1495,6 @@ projects.card_type.desc = Card previews
projects.card_type.images_and_text = Images and text
projects.card_type.text_only = Text only
issues.desc = Organize bug reports, tasks and milestones.
issues.filter_assignees = Filter assignee
issues.filter_milestones = Filter milestone
issues.filter_projects = Filter project
@ -1583,7 +1537,6 @@ issues.new_label = New label
issues.new_label_placeholder = Label name
issues.new_label_desc_placeholder = Description
issues.create_label = Create label
issues.label_templates.title = Load a label preset
issues.label_templates.info = No labels exist yet. Create a label with "New label" or use a label preset:
issues.label_templates.helper = Select a label preset
issues.label_templates.use = Use label preset
@ -1628,7 +1581,6 @@ issues.filter_assginee_no_assignee = No assignee
issues.filter_poster = Author
issues.filter_poster_no_select = All authors
issues.filter_type = Type
issues.filter_type.all_issues = All issues
issues.filter_type.all_pull_requests = All pull requests
issues.filter_type.assigned_to_you = Assigned to you
issues.filter_type.created_by_you = Created by you
@ -1751,7 +1703,6 @@ issues.label.filter_sort.reverse_by_size = Largest size
issues.num_participants_one = %d participant
issues.num_participants_few = %d participants
issues.attachment.open_tab = `Click to see "%s" in a new tab`
issues.attachment.download = `Click to download "%s"`
issues.subscribe = Subscribe
issues.unsubscribe = Unsubscribe
issues.unpin_issue = Unpin issue
@ -1799,7 +1750,6 @@ issues.del_time_history= `deleted spent time %s`
issues.add_time_hours = Hours
issues.add_time_minutes = Minutes
issues.add_time_sum_to_small = No time was entered.
issues.time_spent_total = Total time spent
issues.time_spent_from_all_authors = `Total time spent: %s`
issues.due_date = Due date
issues.push_commit_1 = added %d commit %s
@ -1855,7 +1805,6 @@ issues.review.dismissed_label = Dismissed
issues.review.left_comment = left a comment
issues.review.content.empty = You need to leave a comment indicating the requested change(s).
issues.review.reject = requested changes %s
issues.review.wait = was requested for review %s
issues.review.add_review_request = requested review from %[1]s %[2]s
issues.review.add_review_requests = requested reviews from %[1]s %[2]s
issues.review.remove_review_request = removed review request for %[1]s %[2]s
@ -1885,13 +1834,11 @@ issues.content_history.delete_from_history_confirm = Delete from history?
issues.content_history.options = Options
issues.blocked_by_user = You cannot create issues in this repository because you are blocked by the repository owner.
comment.blocked_by_user = Commenting is not possible because you are blocked by the repository owner or the author.
issues.reopen.blocked_by_user = You cannot reopen this issue because you are blocked by the repository owner or the poster of this issue.
issues.summary_card_alt = Summary card of an issue titled "%s" in repository %s
compare.compare_base = base
compare.compare_head = compare
pulls.desc = Enable pull requests and code reviews.
pulls.new = New pull request
pulls.view = View pull request
pulls.edit.already_changed = Unable to save changes to the pull request. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes
@ -1917,7 +1864,6 @@ pulls.show_changes_since_your_last_review = Show changes since your last review
pulls.showing_only_single_commit = Showing only changes of commit %[1]s
pulls.showing_specified_commit_range = Showing only changes between %[1]s..%[2]s
pulls.select_commit_hold_shift_for_range = Select commit. Hold shift + click to select a range
pulls.review_only_possible_for_full_diff = Review is only possible when viewing the full diff
pulls.filter_changes_by_commit = Filter by commit
pulls.nothing_to_compare = These branches are equal. There is no need to create a pull request.
pulls.nothing_to_compare_have_tag = The selected branches/tags are equal.
@ -2107,7 +2053,6 @@ ext_wiki = External Wiki
wiki = Wiki
wiki.welcome = Welcome to the wiki.
wiki.welcome_desc = The wiki lets you write and share documentation with collaborators.
wiki.desc = Write and share documentation with collaborators.
wiki.create_first_page = Create the first page
wiki.page = Page
wiki.filter_page = Filter page
@ -2737,11 +2682,9 @@ diff.protected = Protected
diff.image.side_by_side = Side by side
diff.image.swipe = Swipe
diff.image.overlay = Overlay
diff.has_escaped = This line has hidden Unicode characters
diff.show_file_tree = Show file tree
diff.hide_file_tree = Hide file tree
releases.desc = Track project versions and downloads.
release.releases = Releases
release.detail = Release details
release.tags = Tags
@ -2753,7 +2696,6 @@ release.compare = Compare
release.edit = Edit
release.ahead.commits = <strong>%d</strong> commits
release.ahead.target = to %s since this release
tag.ahead.target = to %s since this tag
release.source_code = Source code
release.new_subheader = Releases organize project versions.
release.edit_subheader = Releases organize project versions.
@ -2781,7 +2723,6 @@ release.deletion_tag_success = The tag has been deleted.
release.tag_name_already_exist = A release with this tag name already exists.
release.tag_name_invalid = The tag name is not valid.
release.tag_name_protected = The tag name is protected.
release.tag_already_exist = This tag name already exists.
release.downloads = Downloads
release.download_count_one = %s download
release.download_count_few = %s downloads
@ -2802,7 +2743,6 @@ release.summary_card_alt = Summary card of an release titled "%s" in repository
branch.name = Branch name
branch.already_exists = A branch named "%s" already exists.
branch.delete_head = Delete
branch.delete = Delete branch "%s"
branch.delete_html = Delete branch
branch.delete_desc = Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
@ -2842,7 +2782,6 @@ tag.create_tag_from = Create new tag from "%s"
tag.create_success = Tag "%s" has been created.
topic.manage_topics = Manage topics
topic.done = Done
topic.count_prompt = You cannot select more than 25 topics
topic.format_prompt = Topics must start with a letter or number, can include dashes ("-") and dots ("."), can be up to 35 characters long. Letters must be lowercase.
@ -2913,7 +2852,6 @@ form.create_org_not_allowed = You are not allowed to create an organization.
settings = Settings
settings.options = Organization
settings.full_name = Full name
settings.email = Contact email
settings.website = Website
settings.location = Location
@ -2943,8 +2881,6 @@ settings.hooks_desc = Add webhooks which will be triggered for <strong>all repos
settings.labels_desc = Add labels which can be used on issues for <strong>all repositories</strong> under this organization.
members.membership_visibility = Membership visibility:
members.public = Visible
members.public_helper = Make hidden
members.private = Hidden
members.private_helper = Make visible
@ -2955,8 +2891,6 @@ members.remove = Remove
members.remove.detail = Remove %[1]s from %[2]s?
members.leave = Leave
members.leave.detail = Are you sure you want to leave organization "%s"?
members.invite_desc = Add a new member to %s:
members.invite_now = Invite now
teams.join = Join
teams.leave = Leave
@ -2974,7 +2908,6 @@ teams.admin_access_helper = Members can pull and push to team repositories and a
teams.no_desc = This team has no description
teams.settings = Settings
teams.owners_permission_desc = Owners have full access to <strong>all repositories</strong> and have <strong>administrator access</strong> to the organization.
teams.members = Team members
teams.update_settings = Update settings
teams.delete_team = Delete team
teams.add_team_member = Add team member
@ -2984,11 +2917,7 @@ teams.delete_team_title = Delete team
teams.delete_team_desc = Deleting a team revokes repository access from its members. Continue?
teams.delete_team_success = The team has been deleted.
teams.admin_permission_desc = This team grants <strong>Administrator</strong> access: members can read from, push to and add collaborators to team repositories.
teams.create_repo_permission_desc = Additionally, this team grants <strong>Create repository</strong> permission: members can create new repositories in organization.
teams.repositories = Team repositories
teams.remove_all_repos_title = Remove all team repositories
teams.remove_all_repos_desc = This will remove all repositories from the team.
teams.add_all_repos_title = Add all repositories
teams.add_all_repos_desc = This will add all the organization's repositories to the team.
teams.add_nonexistent_repo = The repository you're trying to add doesn't exist, please create it first.
teams.add_duplicate_users = User is already a team member.
@ -3201,7 +3130,6 @@ repos.unadopted = Unadopted repositories
repos.unadopted.no_more = No unadopted repositories found.
repos.owner = Owner
repos.name = Name
repos.private = Private
repos.issues = Issues
repos.size = Size
repos.lfs_size = LFS size
@ -3240,7 +3168,6 @@ auths.updated = Updated
auths.auth_type = Authentication type
auths.auth_name = Authentication name
auths.security_protocol = Security protocol
auths.domain = Domain
auths.host = Host
auths.port = Port
auths.bind_dn = Bind DN
@ -3270,7 +3197,6 @@ auths.user_attribute_in_group = User attribute listed in group
auths.map_group_to_team = Map LDAP groups to Organization teams (leave the field empty to skip)
auths.map_group_to_team_removal = Remove users from synchronized teams if user does not belong to corresponding LDAP group
auths.enable_ldap_groups = Enable LDAP groups
auths.ms_ad_sa = MS AD search attributes
auths.smtp_auth = SMTP authentication type
auths.smtphost = SMTP host
auths.smtpport = SMTP port
@ -3336,7 +3262,6 @@ auths.delete_auth_desc = Deleting an authentication source prevents users from u
auths.still_in_used = The authentication source is still in use. Convert or delete any users using this authentication source first.
auths.deletion_success = The authentication source has been deleted.
auths.login_source_exist = The authentication source "%s" already exists.
auths.login_source_of_type_exist = An authentication source of this type already exists.
auths.unable_to_initialize_openid = Unable to initialize OpenID Connect Provider: %s
auths.invalid_openIdConnectAutoDiscoveryURL = Invalid Auto Discovery URL (this must be a valid URL starting with http:// or https://)
@ -3355,7 +3280,6 @@ config.run_mode = Run mode
config.git_version = Git version
config.app_data_path = App data path
config.repo_root_path = Repository root path
config.lfs_root_path = LFS root path
config.log_file_root_path = Log path
config.script_type = Script type
config.reverse_auth_user = Reverse proxy authentication user
@ -3433,9 +3357,6 @@ config.send_test_mail_submit = Send
config.test_mail_failed = Failed to send a test email to "%s": %v
config.test_mail_sent = A test email has been sent to "%s".
config.oauth_config = OAuth configuration
config.oauth_enabled = Enabled
config.cache_config = Cache configuration
config.cache_adapter = Cache adapter
config.cache_interval = Cache interval
@ -3454,10 +3375,8 @@ config.cookie_name = Cookie name
config.gc_interval_time = GC interval time
config.session_life_time = Session lifetime
config.https_only = HTTPS only
config.cookie_life_time = Cookie lifetime
config.picture_config = Picture and avatar configuration
config.picture_service = Picture service
config.disable_gravatar = Disable Gravatar
config.enable_federated_avatar = Enable federated avatars
config.open_with_editor_app_help = The "Open with" editors for the clone menu. If left empty, the default will be used. Expand to see the default.
@ -3477,7 +3396,6 @@ config.git_gc_timeout = GC Operation timeout
config.log_config = Log configuration
config.logger_name_fmt = Logger: %s
config.disabled_logger = Disabled
config.access_log_mode = Access log mode
config.access_log_template = Access log template
config.xorm_log_sql = Log SQL
@ -3496,14 +3414,10 @@ monitor.stacktrace = Stacktrace
monitor.processes_count = %d Processes
monitor.download_diagnosis_report = Download diagnosis report
monitor.duration = Duration (s)
monitor.desc = Description
monitor.start = Start Time
monitor.execute_time = Execution Time
monitor.last_execution_result = Result
monitor.process.cancel = Cancel process
monitor.process.cancel_desc = Canceling a process may cause data loss
monitor.process.cancel_notices = Cancel: <strong>%s</strong>?
monitor.process.children = Children
monitor.queues = Queues
monitor.queue = Queue: %s
@ -3642,11 +3556,9 @@ error.probable_bad_default_signature = WARNING! Although the default key has thi
[units]
unit = Unit
error.no_unit_allowed_repo = You are not allowed to access any section of this repository.
error.unit_not_allowed = You are not allowed to access this repository section.
[packages]
title = Packages
desc = Manage repository packages.
empty = There are no packages yet.
empty.documentation = For more information on the package registry, see <a target="_blank" rel="noopener noreferrer" href="%s">the documentation</a>.
empty.repo = Did you upload a package, but it's not shown here? Go to <a href="%[1]s">package settings</a> and link it to this repo.
@ -3707,7 +3619,6 @@ composer.registry = Setup this registry in your <code>~/.composer/config.json</c
composer.install = To install the package using Composer, run the following command:
composer.dependencies = Dependencies
composer.dependencies.development = Development dependencies
conan.details.repository = Repository
conan.registry = Setup this registry from the command line:
conan.install = To install the package using Conan, run the following command:
conda.registry = Setup this registry as a Conda repository in your <code>.condarc</code> file:
@ -3806,7 +3717,6 @@ owner.settings.cleanuprules.none = There are no cleanup rules yet.
owner.settings.cleanuprules.preview = Cleanup rule preview
owner.settings.cleanuprules.preview.overview = %d packages are scheduled to be removed.
owner.settings.cleanuprules.preview.none = Cleanup rule does not match any packages.
owner.settings.cleanuprules.enabled = Enabled
owner.settings.cleanuprules.pattern_full_match = Apply pattern to full package name
owner.settings.cleanuprules.keep.title = Versions that match these rules are kept, even if they match a removal rule below.
owner.settings.cleanuprules.keep.count = Keep the most recent
@ -3840,7 +3750,6 @@ management = Manage secrets
[actions]
actions = Actions
unit.desc = Manage integrated CI/CD pipelines with Forgejo Actions.
status.unknown = Unknown
status.waiting = Waiting
@ -3931,7 +3840,6 @@ variables.none = There are no variables yet.
variables.deletion = Remove variable
variables.deletion.description = Removing a variable is permanent and cannot be undone. Continue?
variables.description = Variables will be passed to certain actions and cannot be read otherwise.
variables.id_not_exist = Variable with ID %d does not exist.
variables.edit = Edit Variable
variables.not_found = Failed to find the variable.
variables.deletion.failed = Failed to remove variable.

View file

@ -124,6 +124,7 @@
"settings.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts.",
"error.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: %s",
"avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels",
"user.ghost.tooltip": "This user has been deleted, or cannot be matched.",
"og.repo.summary_card.alt_description": "Summary card of repository %[1]s, described as: %[2]s",
"repo.commit.load_tags_failed": "Load tags failed because of internal error",
"compare.branches.title": "Compare branches",

1
release-notes/9023.md Normal file
View file

@ -0,0 +1 @@
The `artifact-url` ouput [returned by the upload-artifact@v4 action](https://code.forgejo.org/actions/upload-artifact#outputs) can be used to download the artifact. It was previously 404. To implement this compatibility fix, the web UI URL to download artifacts (i.e. `/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_name}`) now relies on an identifier that is unique accross the instance. URLs to download artifacts that were bookmarked or copied prior to this change use an id relative to the repository and will no longer work. It previously was `/{owner}/{repo}/actions/runs/{run_index}/artifacts/{artifact_name}`, note the difference between `{run_id}` and `{run_index}`. The new URL can be obtained again by visiting the parent page, which still uses the relative id (`/{owner}/{repo}/actions/runs/{run_index}`).

View file

@ -53,6 +53,7 @@ func View(ctx *context_module.Context) {
workflowName := job.Run.WorkflowID
ctx.Data["RunIndex"] = runIndex
ctx.Data["RunID"] = job.Run.ID
ctx.Data["JobIndex"] = jobIndex
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
ctx.Data["WorkflowName"] = workflowName
@ -668,6 +669,31 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
ctx.JSON(http.StatusOK, struct{}{})
}
func getRunByID(ctx *context_module.Context, runID int64) *actions_model.ActionRun {
if runID == 0 {
log.Debug("Requested runID is zero.")
ctx.Error(http.StatusNotFound, "zero is not a valid run ID")
return nil
}
run, has, err := actions_model.GetRunByIDWithHas(ctx, runID)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return nil
}
if !has {
log.Debug("Requested runID[%d] not found.", runID)
ctx.Error(http.StatusNotFound, fmt.Sprintf("no such run %d", runID))
return nil
}
if run.RepoID != ctx.Repo.Repository.ID {
log.Debug("Requested runID[%d] does not belong to repo[%-v].", runID, ctx.Repo.Repository)
ctx.Error(http.StatusNotFound, "no such run")
return nil
}
return run
}
func artifactsFind(ctx *context_module.Context, opts actions_model.FindArtifactsOptions) []*actions_model.ActionArtifact {
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, opts)
if err != nil {
@ -711,18 +737,11 @@ func artifactsFindByNameOrID(ctx *context_module.Context, runID int64, nameOrID
}
func ArtifactsDownloadView(ctx *context_module.Context) {
runIndex := ctx.ParamsInt64("run")
artifactNameOrID := ctx.Params("artifact_name_or_id")
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, err.Error())
return
}
ctx.Error(http.StatusInternalServerError, err.Error())
run := getRunByID(ctx, ctx.ParamsInt64("run"))
if ctx.Written() {
return
}
artifactNameOrID := ctx.Params("artifact_name_or_id")
artifacts := artifactsFindByNameOrID(ctx, run.ID, artifactNameOrID)
if ctx.Written() {

View file

@ -8,12 +8,54 @@ import (
"testing"
actions_model "forgejo.org/models/actions"
repo_model "forgejo.org/models/repo"
unittest "forgejo.org/models/unittest"
"forgejo.org/services/contexttest"
"github.com/stretchr/testify/assert"
)
func Test_getRunByID(t *testing.T) {
unittest.PrepareTestEnv(t)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: 5, ID: 4})
for _, testCase := range []struct {
name string
runID int64
err string
}{
{
name: "Found",
runID: 792,
},
{
name: "NotFound",
runID: 24344,
err: "no such run",
},
{
name: "ZeroNotFound",
runID: 0,
err: "zero is not a valid run ID",
},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, fmt.Sprintf("user5/repo4/actions/runs/%v/artifacts/some-name", testCase.runID))
ctx.Repo.Repository = repo
run := getRunByID(ctx, testCase.runID)
if testCase.err == "" {
assert.NotNil(t, run)
assert.False(t, ctx.Written(), resp.Body.String())
} else {
assert.Nil(t, run)
assert.True(t, ctx.Written())
assert.Contains(t, resp.Body.String(), testCase.err)
}
})
}
}
func Test_artifactsFind(t *testing.T) {
unittest.PrepareTestEnv(t)

View file

@ -35,7 +35,7 @@ func HandleGitError(ctx *context.Context, msg string, err error) {
case ctx.Repo.IsViewCommit:
refType = "commit"
}
ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.tree_path_not_found_"+refType, ctx.Repo.TreePath, url.PathEscape(ctx.Repo.RefName))
ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.tree_path_not_found."+refType, ctx.Repo.TreePath, url.PathEscape(ctx.Repo.RefName))
ctx.Data["NotFoundGoBackURL"] = ctx.Repo.RepoLink + "/src/" + refType + "/" + url.PathEscape(ctx.Repo.RefName)
ctx.NotFound(msg, err)
} else {

View file

@ -35,7 +35,7 @@ func TaskStatus(ctx *context.Context) {
var translatableMessage admin_model.TranslatableMessage
if err := json.Unmarshal([]byte(message), &translatableMessage); err != nil {
translatableMessage = admin_model.TranslatableMessage{
Format: "migrate.migrating_failed.error",
Format: "repo.migrate.migrating_failed.error",
Args: []any{task.Message},
}
}

View file

@ -427,6 +427,7 @@ func (diffFile *DiffFile) ShouldBeHidden() bool {
return diffFile.IsGenerated || diffFile.IsViewed
}
//llu:returnsTrKey
func (diffFile *DiffFile) ModeTranslationKey(mode string) string {
switch mode {
case "040000":

View file

@ -53,7 +53,7 @@ func sendMailActionRun(to *user_model.User, run *actions_model.ActionRun, priorS
if run.Status.IsSuccess() {
subject = locale.TrString("mail.actions.successful_run_after_failure_subject", run.Title, run.Repo.FullName())
} else {
subject = locale.TrString("mail.actions.not_successful_run", run.Title, run.Repo.FullName())
subject = locale.TrString("mail.actions.not_successful_run_subject", run.Title, run.Repo.FullName())
}
commitSHA := run.CommitSHA

View file

@ -4,6 +4,7 @@
{{template "repo/header" .}}
<div id="repo-action-view"
data-run-index="{{.RunIndex}}"
data-run-id="{{.RunID}}"
data-job-index="{{.JobIndex}}"
data-actions-url="{{.ActionsURL}}"
data-workflow-name="{{.WorkflowName}}"

View file

@ -29,7 +29,7 @@
{{end}}
</h4>
<div>
{{if $.CanCreateRelease}}
{{if and $.CanCreateRelease (not $release.IsTag)}}
<a class="muted" data-tooltip-content="{{ctx.Locale.Tr "repo.release.edit"}}" href="{{$.RepoLink}}/releases/edit/{{$release.TagName | PathEscapeSegments}}" rel="nofollow">
{{svg "octicon-pencil"}}
</a>
@ -42,9 +42,10 @@
{{svg (MigrationIcon $release.Repo.GetOriginalURLHostname) 20 "tw-mr-1"}}{{$release.OriginalAuthor}}
{{else if $release.Publisher}}
{{ctx.AvatarUtils.Avatar $release.Publisher 20 "tw-mr-1"}}
<a href="{{$release.Publisher.HomeLink}}">{{$release.Publisher.GetDisplayName}}</a>
{{template "shared/user/authorlink" $release.Publisher}}
{{else}}
Ghost
<!-- Fake the Ghost user -->
{{template "shared/user/authorlink" (dict "ID" -1)}}
{{end}}
</span>
<span class="released">

View file

@ -9,7 +9,7 @@
{{$class = (print $class " isWarning")}}
{{end}}
<div class="ui attached message tw-text-left tw-flex tw-mb-4 tw-content-center tw-justify-between tag-signature-row tw-flex-wrap {{$class}}">
<div class="ui bottom attached message tw-text-left tw-flex tw-mb-4 tw-content-center tw-justify-between tag-signature-row tw-flex-wrap {{$class}}">
<div class="tw-flex tw-content-center">
{{if $v.Verified}}
{{if ne $v.SigningUser.ID 0}}
@ -62,14 +62,14 @@
{{else}}
{{if $v.SigningKey}}
{{if ne $v.SigningKey.KeyID ""}}
{{svg "octicon-verified" 16 "tw-mr-2"}}
{{svg "octicon-unverified" 16 "tw-mr-2"}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.gpg_key_id"}}:</span>
{{$v.SigningKey.PaddedKeyID}}
{{end}}
{{end}}
{{if $v.SigningSSHKey}}
{{if ne $v.SigningSSHKey.Fingerprint ""}}
{{svg "octicon-verified" 16 "tw-mr-2"}}
{{svg "octicon-unverified" 16 "tw-mr-2"}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}:</span>
{{$v.SigningSSHKey.Fingerprint}}
{{end}}

View file

@ -1 +1,7 @@
<a class="author text black tw-font-semibold muted"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{if .IsBot}}<span class="ui basic label tw-p-1 tw-align-baseline">bot</span>{{end}}
{{if eq .ID -1}}
<a class="author text black tw-font-semibold muted"
data-tooltip-content="{{ctx.Locale.Tr "user.ghost.tooltip"}}">Ghost</a>
{{else}}
<a class="author text black tw-font-semibold muted"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>
{{if .IsBot}}<span class="ui basic label tw-p-1 tw-align-baseline">bot</span>{{end}}
{{end}}

View file

@ -30,57 +30,59 @@ test('PR: Create review from files', async ({page}) => {
await save_visual(page);
});
test('PR: Create review from commit', async ({page}) => {
const response = await page.goto('/user2/repo1/pulls/3/commits/4a357436d925b5c974181ff12a994538ddc5a269');
expect(response?.status()).toBe(200);
await page.locator('button.add-code-comment').click();
const code_comment = page.locator('.comment-code-cloud form textarea.markdown-text-editor');
await expect(code_comment).toBeVisible();
await code_comment.fill('This is a code comment');
await save_visual(page);
const start_button = page.locator('.comment-code-cloud form button.btn-start-review');
// Workaround for #7152, where there might already be a pending review state from previous
// test runs (most likely to happen when debugging tests).
if (await start_button.isVisible({timeout: 100})) {
await start_button.click();
} else {
await page.locator('.comment-code-cloud form button[name="pending_review"]').click();
}
await expect(page.locator('.comment-list .comment-container')).toBeVisible();
// We need to wait for the review to be processed. Checking the comment counter
// conveniently does that.
await expect(page.locator('#review-box .js-btn-review > span.review-comments-counter')).toHaveText('1');
await page.locator('#review-box .js-btn-review').click();
await expect(page.locator('.tippy-box .review-box-panel')).toBeVisible();
await save_visual(page);
await page.locator('.review-box-panel textarea.markdown-text-editor')
.fill('This is a review');
await page.locator('.review-box-panel button.btn-submit[value="approve"]').click();
await page.waitForURL(/.*\/user2\/repo1\/pulls\/3#issuecomment-\d+/);
await save_visual(page);
// In addition to testing the ability to delete comments, this also
// performs clean up. If tests are run for multiple platforms, the data isn't reset
// in-between, and subsequent runs of this test would fail, because when there already is
// a comment, the on-hover button to start a conversation doesn't appear anymore.
await page.goto('/user2/repo1/pulls/3/commits/4a357436d925b5c974181ff12a994538ddc5a269');
await page.locator('.comment-header-right.actions a.context-menu').click();
await expect(page.locator('.comment-header-right.actions div.menu').getByText(/Copy link.*/)).toBeVisible();
// The button to delete a comment will prompt for confirmation using a browser alert.
page.on('dialog', (dialog) => dialog.accept());
await page.locator('.comment-header-right.actions div.menu .delete-comment').click();
await expect(page.locator('.comment-list .comment-container')).toBeHidden();
await save_visual(page);
});
// Test disabled because it is flaky.
// TODO: re-enable it
// test('PR: Create review from commit', async ({page}) => {
// const response = await page.goto('/user2/repo1/pulls/3/commits/4a357436d925b5c974181ff12a994538ddc5a269');
// expect(response?.status()).toBe(200);
//
// await page.locator('button.add-code-comment').click();
// const code_comment = page.locator('.comment-code-cloud form textarea.markdown-text-editor');
// await expect(code_comment).toBeVisible();
//
// await code_comment.fill('This is a code comment');
// await save_visual(page);
//
// const start_button = page.locator('.comment-code-cloud form button.btn-start-review');
// // Workaround for #7152, where there might already be a pending review state from previous
// // test runs (most likely to happen when debugging tests).
// if (await start_button.isVisible({timeout: 100})) {
// await start_button.click();
// } else {
// await page.locator('.comment-code-cloud form button[name="pending_review"]').click();
// }
//
// await expect(page.locator('.comment-list .comment-container')).toBeVisible();
//
// // We need to wait for the review to be processed. Checking the comment counter
// // conveniently does that.
// await expect(page.locator('#review-box .js-btn-review > span.review-comments-counter')).toHaveText('1');
//
// await page.locator('#review-box .js-btn-review').click();
// await expect(page.locator('.tippy-box .review-box-panel')).toBeVisible();
// await save_visual(page);
//
// await page.locator('.review-box-panel textarea.markdown-text-editor')
// .fill('This is a review');
// await page.locator('.review-box-panel button.btn-submit[value="approve"]').click();
// await page.waitForURL(/.*\/user2\/repo1\/pulls\/3#issuecomment-\d+/);
// await save_visual(page);
//
// // In addition to testing the ability to delete comments, this also
// // performs clean up. If tests are run for multiple platforms, the data isn't reset
// // in-between, and subsequent runs of this test would fail, because when there already is
// // a comment, the on-hover button to start a conversation doesn't appear anymore.
// await page.goto('/user2/repo1/pulls/3/commits/4a357436d925b5c974181ff12a994538ddc5a269');
// await page.locator('.comment-header-right.actions a.context-menu').click();
//
// await expect(page.locator('.comment-header-right.actions div.menu').getByText(/Copy link.*/)).toBeVisible();
// // The button to delete a comment will prompt for confirmation using a browser alert.
// page.on('dialog', (dialog) => dialog.accept());
// await page.locator('.comment-header-right.actions div.menu .delete-comment').click();
//
// await expect(page.locator('.comment-list .comment-container')).toBeHidden();
// await save_visual(page);
// });
test('PR: Navigate by single commit', async ({page}) => {
const response = await page.goto('/user2/repo1/pulls/3/commits');

View file

@ -146,38 +146,3 @@ func TestActionsWebRouteLatestRun(t *testing.T) {
assert.Equal(t, workflow.HTMLURL(), resp.Header().Get("Location"))
})
}
func TestActionsWebRouteArtifactDeletion(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, _, f := tests.CreateDeclarativeRepo(t, user2, "",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/pr.yml",
ContentReader: strings.NewReader("name: test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
},
},
)
defer f()
// a run has been created
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
// Load the run we just created
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
err := run.LoadAttributes(t.Context())
require.NoError(t, err)
// Visit it's web view
req := NewRequest(t, "GET", run.HTMLURL())
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Assert that the artifact deletion markup exists
htmlDoc.AssertElement(t, "[data-locale-confirm-delete-artifact]", true)
})
}

View file

@ -0,0 +1,114 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"bytes"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
actions_model "forgejo.org/models/actions"
unit_model "forgejo.org/models/unit"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
files_service "forgejo.org/services/repository/files"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsViewArtifactDeletion(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, _, f := tests.CreateDeclarativeRepo(t, user2, "",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/pr.yml",
ContentReader: strings.NewReader("name: test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
},
},
)
defer f()
// a run has been created
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
// Load the run we just created
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
err := run.LoadAttributes(t.Context())
require.NoError(t, err)
// Visit it's web view
req := NewRequest(t, "GET", run.HTMLURL())
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Assert that the artifact deletion markup exists
htmlDoc.AssertElement(t, "[data-locale-confirm-delete-artifact]", true)
})
}
func TestActionViewsArtifactDownload(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
assertDataAttrs := func(t *testing.T, body *bytes.Buffer, runID int64) {
t.Helper()
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
htmlDoc := NewHTMLParser(t, body)
selector := "#repo-action-view"
htmlDoc.AssertAttrEqual(t, selector, "data-run-id", fmt.Sprintf("%d", run.ID))
htmlDoc.AssertAttrEqual(t, selector, "data-run-index", fmt.Sprintf("%d", run.Index))
}
t.Run("V3", func(t *testing.T) {
runIndex := 187
runID := int64(791)
req := NewRequest(t, "GET", fmt.Sprintf("/user5/repo4/actions/runs/%d/artifacts", runIndex))
resp := MakeRequest(t, req, http.StatusOK)
assert.JSONEq(t, `{"artifacts":[{"name":"multi-file-download","size":2048,"status":"completed"}]}`, strings.TrimSuffix(resp.Body.String(), "\n"))
req = NewRequest(t, "GET", fmt.Sprintf("/user5/repo4/actions/runs/%d", runIndex))
resp = MakeRequest(t, req, http.StatusOK)
assertDataAttrs(t, resp.Body, runID)
req = NewRequest(t, "GET", fmt.Sprintf("/user5/repo4/actions/runs/%d/artifacts/multi-file-download", runID))
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Header().Get("content-disposition"), "multi-file-download.zip")
})
t.Run("V4", func(t *testing.T) {
runIndex := 188
runID := int64(792)
req := NewRequest(t, "GET", fmt.Sprintf("/user5/repo4/actions/runs/%d/artifacts", runIndex))
resp := MakeRequest(t, req, http.StatusOK)
assert.JSONEq(t, `{"artifacts":[{"name":"artifact-v4-download","size":1024,"status":"completed"}]}`, strings.TrimSuffix(resp.Body.String(), "\n"))
req = NewRequest(t, "GET", fmt.Sprintf("/user5/repo4/actions/runs/%d", runIndex))
resp = MakeRequest(t, req, http.StatusOK)
assertDataAttrs(t, resp.Body, runID)
download := fmt.Sprintf("/user5/repo4/actions/runs/%d/artifacts/artifact-v4-download", runID)
req = NewRequest(t, "GET", download)
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "bytes", resp.Header().Get("accept-ranges"))
assert.Contains(t, resp.Header().Get("content-disposition"), "artifact-v4-download.zip")
assert.Equal(t, strings.Repeat("D", 1024), resp.Body.String())
// Partial artifact download
req = NewRequest(t, "GET", download).SetHeader("range", "bytes=0-99")
resp = MakeRequest(t, req, http.StatusPartialContent)
assert.Equal(t, "bytes 0-99/1024", resp.Header().Get("content-range"))
assert.Equal(t, strings.Repeat("D", 100), resp.Body.String())
})
}

View file

@ -267,11 +267,6 @@ func TestActionsArtifactDownloadMultiFiles(t *testing.T) {
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, strings.Repeat(bodyChar, 1024), resp.Body.String())
}
// Download artifact via user-facing URL
req = NewRequest(t, "GET", "/user5/repo4/actions/runs/187/artifacts/multi-file-download")
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Header().Get("content-disposition"), "multi-file-download.zip")
}
func TestActionsArtifactUploadWithRetentionDays(t *testing.T) {

View file

@ -339,20 +339,6 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) {
body := strings.Repeat("D", 1024)
assert.Equal(t, "bytes", resp.Header().Get("accept-ranges"))
assert.Equal(t, body, resp.Body.String())
// Download artifact via user-facing URL
req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact-v4-download")
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "bytes", resp.Header().Get("accept-ranges"))
assert.Contains(t, resp.Header().Get("content-disposition"), "artifact-v4-download.zip")
assert.Equal(t, body, resp.Body.String())
// Partial artifact download
req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact-v4-download").SetHeader("range", "bytes=0-99")
resp = MakeRequest(t, req, http.StatusPartialContent)
body = strings.Repeat("D", 100)
assert.Equal(t, "bytes 0-99/1024", resp.Header().Get("content-range"))
assert.Equal(t, body, resp.Body.String())
}
func TestActionsArtifactV4DownloadRange(t *testing.T) {
@ -378,12 +364,6 @@ func TestActionsArtifactV4DownloadRange(t *testing.T) {
resp = MakeRequest(t, req, http.StatusPartialContent)
assert.Equal(t, "bytes 100-199/1024", resp.Header().Get("content-range"))
assert.Equal(t, bstr, resp.Body.String())
// Download (user-facing API)
req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact-v4-download").SetHeader("range", "bytes=100-199")
resp = MakeRequest(t, req, http.StatusPartialContent)
assert.Equal(t, "bytes 100-199/1024", resp.Header().Get("content-range"))
assert.Equal(t, bstr, resp.Body.String())
}
func TestActionsArtifactV4Delete(t *testing.T) {

View file

@ -26,6 +26,17 @@ func NewHTMLParser(t testing.TB, body *bytes.Buffer) *HTMLDoc {
return &HTMLDoc{doc: doc}
}
func (doc *HTMLDoc) AssertAttrEqual(t testing.TB, selector, attr, expected string) bool {
t.Helper()
selection := doc.doc.Find(selector)
require.NotEmpty(t, selection, selector)
actual, exists := selection.Attr(attr)
require.True(t, exists, "%s not found in %s", attr, selection.Text())
return assert.Equal(t, expected, actual)
}
// GetInputValueByID for get input value by id
func (doc *HTMLDoc) GetInputValueByID(id string) string {
text, _ := doc.doc.Find("#" + id).Attr("value")

View file

@ -32,12 +32,14 @@ func TestTagViewWithoutRelease(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.Name)
err := release.CreateNewTag(git.DefaultContext, owner, repo, "master", "no-release", "release-less tag")
require.NoError(t, err)
// Test that the page loads
req := NewRequestf(t, "GET", "/%s/releases/tag/no-release", repo.FullName())
resp := MakeRequest(t, req, http.StatusOK)
resp := session.MakeRequest(t, req, http.StatusOK)
// Test that the tags sub-menu is active and has a counter
htmlDoc := NewHTMLParser(t, resp.Body)
@ -54,6 +56,33 @@ func TestTagViewWithoutRelease(t *testing.T) {
// Test that there is no "Stable" link
htmlDoc.AssertElement(t, "h4.release-list-title > span.ui.green.label", false)
// Ensure that there is no "Edit" button
htmlDoc.AssertElement(t, ".detail a.muted > svg.octicon-pencil", false)
// Test that the correct user is linked
ownerLinkHref, _ := htmlDoc.Find("a.author").Attr("href")
assert.Equal(t, "/user2", ownerLinkHref)
t.Run("Ghost owner", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
ghost := user_model.NewGhostUser()
err = release.CreateNewTag(git.DefaultContext, ghost, repo, "master", "ghost-tag", "a spooky tag")
require.NoError(t, err)
req := NewRequestf(t, "GET", "/%s/releases/tag/ghost-tag", repo.FullName())
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Test that the Ghost user does not link anywhere
ownerLink := htmlDoc.Find("a.author")
_, ok := ownerLink.Attr("href")
assert.Equal(t, 1, ownerLink.Length())
assert.False(t, ok)
assert.Equal(t, "Ghost", ownerLink.Text())
})
}
func TestCreateNewTagProtected(t *testing.T) {

View file

@ -215,3 +215,104 @@ test('load multiple steps on a finished action', async () => {
expect(wrapper.get('.job-step-section:nth-of-type(2) .job-log-line:nth-of-type(2) .log-msg').text()).toEqual('Step #2 Log #2');
expect(wrapper.get('.job-step-section:nth-of-type(2) .job-log-line:nth-of-type(3) .log-msg').text()).toEqual('Step #2 Log #3');
});
test('artifacts download links', async () => {
Object.defineProperty(document.documentElement, 'lang', {value: 'en'});
vi.spyOn(global, 'fetch').mockImplementation((url, opts) => {
if (url.endsWith('/artifacts')) {
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue(
{
artifacts: [
{name: 'artifactname1', size: 111, status: 'completed'},
{name: 'artifactname2', size: 222, status: 'expired'},
],
},
),
});
}
const postBody = JSON.parse(opts.body);
const stepsLog_value = [];
for (const cursor of postBody.logCursors) {
if (cursor.expanded) {
stepsLog_value.push(
{
step: cursor.step,
cursor: 0,
lines: [
{index: 1, message: `Step #${cursor.step + 1} Log #1`, timestamp: 0},
],
},
);
}
}
const jobs_value = {
state: {
run: {
status: 'success',
commit: {
pusher: {},
},
},
currentJob: {
steps: [
{
summary: 'Test Step #1',
duration: '1s',
status: 'success',
},
],
},
},
logs: {
stepsLog: opts.body?.includes('"cursor":null') ? stepsLog_value : [],
},
};
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue(
jobs_value,
),
});
});
const wrapper = mount(RepoActionView, {
props: {
actionsURL: 'https://example.com/example-org/example-repo/actions',
runIndex: '10',
runID: '1001',
jobIndex: '2',
locale: {
approve: '',
cancel: '',
rerun: '',
artifactsTitle: 'artifactTitleHere',
areYouSure: '',
confirmDeleteArtifact: '',
rerun_all: '',
showTimeStamps: '',
showLogSeconds: '',
showFullScreen: '',
downloadLogs: '',
status: {
unknown: '',
waiting: '',
running: '',
success: '',
failure: '',
cancelled: '',
skipped: '',
blocked: '',
},
},
},
});
await flushPromises();
expect(wrapper.get('.job-artifacts .job-artifacts-title').text()).toEqual('artifactTitleHere');
expect(wrapper.get('.job-artifacts .job-artifacts-item:nth-of-type(1) .job-artifacts-link').attributes('href')).toEqual('https://example.com/example-org/example-repo/actions/runs/1001/artifacts/artifactname1');
expect(wrapper.get('.job-artifacts .job-artifacts-item:nth-of-type(2) .job-artifacts-link').attributes('href')).toEqual('https://example.com/example-org/example-repo/actions/runs/1001/artifacts/artifactname2');
});

View file

@ -15,6 +15,7 @@ const sfc = {
},
props: {
runIndex: String,
runID: String,
jobIndex: String,
actionsURL: String,
workflowName: String,
@ -386,6 +387,7 @@ export function initRepositoryActionView() {
const view = createApp(sfc, {
runIndex: el.getAttribute('data-run-index'),
runID: el.getAttribute('data-run-id'),
jobIndex: el.getAttribute('data-job-index'),
actionsURL: el.getAttribute('data-actions-url'),
workflowName: el.getAttribute('data-workflow-name'),
@ -473,7 +475,7 @@ export function initRepositoryActionView() {
</div>
<ul class="job-artifacts-list">
<li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name">
<a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
<a class="job-artifacts-link" target="_blank" :href="actionsURL+'/runs/'+runID+'/artifacts/'+artifact.name">
<SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}
</a>
<a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete">