forked from mirrors/forgejo
feat(api): add REST API endpoints for Actions artifacts (#12140)
## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Tests for JavaScript changes (not applicable — Go-only change) ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [ ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. ## Summary Add public REST API endpoints under `/api/v1/` for listing, inspecting, downloading, and deleting Actions artifacts. Previously, artifacts could only be accessed through the web UI or the internal runner API. ### New endpoints | Method | Path | Description | |--------|------|-------------| | `GET` | `/repos/{owner}/{repo}/actions/artifacts` | List all artifacts for a repository | | `GET` | `/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | List artifacts for a workflow run | | `GET` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Get artifact metadata | | `GET` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip` | Download artifact as zip | | `DELETE` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Delete an artifact | - List endpoints support `page`, `limit`, and `name` query parameters - Both v1-v3 (multi-file, zip on-the-fly) and v4 (single zip) artifact backends are supported - Expired artifacts are listed with `expired: true` but cannot be downloaded - Delete requires write permission; all other endpoints require read permission Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12140 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org> Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org> Co-authored-by: ShellWen <me@shellwen.com> Co-committed-by: ShellWen <me@shellwen.com>
This commit is contained in:
parent
f9b3630911
commit
a85c527709
10 changed files with 1210 additions and 63 deletions
|
|
@ -9,6 +9,7 @@ package actions
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
|
|
@ -88,6 +89,13 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
|
|||
return artifact, nil
|
||||
}
|
||||
|
||||
// IsV4 reports whether the artifact was uploaded via the v4 backend.
|
||||
// The v4 backend stores the whole artifact as a single zip file;
|
||||
// v1-v3 stores each file as a separate row.
|
||||
func (a *ActionArtifact) IsV4() bool {
|
||||
return a.ArtifactName+".zip" == a.ArtifactPath && a.ContentEncoding == "application/zip"
|
||||
}
|
||||
|
||||
func getArtifactByNameAndPath(ctx context.Context, runID int64, name, fpath string) (*ActionArtifact, error) {
|
||||
var art ActionArtifact
|
||||
has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ?", runID, name, fpath).Get(&art)
|
||||
|
|
@ -150,11 +158,32 @@ type ActionArtifactMeta struct {
|
|||
Status ArtifactStatus
|
||||
}
|
||||
|
||||
// AggregatedArtifact is the aggregated view of a logical artifact
|
||||
// (one or more rows sharing the same run_id + artifact_name), used by the
|
||||
// public API to represent a single artifact to clients.
|
||||
type AggregatedArtifact struct {
|
||||
ID int64 `xorm:"id"`
|
||||
RunID int64 `xorm:"run_id"`
|
||||
RepoID int64 `xorm:"-"`
|
||||
ArtifactName string `xorm:"artifact_name"`
|
||||
FileSize int64 `xorm:"file_size"`
|
||||
Status ArtifactStatus `xorm:"status"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created_unix"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated_unix"`
|
||||
ExpiredUnix timeutil.TimeStamp `xorm:"expired_unix"`
|
||||
}
|
||||
|
||||
// APIDownloadURL returns the download URL for this artifact under the given
|
||||
// repository API URL prefix (e.g. "https://host/api/v1/repos/owner/name").
|
||||
func (a *AggregatedArtifact) APIDownloadURL(repoAPIURL string) string {
|
||||
return fmt.Sprintf("%s/actions/artifacts/%d/zip", repoAPIURL, a.ID)
|
||||
}
|
||||
|
||||
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
|
||||
func ListUploadedArtifactsMeta(ctx context.Context, runID int64) ([]*ActionArtifactMeta, error) {
|
||||
arts := make([]*ActionArtifactMeta, 0, 10)
|
||||
return arts, db.GetEngine(ctx).Table("action_artifact").
|
||||
Where("run_id=? AND (status=? OR status=?)", runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
||||
Where(builder.Eq{"run_id": runID}.And(builder.In("status", ArtifactStatusUploadConfirmed, ArtifactStatusExpired))).
|
||||
GroupBy("artifact_name").
|
||||
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
|
||||
Find(&arts)
|
||||
|
|
@ -192,3 +221,85 @@ func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
|
|||
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusDeleted)})
|
||||
return err
|
||||
}
|
||||
|
||||
// aggregatedArtifactConds returns the common WHERE clause used by aggregated
|
||||
// artifact queries: restrict to visible statuses and apply the caller's filters.
|
||||
// The Status field on opts is ignored — visibility is fixed to UploadConfirmed/Expired.
|
||||
func aggregatedArtifactConds(opts FindArtifactsOptions) builder.Cond {
|
||||
opts.Status = 0
|
||||
return opts.ToConds().And(builder.In("status", ArtifactStatusUploadConfirmed, ArtifactStatusExpired))
|
||||
}
|
||||
|
||||
const aggregatedArtifactSelect = "min(id) as id, run_id, artifact_name, sum(file_size) as file_size, max(status) as status, min(created_unix) as created_unix, max(updated_unix) as updated_unix, max(expired_unix) as expired_unix"
|
||||
|
||||
// ListAggregatedArtifacts returns paginated aggregated artifacts.
|
||||
// Each result represents one logical artifact: a (run_id, artifact_name) group,
|
||||
// with ID = MIN(id), FileSize = SUM(file_size), Status = MAX(status), and
|
||||
// timestamps aggregated accordingly. Status filter in opts is ignored; results
|
||||
// are always restricted to UploadConfirmed and Expired statuses.
|
||||
func ListAggregatedArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*AggregatedArtifact, int64, error) {
|
||||
cond := aggregatedArtifactConds(opts)
|
||||
|
||||
var countKeys []struct {
|
||||
ID int64 `xorm:"id"`
|
||||
}
|
||||
if err := db.GetEngine(ctx).Table("action_artifact").
|
||||
Where(cond).
|
||||
GroupBy("run_id, artifact_name").
|
||||
Select("min(id) as id").
|
||||
Find(&countKeys); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := int64(len(countKeys))
|
||||
|
||||
sess := db.GetEngine(ctx).Table("action_artifact").
|
||||
Where(cond).
|
||||
GroupBy("run_id, artifact_name").
|
||||
Select(aggregatedArtifactSelect).
|
||||
OrderBy("id DESC")
|
||||
|
||||
capacity := 10
|
||||
if opts.PageSize > 0 {
|
||||
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
||||
capacity = opts.PageSize
|
||||
}
|
||||
|
||||
arts := make([]*AggregatedArtifact, 0, capacity)
|
||||
return arts, total, sess.Find(&arts)
|
||||
}
|
||||
|
||||
// GetAggregatedArtifactByID returns the aggregated artifact by its canonical ID
|
||||
// (MIN(id) of the group), scoped to the given repository. Returns util.ErrNotExist
|
||||
// when the ID does not exist, is not canonical for its group, or does not belong to repoID.
|
||||
// The repoID scoping is performed in the query so callers don't need a follow-up check.
|
||||
func GetAggregatedArtifactByID(ctx context.Context, repoID, artifactID int64) (*AggregatedArtifact, error) {
|
||||
var art ActionArtifact
|
||||
has, err := db.GetEngine(ctx).Where(builder.Eq{"id": artifactID, "repo_id": repoID}).Get(&art)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, util.ErrNotExist
|
||||
}
|
||||
|
||||
cond := aggregatedArtifactConds(FindArtifactsOptions{
|
||||
RunID: art.RunID,
|
||||
ArtifactName: art.ArtifactName,
|
||||
})
|
||||
|
||||
meta := new(AggregatedArtifact)
|
||||
has, err = db.GetEngine(ctx).Table("action_artifact").
|
||||
Where(cond).
|
||||
GroupBy("run_id, artifact_name").
|
||||
Select(aggregatedArtifactSelect).
|
||||
Get(meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has || meta.ID != artifactID {
|
||||
return nil, util.ErrNotExist
|
||||
}
|
||||
|
||||
meta.RepoID = art.RepoID
|
||||
return meta, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue