0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-15 04:13:35 +02:00

fix review

This commit is contained in:
Nicolas 2026-05-09 10:00:13 +02:00
parent 49b6064074
commit a1915f1c4d
2 changed files with 7 additions and 671 deletions

View File

@ -41,114 +41,22 @@ var mockActionsArtifactFiles = map[string][]mockArtifactFile{
},
"artifact-lcov-coverage": {
{
Path: "coverage/index.html",
Content: `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Coverage Report</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
.ok { color: #1f883d; font-weight: 600; }
.warn { color: #9a6700; font-weight: 600; }
</style>
</head>
<body>
<h1>LCOV Coverage Report</h1>
<p>Generated from mock fixture artifact.</p>
<table>
<thead>
<tr>
<th>File</th>
<th>Lines</th>
<th>Branches</th>
<th>Functions</th>
</tr>
</thead>
<tbody>
<tr>
<td>web_src/js/components/RepoActionView.vue</td>
<td class="ok">100.0% (7/7)</td>
<td class="warn">75.0% (3/4)</td>
<td class="ok">100.0% (3/3)</td>
</tr>
<tr>
<td>web_src/js/features/repo-actions.ts</td>
<td class="ok">100.0% (5/5)</td>
<td>n/a</td>
<td class="ok">100.0% (2/2)</td>
</tr>
</tbody>
</table>
<p><a href="./lcov.info">Open raw lcov.info</a></p>
</body>
</html>`,
Path: "coverage/index.html",
Content: "<html><body>mock lcov coverage report</body></html>",
},
{
Path: "coverage/lcov.info",
Content: `TN:
SF:web_src/js/components/RepoActionView.vue
FN:33,artifactBaseURL
FN:37,artifactPreviewURL
FN:41,deleteArtifact
FNF:3
FNH:3
FNDA:9,artifactBaseURL
FNDA:8,artifactPreviewURL
FNDA:2,deleteArtifact
DA:33,9
DA:34,9
DA:37,8
DA:38,8
DA:41,2
DA:42,2
DA:43,2
LF:7
LH:7
BRDA:131,0,0,1
BRDA:131,0,1,3
BRDA:140,1,0,1
BRDA:140,1,1,0
BRF:4
BRH:3
end_of_record
TN:
SF:web_src/js/features/repo-actions.ts
FN:12,loadRunView
FN:61,fetchRunArtifacts
FNF:2
FNH:2
FNDA:5,loadRunView
FNDA:5,fetchRunArtifacts
DA:12,5
DA:13,5
DA:14,5
DA:61,5
DA:62,5
LF:5
LH:5
BRF:0
BRH:0
end_of_record
`,
Path: "coverage/lcov.info",
Content: "TN:\nSF:mock.go\nend_of_record\n",
},
{
Path: "coverage/summary.txt",
Content: "HTML coverage fixture with linked lcov.info and realistic function/line/branch records.",
Content: "mock coverage summary",
},
},
"artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong": {
{
Path: "index.html",
Content: `<!doctype html>
<html>
<body>
<h1>Mock Artifact Preview</h1>
<p>artifact-really-loooooong</p>
</body>
</html>`,
Path: "index.html",
Content: "<html><body>mock preview</body></html>",
},
{
Path: "logs/output.txt",

View File

@ -4,19 +4,13 @@
package actions
import (
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
actions_model "code.gitea.io/gitea/models/actions"
@ -27,15 +21,10 @@ import (
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/util/filebuffer"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common"
actions_service "code.gitea.io/gitea/services/actions"
@ -266,55 +255,6 @@ type ViewRequest struct {
LogCursors []LogCursor `json:"logCursors"`
}
type ArtifactsViewItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
Status string `json:"status"`
ExpiresUnix int64 `json:"expiresUnix"`
}
type ArtifactPreviewFile struct {
Path string
Selected bool
}
const (
artifactPreviewV4ZipListCacheTTL = 10 * time.Minute
artifactPreviewV4ZipListCacheMaxEntries = 128
)
type artifactPreviewV4ZipListCacheEntry struct {
paths []string
expiresAt time.Time
}
var artifactPreviewV4ZipListCache = struct {
mu sync.Mutex
entries map[string]artifactPreviewV4ZipListCacheEntry
order []string
}{
entries: map[string]artifactPreviewV4ZipListCacheEntry{},
}
type readAtBySeeker struct {
rs io.ReadSeeker
mu sync.Mutex
}
func (r *readAtBySeeker) ReadAt(p []byte, off int64) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.rs.Seek(off, io.SeekStart); err != nil {
return 0, err
}
n, err := io.ReadFull(r.rs, p)
if errors.Is(err, io.ErrUnexpectedEOF) {
return n, io.EOF
}
return n, err
}
type ViewResponse struct {
Artifacts []*ArtifactsViewItem `json:"artifacts"`
@ -948,518 +888,6 @@ func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.A
return run, attempt, jobs
}
// resolveArtifactAttemptIDFromQuery resolves the run_attempt_id used to scope artifact lookups.
// If the `attempt` query parameter is present and valid, it returns the matching attempt's ID.
// Otherwise it falls back to run.LatestAttemptID, which is 0 only for legacy runs created before ActionRunAttempt existed.
func resolveArtifactAttemptIDFromQuery(ctx *context_module.Context, run *actions_model.ActionRun) (int64, error) {
if ctx.FormString("attempt") == "" {
return run.LatestAttemptID, nil
}
attemptNum := ctx.FormInt64("attempt")
if attemptNum <= 0 {
return 0, util.ErrNotExist
}
attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
if err != nil {
return 0, err
}
return attempt.ID, nil
}
func getCurrentRunAndUploadedArtifacts(ctx *context_module.Context, artifactName string) (*actions_model.ActionRun, []*actions_model.ActionArtifact, bool) {
run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
return nil, nil, false
}
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: run.ID,
ArtifactName: artifactName,
})
if err != nil {
ctx.ServerError("FindArtifacts", err)
return nil, nil, false
}
if len(artifacts) == 0 {
ctx.HTTPError(http.StatusNotFound, "artifact not found")
return nil, nil, false
}
for _, art := range artifacts {
if art.Status != actions_model.ArtifactStatusUploadConfirmed {
ctx.HTTPError(http.StatusNotFound, "artifact not found")
return nil, nil, false
}
}
run.Repo = ctx.Repo.Repository
return run, artifacts, true
}
func normalizeArtifactPreviewPath(path string) string {
path = util.PathJoinRelX(path)
if path == "." {
return ""
}
return path
}
// GetRequestedPreviewPath reads the requested artifact preview path from a
// request, accepting either the trailing `/preview/raw/*` path segment or a
// `?path=` query parameter, and normalizes it to a safe relative path.
func GetRequestedPreviewPath(ctx *context_module.Context) string {
path := strings.TrimPrefix(ctx.PathParam("*"), "/")
if path == "" {
path = ctx.Req.URL.Query().Get("path")
}
return normalizeArtifactPreviewPath(path)
}
func artifactPreviewFallbackPath(artifact *actions_model.ActionArtifact) string {
path := normalizeArtifactPreviewPath(artifact.ArtifactPath)
if path != "" {
return path
}
return artifact.ArtifactName
}
// ChoosePreviewPath resolves the preview path to render.
// An empty `requested` means no path was specified, so the first file is selected as a default.
// A non-empty `requested` that is not present in `paths` returns "" so callers can 404 instead of silently swapping to a different file.
func ChoosePreviewPath(paths []string, requested string) string {
if len(paths) == 0 {
return ""
}
if requested == "" {
return paths[0]
}
if util.SliceContainsString(paths, requested) {
return requested
}
return ""
}
func listPreviewPathsForLegacyArtifacts(artifacts []*actions_model.ActionArtifact) []string {
paths := make([]string, 0, len(artifacts))
seen := make(map[string]struct{}, len(artifacts))
for _, artifact := range artifacts {
path := artifactPreviewFallbackPath(artifact)
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func openArtifactV4ZipReader(artifact *actions_model.ActionArtifact) (storage.Object, *zip.Reader, error) {
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
if err != nil {
return nil, nil, err
}
stat, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, nil, err
}
reader, err := zip.NewReader(&readAtBySeeker{rs: f}, stat.Size())
if err != nil {
_ = f.Close()
return nil, nil, err
}
return f, reader, nil
}
func listArtifactV4ZipFiles(reader *zip.Reader) ([]string, map[string]*zip.File) {
paths := make([]string, 0, len(reader.File))
files := make(map[string]*zip.File, len(reader.File))
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
path := normalizeArtifactPreviewPath(file.Name)
if path == "" {
continue
}
if _, ok := files[path]; ok {
continue
}
files[path] = file
paths = append(paths, path)
}
sort.Strings(paths)
return paths, files
}
func listPreviewPathsForV4Artifact(artifact *actions_model.ActionArtifact) ([]string, error) {
if paths, ok := getArtifactPreviewV4ZipListFromCache(artifact); ok {
return paths, nil
}
obj, reader, err := openArtifactV4ZipReader(artifact)
if err != nil {
if errors.Is(err, zip.ErrFormat) {
paths := []string{artifactPreviewFallbackPath(artifact)}
setArtifactPreviewV4ZipListCache(artifact, paths)
return paths, nil
}
return nil, err
}
defer obj.Close()
paths, _ := listArtifactV4ZipFiles(reader)
setArtifactPreviewV4ZipListCache(artifact, paths)
return paths, nil
}
func artifactPreviewV4ZipListCacheKey(artifact *actions_model.ActionArtifact) string {
return strconv.FormatInt(artifact.ID, 10) + ":" + strconv.FormatInt(int64(artifact.UpdatedUnix), 10)
}
func removeArtifactPreviewV4ZipListCacheOrderKey(order []string, key string) []string {
for i, current := range order {
if current != key {
continue
}
return append(order[:i], order[i+1:]...)
}
return order
}
func getArtifactPreviewV4ZipListFromCache(artifact *actions_model.ActionArtifact) ([]string, bool) {
key := artifactPreviewV4ZipListCacheKey(artifact)
artifactPreviewV4ZipListCache.mu.Lock()
entry, ok := artifactPreviewV4ZipListCache.entries[key]
if !ok {
artifactPreviewV4ZipListCache.mu.Unlock()
return nil, false
}
if time.Now().After(entry.expiresAt) {
delete(artifactPreviewV4ZipListCache.entries, key)
artifactPreviewV4ZipListCache.order = removeArtifactPreviewV4ZipListCacheOrderKey(artifactPreviewV4ZipListCache.order, key)
artifactPreviewV4ZipListCache.mu.Unlock()
return nil, false
}
paths := append([]string(nil), entry.paths...)
artifactPreviewV4ZipListCache.mu.Unlock()
return paths, true
}
func setArtifactPreviewV4ZipListCache(artifact *actions_model.ActionArtifact, paths []string) {
key := artifactPreviewV4ZipListCacheKey(artifact)
artifactPreviewV4ZipListCache.mu.Lock()
defer artifactPreviewV4ZipListCache.mu.Unlock()
if _, ok := artifactPreviewV4ZipListCache.entries[key]; ok {
artifactPreviewV4ZipListCache.order = removeArtifactPreviewV4ZipListCacheOrderKey(artifactPreviewV4ZipListCache.order, key)
}
artifactPreviewV4ZipListCache.order = append(artifactPreviewV4ZipListCache.order, key)
artifactPreviewV4ZipListCache.entries[key] = artifactPreviewV4ZipListCacheEntry{
paths: append([]string(nil), paths...),
expiresAt: time.Now().Add(artifactPreviewV4ZipListCacheTTL),
}
for len(artifactPreviewV4ZipListCache.entries) > artifactPreviewV4ZipListCacheMaxEntries && len(artifactPreviewV4ZipListCache.order) > 0 {
oldestKey := artifactPreviewV4ZipListCache.order[0]
artifactPreviewV4ZipListCache.order = artifactPreviewV4ZipListCache.order[1:]
if _, ok := artifactPreviewV4ZipListCache.entries[oldestKey]; !ok {
continue
}
delete(artifactPreviewV4ZipListCache.entries, oldestKey)
}
}
func listPreviewPaths(artifacts []*actions_model.ActionArtifact) ([]string, error) {
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
return listPreviewPathsForV4Artifact(artifacts[0])
}
return listPreviewPathsForLegacyArtifacts(artifacts), nil
}
func isPreviewableArtifactType(st typesniffer.SniffedType) bool {
return st.IsText() || st.IsPDF()
}
func previewArtifactByReader(ctx *context_module.Context, path string, reader io.Reader) {
buf := filebuffer.New(int(setting.UI.MaxDisplayFileSize), "")
defer buf.Close()
if _, err := io.Copy(buf, io.LimitReader(reader, setting.UI.MaxDisplayFileSize)); err != nil {
ctx.ServerError("io.Copy", err)
return
}
if _, err := buf.Seek(0, io.SeekStart); err != nil {
ctx.ServerError("Seek", err)
return
}
previewArtifactByReadSeeker(ctx, path, buf)
}
func previewArtifactByReadSeeker(ctx *context_module.Context, path string, reader io.ReadSeeker) {
buf := make([]byte, typesniffer.SniffContentSize)
n, err := util.ReadAtMost(reader, buf)
if err != nil {
ctx.ServerError("ReadAtMost", err)
return
}
buf = buf[:n]
if _, err := reader.Seek(0, io.SeekStart); err != nil {
ctx.ServerError("Seek", err)
return
}
st := typesniffer.DetectContentType(buf)
if !isPreviewableArtifactType(st) {
ctx.HTTPError(http.StatusUnsupportedMediaType, "artifact preview is not supported for this file type")
return
}
// CSP sandbox is applied by httplib.ServeSetHeaders, see HINT: PDF-RENDER-SANDBOX
ctx.ServeContent(reader, context_module.ServeHeaderOptions{
Filename: path,
ContentType: st.GetMimeType(),
})
}
func ArtifactsPreviewView(ctx *context_module.Context) {
artifactName := ctx.PathParam("artifact_name")
run, artifacts, ok := getCurrentRunAndUploadedArtifacts(ctx, artifactName)
if !ok {
return
}
paths, err := listPreviewPaths(artifacts)
if err != nil {
ctx.ServerError("listPreviewPaths", err)
return
}
selectedPath := ChoosePreviewPath(paths, GetRequestedPreviewPath(ctx))
previewFiles := make([]ArtifactPreviewFile, 0, len(paths))
for _, path := range paths {
previewFiles = append(previewFiles, ArtifactPreviewFile{
Path: path,
Selected: path == selectedPath,
})
}
runURL := run.Link()
artifactPath := url.PathEscape(artifactName)
previewURL := runURL + "/artifacts/" + artifactPath + "/preview"
ctx.Data["Title"] = ctx.Tr("preview")
ctx.Data["PageIsActions"] = true
ctx.Data["RunURL"] = runURL
ctx.Data["ArtifactName"] = artifactName
ctx.Data["PreviewURL"] = previewURL
ctx.Data["PreviewRawURL"] = previewURL + "/raw"
ctx.Data["DownloadURL"] = runURL + "/artifacts/" + artifactPath
ctx.Data["SelectedPath"] = selectedPath
ctx.Data["PreviewFiles"] = previewFiles
ctx.HTML(http.StatusOK, tplArtifactPreviewAction)
}
// serveArtifactV4PreviewRaw opens the v4 artifact zip once and serves a single file from it,
// avoiding the redundant parse that listPreviewPaths would do for raw fetches.
func serveArtifactV4PreviewRaw(ctx *context_module.Context, artifact *actions_model.ActionArtifact, requested string) {
obj, reader, err := openArtifactV4ZipReader(artifact)
if err != nil {
if !errors.Is(err, zip.ErrFormat) {
ctx.ServerError("openArtifactV4ZipReader", err)
return
}
fallbackPath := artifactPreviewFallbackPath(artifact)
selectedPath := ChoosePreviewPath([]string{fallbackPath}, requested)
if selectedPath == "" {
ctx.HTTPError(http.StatusNotFound, "artifact file not found")
return
}
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
if err != nil {
ctx.ServerError("ActionsArtifacts.Open", err)
return
}
defer f.Close()
previewArtifactByReadSeeker(ctx, selectedPath, f)
return
}
defer obj.Close()
paths, files := listArtifactV4ZipFiles(reader)
selectedPath := ChoosePreviewPath(paths, requested)
if selectedPath == "" {
ctx.HTTPError(http.StatusNotFound, "artifact file not found")
return
}
zf := files[selectedPath]
r, err := zf.Open()
if err != nil {
ctx.ServerError("zip.File.Open", err)
return
}
defer r.Close()
previewArtifactByReader(ctx, selectedPath, r)
}
func ArtifactsPreviewRawView(ctx *context_module.Context) {
artifactName := ctx.PathParam("artifact_name")
_, artifacts, ok := getCurrentRunAndUploadedArtifacts(ctx, artifactName)
if !ok {
return
}
requested := GetRequestedPreviewPath(ctx)
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
serveArtifactV4PreviewRaw(ctx, artifacts[0], requested)
return
}
paths := listPreviewPathsForLegacyArtifacts(artifacts)
selectedPath := ChoosePreviewPath(paths, requested)
if selectedPath == "" {
ctx.HTTPError(http.StatusNotFound, "artifact file not found")
return
}
legacyByPath := make(map[string]*actions_model.ActionArtifact, len(artifacts))
for _, artifact := range artifacts {
path := artifactPreviewFallbackPath(artifact)
if _, ok := legacyByPath[path]; ok {
continue
}
legacyByPath[path] = artifact
}
artifact, ok := legacyByPath[selectedPath]
if !ok {
ctx.HTTPError(http.StatusNotFound, "artifact file not found")
return
}
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
if err != nil {
ctx.ServerError("ActionsArtifacts.Open", err)
return
}
defer f.Close()
if artifact.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip {
r, err := gzip.NewReader(f)
if err != nil {
ctx.ServerError("gzip.NewReader", err)
return
}
defer r.Close()
previewArtifactByReader(ctx, selectedPath, r)
return
}
previewArtifactByReadSeeker(ctx, selectedPath, f)
}
func ArtifactsDeleteView(ctx *context_module.Context) {
run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
return
}
resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run)
if err != nil {
ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
artifactName := ctx.PathParam("artifact_name")
if err := actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, run.ID, resolvedAttemptID, artifactName); err != nil {
ctx.ServerError("SetArtifactNeedDeleteByRunAttempt", err)
return
}
ctx.JSON(http.StatusOK, struct{}{})
}
func ArtifactsDownloadView(ctx *context_module.Context) {
run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
return
}
resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run)
if err != nil {
ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
artifactName := ctx.PathParam("artifact_name")
artifacts, err := actions_model.GetArtifactsByRunAttemptAndName(ctx, run.ID, resolvedAttemptID, artifactName)
if err != nil {
ctx.ServerError("GetArtifactsByRunAttemptAndName", err)
return
}
if len(artifacts) == 0 {
ctx.HTTPError(http.StatusNotFound, "artifact not found")
return
}
ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip"))
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
err := actions.DownloadArtifactV4(ctx.Base, artifacts[0])
if err != nil {
ctx.ServerError("DownloadArtifactV4", err)
return
}
return
}
// Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend
// Those need to be zipped for download
zipWriter := zip.NewWriter(ctx.Resp)
defer zipWriter.Close()
writeArtifactToZip := func(art *actions_model.ActionArtifact) error {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
return fmt.Errorf("ActionsArtifacts.Open: %w", err)
}
defer f.Close()
var r io.ReadCloser = f
if art.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip {
r, err = gzip.NewReader(f)
if err != nil {
return fmt.Errorf("gzip.NewReader: %w", err)
}
}
defer r.Close()
w, err := zipWriter.Create(art.ArtifactPath)
if err != nil {
return fmt.Errorf("zipWriter.Create: %w", err)
}
_, err = io.Copy(w, r)
if err != nil {
return fmt.Errorf("io.Copy: %w", err)
}
return nil
}
for _, art := range artifacts {
err := writeArtifactToZip(art)
if err != nil {
ctx.ServerError("writeArtifactToZip", err)
return
}
}
}
func ApproveAllChecks(ctx *context_module.Context) {
repo := ctx.Repo.Repository
commitID := ctx.FormString("commit_id")