enhance: add builtin:download-artifact (#1344)

Co-authored-by: bircni <[email protected]>
This commit is contained in:
bircni authored and silverwind committed 2026-11-06 03:32:59 +00:00
1 parent 02446620df
commit c8b16f4792
11 files changed
+912 -4

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifact
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
type REST struct {
base *url.URL
token string
api *http.Client
blob *http.Client
Retry Retry
Logf func(format string, args ...any)
}
// NewREST takes the instance as the runner reaches it, never the job's GITHUB_API_URL, so the token only goes there.
func NewREST(instance, token string, transport http.RoundTripper) (*REST, error) {
parsed, err := url.Parse(instance)
if err != nil {
return nil, err
}
return &REST{
base: parsed.JoinPath("api", "v1"),
token: token,
api: &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse // the redirect target is signed and must not get the token
}},
blob: &http.Client{Transport: blobTransport{instance: parsed.Host, transport: transport}},
Retry: DefaultRetry,
Logf: func(string, ...any) {},
}, nil
}
func (r *REST) get(ctx context.Context, what string, endpoint *url.URL) (*http.Response, error) {
return r.Retry.do(ctx, r.Logf, what, func() (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "token "+r.token)
req.Header.Set("Accept", "application/json")
return r.api.Do(req)
})
}
func (r *REST) List(ctx context.Context, owner, repo string, runID int64, name string) ([]Artifact, error) {
if name == "" {
r.Logf("Fetching artifact list for workflow run %d in repository %s/%s", runID, owner, repo)
}
var list []Artifact
seen := 0
for page := 1; ; page++ {
endpoint := r.base.JoinPath("repos", owner, repo, "actions", "runs", strconv.FormatInt(runID, 10), "artifacts")
query := url.Values{"page": {strconv.Itoa(page)}, "limit": {"50"}}
if name != "" {
query.Set("name", name)
}
endpoint.RawQuery = query.Encode()
resp, err := r.get(ctx, "list artifacts", endpoint)
if err != nil {
return nil, err
}
var body struct {
TotalCount int `json:"total_count"`
Artifacts []struct {
ID int64 `json:"id"`
Name string `json:"name"`
SizeInBytes int64 `json:"size_in_bytes"`
Expired bool `json:"expired"`
Digest string `json:"digest"`
} `json:"artifacts"`
}
err = json.UnmarshalRead(resp.Body, &body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("list artifacts: %w", err)
}
for _, listed := range body.Artifacts {
if !listed.Expired {
list = append(list, Artifact{ID: listed.ID, Name: listed.Name, Size: listed.SizeInBytes, Digest: listed.Digest})
}
}
if seen += len(body.Artifacts); len(body.Artifacts) == 0 || seen >= body.TotalCount {
return list, nil
}
}
}
func (r *REST) DownloadURL(ctx context.Context, owner, repo string, id int64) (string, error) {
resp, err := r.get(ctx, "download artifact", r.base.JoinPath("repos", owner, repo, "actions", "artifacts", strconv.FormatInt(id, 10), "zip"))
if err != nil {
return "", fmt.Errorf("unable to download artifact: %w", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
return "", fmt.Errorf("unable to download artifact. Unexpected status: %d", resp.StatusCode)
}
location, err := resp.Location()
if err != nil {
return "", errors.New("unable to redirect to artifact download url")
}
return location.String(), nil
}
func (r *REST) Download(ctx context.Context, signedURL string, stall time.Duration) (*http.Response, error) {
return (&Client{blob: r.blob}).Download(ctx, signedURL, stall)
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifact
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRESTListsEveryPageAndSkipsExpired(t *testing.T) {
var pages []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/own%20er/repo/actions/runs/12/artifacts", r.URL.EscapedPath())
assert.Equal(t, "token secret", r.Header.Get("Authorization"))
assert.Empty(t, r.URL.Query().Get("per_page"))
pages = append(pages, r.URL.Query().Get("page")+"/"+r.URL.Query().Get("limit"))
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
const maxResponseItems = 20
var entries []string
for id := (page-1)*maxResponseItems + 1; id <= min(page*maxResponseItems, 53); id++ {
entries = append(entries, fmt.Sprintf(`{"id":%d,"name":"a%d","size_in_bytes":%d,"expired":%t}`, id, id, id, id == 2))
}
_, _ = io.WriteString(w, `{"total_count":53,"artifacts":[`+strings.Join(entries, ",")+`]}`)
}))
defer server.Close()
rest, err := NewREST(server.URL, "secret", nil)
require.NoError(t, err)
list, err := rest.List(t.Context(), "own er", "repo", 12, "")
require.NoError(t, err)
assert.Equal(t, []string{"1/50", "2/50", "3/50"}, pages)
assert.Len(t, list, 52)
assert.Equal(t, Artifact{ID: 3, Name: "a3", Size: 3}, list[1])
}