0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-11 04:55:34 +02:00
This commit is contained in:
Nicolas 2026-04-27 19:26:11 +02:00
parent 00efb1af42
commit 9cd410fe85
5 changed files with 297 additions and 16 deletions

View File

@ -17,6 +17,7 @@ import (
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
@ -36,6 +37,106 @@ var mockActionsArtifactFiles = map[string][]mockArtifactFile{
Content: "artifact-b report",
},
},
"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/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/summary.txt",
Content: "HTML coverage fixture with linked lcov.info and realistic function/line/branch records.",
},
},
"artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong": {
{
Path: "index.html",
@ -160,6 +261,11 @@ func MockActionsRunsJobs(ctx *context.Context) {
Size: 1024 * 1024,
Status: "completed",
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-lcov-coverage",
Size: 256 * 1024,
Status: "completed",
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
Size: 100 * 1024,
@ -291,7 +397,7 @@ func MockActionsArtifactDownload(ctx *context.Context) {
return
}
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip"))
writer := zip.NewWriter(ctx.Resp)
defer writer.Close()
for _, file := range files {

View File

@ -135,6 +135,24 @@ type ArtifactPreviewFile struct {
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
@ -841,19 +859,84 @@ func listArtifactV4ZipFiles(reader *zip.Reader) ([]string, map[string]*zip.File)
}
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) {
return []string{artifactPreviewFallbackPath(artifact)}, nil
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) + ":" + artifact.StoragePath
}
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 = 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])
@ -1081,7 +1164,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
return
}
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
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 {
@ -1093,7 +1176,6 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
// Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend
// Those need to be zipped for download
ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip"))
zipWriter := zip.NewWriter(ctx.Resp)
defer zipWriter.Close()

View File

@ -5,6 +5,7 @@ package actions
import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/timeutil"
@ -45,3 +46,90 @@ func TestConvertToViewModel(t *testing.T) {
}
assert.Equal(t, expectedViewJobs, viewJobSteps)
}
func resetArtifactPreviewV4ZipListCacheForTest() {
artifactPreviewV4ZipListCache.mu.Lock()
defer artifactPreviewV4ZipListCache.mu.Unlock()
artifactPreviewV4ZipListCache.entries = map[string]artifactPreviewV4ZipListCacheEntry{}
artifactPreviewV4ZipListCache.order = nil
}
func TestArtifactPreviewV4ZipListCacheSetGet(t *testing.T) {
resetArtifactPreviewV4ZipListCacheForTest()
artifact := &actions_model.ActionArtifact{
ID: 1,
UpdatedUnix: timeutil.TimeStamp(2),
StoragePath: "artifact/path.zip",
}
paths := []string{"index.html", "logs/output.txt"}
setArtifactPreviewV4ZipListCache(artifact, paths)
paths[0] = "changed"
got, ok := getArtifactPreviewV4ZipListFromCache(artifact)
require.True(t, ok)
assert.Equal(t, []string{"index.html", "logs/output.txt"}, got)
got[0] = "changed-again"
got2, ok := getArtifactPreviewV4ZipListFromCache(artifact)
require.True(t, ok)
assert.Equal(t, []string{"index.html", "logs/output.txt"}, got2)
}
func TestArtifactPreviewV4ZipListCacheExpires(t *testing.T) {
resetArtifactPreviewV4ZipListCacheForTest()
artifact := &actions_model.ActionArtifact{
ID: 2,
UpdatedUnix: timeutil.TimeStamp(3),
StoragePath: "artifact/expired.zip",
}
key := artifactPreviewV4ZipListCacheKey(artifact)
artifactPreviewV4ZipListCache.mu.Lock()
artifactPreviewV4ZipListCache.entries[key] = artifactPreviewV4ZipListCacheEntry{
paths: []string{"expired.txt"},
expiresAt: time.Now().Add(-time.Second),
}
artifactPreviewV4ZipListCache.order = []string{key}
artifactPreviewV4ZipListCache.mu.Unlock()
_, ok := getArtifactPreviewV4ZipListFromCache(artifact)
require.False(t, ok)
artifactPreviewV4ZipListCache.mu.Lock()
_, exists := artifactPreviewV4ZipListCache.entries[key]
order := append([]string(nil), artifactPreviewV4ZipListCache.order...)
artifactPreviewV4ZipListCache.mu.Unlock()
assert.False(t, exists)
assert.NotContains(t, order, key)
}
func TestArtifactPreviewV4ZipListCacheEvictsOldest(t *testing.T) {
resetArtifactPreviewV4ZipListCacheForTest()
for i := range artifactPreviewV4ZipListCacheMaxEntries + 1 {
artifact := &actions_model.ActionArtifact{
ID: int64(i + 1),
UpdatedUnix: timeutil.TimeStamp(i + 1),
StoragePath: "artifact/cache-entry.zip",
}
setArtifactPreviewV4ZipListCache(artifact, []string{"file.txt"})
}
oldest := &actions_model.ActionArtifact{
ID: 1,
UpdatedUnix: timeutil.TimeStamp(1),
StoragePath: "artifact/cache-entry.zip",
}
_, ok := getArtifactPreviewV4ZipListFromCache(oldest)
assert.False(t, ok)
newest := &actions_model.ActionArtifact{
ID: artifactPreviewV4ZipListCacheMaxEntries + 1,
UpdatedUnix: timeutil.TimeStamp(artifactPreviewV4ZipListCacheMaxEntries + 1),
StoragePath: "artifact/cache-entry.zip",
}
_, ok = getArtifactPreviewV4ZipListFromCache(newest)
assert.True(t, ok)
}

View File

@ -7,10 +7,6 @@
min-width: 0;
}
.artifact-preview-subtitle {
color: var(--color-text-light-3);
}
.artifact-preview-frame {
width: 100%;
min-height: 70vh;
@ -22,14 +18,17 @@
<div class="ui top attached header tw-flex tw-items-center tw-justify-between">
<div class="artifact-preview-title">
<span class="tw-text-base tw-font-semibold">{{ctx.Locale.Tr "preview"}}: <span class="gt-ellipsis">{{.ArtifactName}}</span></span>
<div class="artifact-preview-subtitle tw-mt-1">
<a href="{{.RunURL}}">{{ctx.Locale.Tr "go_back"}}</a>
</div>
</div>
<a class="ui button" href="{{.DownloadURL}}">
{{svg "octicon-download"}}
{{ctx.Locale.Tr "repo.download_file"}}
</a>
<div class="tw-flex tw-items-center tw-gap-2">
<a class="ui basic button" href="{{.RunURL}}">
{{svg "octicon-arrow-left"}}
{{ctx.Locale.Tr "go_back"}}
</a>
<a class="ui button" href="{{.DownloadURL}}">
{{svg "octicon-download"}}
{{ctx.Locale.Tr "repo.download_file"}}
</a>
</div>
</div>
<div class="ui attached segment">

View File

@ -36,6 +36,8 @@ func TestActionsArtifactPreviewSingleFile(t *testing.T) {
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "abc.txt")
assert.Contains(t, resp.Body.String(), "/preview/raw/abc.txt")
assert.Contains(t, resp.Body.String(), "sandbox=\"allow-scripts\"")
assert.Contains(t, resp.Body.String(), "referrerpolicy=\"no-referrer\"")
req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName())
resp = session.MakeRequest(t, req, http.StatusOK)
@ -54,7 +56,11 @@ func TestActionsArtifactPreviewMultiFile(t *testing.T) {
assert.Contains(t, resp.Body.String(), "abc.txt")
assert.Contains(t, resp.Body.String(), "xyz/def.txt")
req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview/raw?path=%s", repo.FullName(), url.QueryEscape("xyz/def.txt"))
req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview?path=%s", repo.FullName(), url.QueryEscape("xyz/def.txt"))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "/preview/raw/xyz/def.txt")
req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview/raw/xyz/def.txt", repo.FullName())
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, strings.Repeat("C", 1024), resp.Body.String())
}