Replay pipeline using cli exec by downloading metadata (#4103)

Co-authored-by: Anbraten <6918444+anbraten@users.noreply.github.com>
This commit is contained in:
6543 2024-09-25 07:20:51 +02:00 committed by GitHub
parent 1a6c8dfec6
commit fcc57dfc38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 927 additions and 194 deletions

View file

@ -37,6 +37,7 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/kubernetes"
"go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/local"
backend_types "go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/types"
"go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/metadata"
"go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml"
"go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/compiler"
"go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/linter"
@ -76,6 +77,7 @@ func execDir(ctx context.Context, c *cli.Command, dir string) error {
if runtime.GOOS == "windows" {
repoPath = convertPathForWindows(repoPath)
}
// TODO: respect depends_on and do parallel runs with output to multiple _windows_ e.g. tmux like
return filepath.Walk(dir, func(path string, info os.FileInfo, e error) error {
if e != nil {
return e
@ -84,7 +86,7 @@ func execDir(ctx context.Context, c *cli.Command, dir string) error {
// check if it is a regular file (not dir)
if info.Mode().IsRegular() && (strings.HasSuffix(info.Name(), ".yaml") || strings.HasSuffix(info.Name(), ".yml")) {
fmt.Println("#", info.Name())
_ = runExec(ctx, c, path, repoPath) // TODO: should we drop errors or store them and report back?
_ = runExec(ctx, c, path, repoPath, false) // TODO: should we drop errors or store them and report back?
fmt.Println("")
return nil
}
@ -103,10 +105,10 @@ func execFile(ctx context.Context, c *cli.Command, file string) error {
if runtime.GOOS == "windows" {
repoPath = convertPathForWindows(repoPath)
}
return runExec(ctx, c, file, repoPath)
return runExec(ctx, c, file, repoPath, true)
}
func runExec(ctx context.Context, c *cli.Command, file, repoPath string) error {
func runExec(ctx context.Context, c *cli.Command, file, repoPath string, singleExec bool) error {
dat, err := os.ReadFile(file)
if err != nil {
return err
@ -121,7 +123,7 @@ func runExec(ctx context.Context, c *cli.Command, file, repoPath string) error {
axes = append(axes, matrix.Axis{})
}
for _, axis := range axes {
err := execWithAxis(ctx, c, file, repoPath, axis)
err := execWithAxis(ctx, c, file, repoPath, axis, singleExec)
if err != nil {
return err
}
@ -129,11 +131,20 @@ func runExec(ctx context.Context, c *cli.Command, file, repoPath string) error {
return nil
}
func execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, axis matrix.Axis) error {
metadata, err := metadataFromContext(ctx, c, axis)
func execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, axis matrix.Axis, singleExec bool) error {
var metadataWorkflow *metadata.Workflow
if !singleExec {
// TODO: proper try to use the engine to generate the same metadata for workflows
// https://github.com/woodpecker-ci/woodpecker/pull/3967
metadataWorkflow.Name = strings.TrimSuffix(strings.TrimSuffix(file, ".yaml"), ".yml")
}
metadata, err := metadataFromContext(ctx, c, axis, metadataWorkflow)
if err != nil {
return fmt.Errorf("could not create metadata: %w", err)
} else if metadata == nil {
return fmt.Errorf("metadata is nil")
}
environ := metadata.Environ()
var secrets []compiler.Secret
for key, val := range metadata.Workflow.Matrix {
@ -239,7 +250,7 @@ func execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, ax
c.String("netrc-password"),
c.String("netrc-machine"),
),
compiler.WithMetadata(metadata),
compiler.WithMetadata(*metadata),
compiler.WithSecret(secrets...),
compiler.WithEnviron(pipelineEnv),
).Compile(conf)

View file

@ -32,6 +32,11 @@ var flags = []cli.Flag{
Name: "repo-path",
Usage: "path to local repository",
},
&cli.StringFlag{
Sources: cli.EnvVars("WOODPECKER_METADATA_FILE"),
Name: "metadata-file",
Usage: "path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags",
},
&cli.DurationFlag{
Sources: cli.EnvVars("WOODPECKER_TIMEOUT"),
Name: "timeout",

View file

@ -18,6 +18,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"runtime"
"strings"
@ -29,108 +30,131 @@ import (
)
// return the metadata from the cli context.
func metadataFromContext(_ context.Context, c *cli.Command, axis matrix.Axis) (metadata.Metadata, error) {
func metadataFromContext(_ context.Context, c *cli.Command, axis matrix.Axis, w *metadata.Workflow) (*metadata.Metadata, error) {
m := &metadata.Metadata{}
if c.IsSet("metadata-file") {
metadataFile, err := os.Open(c.String("metadata-file"))
if err != nil {
return nil, err
}
defer metadataFile.Close()
if err := json.NewDecoder(metadataFile).Decode(m); err != nil {
return nil, err
}
}
platform := c.String("system-platform")
if platform == "" {
platform = runtime.GOOS + "/" + runtime.GOARCH
}
fullRepoName := c.String("repo-name")
repoOwner := ""
repoName := ""
if idx := strings.LastIndex(fullRepoName, "/"); idx != -1 {
repoOwner = fullRepoName[:idx]
repoName = fullRepoName[idx+1:]
metadataFileAndOverrideOrDefault(c, "repo-name", func(fullRepoName string) {
if idx := strings.LastIndex(fullRepoName, "/"); idx != -1 {
m.Repo.Owner = fullRepoName[:idx]
m.Repo.Name = fullRepoName[idx+1:]
}
}, c.String)
var err error
metadataFileAndOverrideOrDefault(c, "pipeline-changed-files", func(changedFilesRaw string) {
var changedFiles []string
if len(changedFilesRaw) != 0 && changedFilesRaw[0] == '[' {
if jsonErr := json.Unmarshal([]byte(changedFilesRaw), &changedFiles); jsonErr != nil {
err = fmt.Errorf("pipeline-changed-files detected json but could not parse it: %w", jsonErr)
}
} else {
for _, file := range strings.Split(changedFilesRaw, ",") {
changedFiles = append(changedFiles, strings.TrimSpace(file))
}
}
m.Curr.Commit.ChangedFiles = changedFiles
}, c.String)
if err != nil {
return nil, err
}
var changedFiles []string
changedFilesRaw := c.String("pipeline-changed-files")
if len(changedFilesRaw) != 0 && changedFilesRaw[0] == '[' {
if err := json.Unmarshal([]byte(changedFilesRaw), &changedFiles); err != nil {
return metadata.Metadata{}, fmt.Errorf("pipeline-changed-files detected json but could not parse it: %w", err)
}
} else {
for _, file := range strings.Split(changedFilesRaw, ",") {
changedFiles = append(changedFiles, strings.TrimSpace(file))
}
// Repo
metadataFileAndOverrideOrDefault(c, "repo-remote-id", func(s string) { m.Repo.RemoteID = s }, c.String)
metadataFileAndOverrideOrDefault(c, "repo-url", func(s string) { m.Repo.ForgeURL = s }, c.String)
metadataFileAndOverrideOrDefault(c, "repo-scm", func(s string) { m.Repo.SCM = s }, c.String)
metadataFileAndOverrideOrDefault(c, "repo-default-branch", func(s string) { m.Repo.Branch = s }, c.String)
metadataFileAndOverrideOrDefault(c, "repo-clone-url", func(s string) { m.Repo.CloneURL = s }, c.String)
metadataFileAndOverrideOrDefault(c, "repo-clone-ssh-url", func(s string) { m.Repo.CloneSSHURL = s }, c.String)
metadataFileAndOverrideOrDefault(c, "repo-private", func(b bool) { m.Repo.Private = b }, c.Bool)
metadataFileAndOverrideOrDefault(c, "repo-trusted", func(b bool) { m.Repo.Trusted = b }, c.Bool)
// Current Pipeline
metadataFileAndOverrideOrDefault(c, "pipeline-number", func(i int64) { m.Curr.Number = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "pipeline-parent", func(i int64) { m.Curr.Parent = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "pipeline-created", func(i int64) { m.Curr.Created = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "pipeline-started", func(i int64) { m.Curr.Started = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "pipeline-finished", func(i int64) { m.Curr.Finished = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "pipeline-status", func(s string) { m.Curr.Status = s }, c.String)
metadataFileAndOverrideOrDefault(c, "pipeline-event", func(s string) { m.Curr.Event = s }, c.String)
metadataFileAndOverrideOrDefault(c, "pipeline-url", func(s string) { m.Curr.ForgeURL = s }, c.String)
metadataFileAndOverrideOrDefault(c, "pipeline-deploy-to", func(s string) { m.Curr.DeployTo = s }, c.String)
metadataFileAndOverrideOrDefault(c, "pipeline-deploy-task", func(s string) { m.Curr.DeployTask = s }, c.String)
// Current Pipeline Commit
metadataFileAndOverrideOrDefault(c, "commit-sha", func(s string) { m.Curr.Commit.Sha = s }, c.String)
metadataFileAndOverrideOrDefault(c, "commit-ref", func(s string) { m.Curr.Commit.Ref = s }, c.String)
metadataFileAndOverrideOrDefault(c, "commit-refspec", func(s string) { m.Curr.Commit.Refspec = s }, c.String)
metadataFileAndOverrideOrDefault(c, "commit-branch", func(s string) { m.Curr.Commit.Branch = s }, c.String)
metadataFileAndOverrideOrDefault(c, "commit-message", func(s string) { m.Curr.Commit.Message = s }, c.String)
metadataFileAndOverrideOrDefault(c, "commit-author-name", func(s string) { m.Curr.Commit.Author.Name = s }, c.String)
metadataFileAndOverrideOrDefault(c, "commit-author-email", func(s string) { m.Curr.Commit.Author.Email = s }, c.String)
metadataFileAndOverrideOrDefault(c, "commit-author-avatar", func(s string) { m.Curr.Commit.Author.Avatar = s }, c.String)
metadataFileAndOverrideOrDefault(c, "commit-pull-labels", func(sl []string) { m.Curr.Commit.PullRequestLabels = sl }, c.StringSlice)
metadataFileAndOverrideOrDefault(c, "commit-release-is-pre", func(b bool) { m.Curr.Commit.IsPrerelease = b }, c.Bool)
// Previous Pipeline
metadataFileAndOverrideOrDefault(c, "prev-pipeline-number", func(i int64) { m.Prev.Number = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "prev-pipeline-created", func(i int64) { m.Prev.Created = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "prev-pipeline-started", func(i int64) { m.Prev.Started = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "prev-pipeline-finished", func(i int64) { m.Prev.Finished = i }, c.Int)
metadataFileAndOverrideOrDefault(c, "prev-pipeline-status", func(s string) { m.Prev.Status = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-pipeline-event", func(s string) { m.Prev.Event = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-pipeline-url", func(s string) { m.Prev.ForgeURL = s }, c.String)
// Previous Pipeline Commit
metadataFileAndOverrideOrDefault(c, "prev-commit-sha", func(s string) { m.Prev.Commit.Sha = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-commit-ref", func(s string) { m.Prev.Commit.Ref = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-commit-refspec", func(s string) { m.Prev.Commit.Refspec = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-commit-branch", func(s string) { m.Prev.Commit.Branch = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-commit-message", func(s string) { m.Prev.Commit.Message = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-commit-author-name", func(s string) { m.Prev.Commit.Author.Name = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-commit-author-email", func(s string) { m.Prev.Commit.Author.Email = s }, c.String)
metadataFileAndOverrideOrDefault(c, "prev-commit-author-avatar", func(s string) { m.Prev.Commit.Author.Avatar = s }, c.String)
// Workflow
metadataFileAndOverrideOrDefault(c, "workflow-name", func(s string) { m.Workflow.Name = s }, c.String)
metadataFileAndOverrideOrDefault(c, "workflow-number", func(i int64) { m.Workflow.Number = int(i) }, c.Int)
m.Workflow.Matrix = axis
// System
metadataFileAndOverrideOrDefault(c, "system-name", func(s string) { m.Sys.Name = s }, c.String)
metadataFileAndOverrideOrDefault(c, "system-url", func(s string) { m.Sys.URL = s }, c.String)
metadataFileAndOverrideOrDefault(c, "system-host", func(s string) { m.Sys.Host = s }, c.String)
m.Sys.Platform = platform
m.Sys.Version = version.Version
// Forge
metadataFileAndOverrideOrDefault(c, "forge-type", func(s string) { m.Forge.Type = s }, c.String)
metadataFileAndOverrideOrDefault(c, "forge-url", func(s string) { m.Forge.URL = s }, c.String)
if w != nil {
m.Workflow = *w
}
return metadata.Metadata{
Repo: metadata.Repo{
Name: repoName,
Owner: repoOwner,
RemoteID: c.String("repo-remote-id"),
ForgeURL: c.String("repo-url"),
SCM: c.String("repo-scm"),
Branch: c.String("repo-default-branch"),
CloneURL: c.String("repo-clone-url"),
CloneSSHURL: c.String("repo-clone-ssh-url"),
Private: c.Bool("repo-private"),
Trusted: c.Bool("repo-trusted"),
},
Curr: metadata.Pipeline{
Number: c.Int("pipeline-number"),
Parent: c.Int("pipeline-parent"),
Created: c.Int("pipeline-created"),
Started: c.Int("pipeline-started"),
Finished: c.Int("pipeline-finished"),
Status: c.String("pipeline-status"),
Event: c.String("pipeline-event"),
ForgeURL: c.String("pipeline-url"),
DeployTo: c.String("pipeline-deploy-to"),
DeployTask: c.String("pipeline-deploy-task"),
Commit: metadata.Commit{
Sha: c.String("commit-sha"),
Ref: c.String("commit-ref"),
Refspec: c.String("commit-refspec"),
Branch: c.String("commit-branch"),
Message: c.String("commit-message"),
Author: metadata.Author{
Name: c.String("commit-author-name"),
Email: c.String("commit-author-email"),
Avatar: c.String("commit-author-avatar"),
},
PullRequestLabels: c.StringSlice("commit-pull-labels"),
IsPrerelease: c.Bool("commit-release-is-pre"),
ChangedFiles: changedFiles,
},
},
Prev: metadata.Pipeline{
Number: c.Int("prev-pipeline-number"),
Created: c.Int("prev-pipeline-created"),
Started: c.Int("prev-pipeline-started"),
Finished: c.Int("prev-pipeline-finished"),
Status: c.String("prev-pipeline-status"),
Event: c.String("prev-pipeline-event"),
ForgeURL: c.String("prev-pipeline-url"),
Commit: metadata.Commit{
Sha: c.String("prev-commit-sha"),
Ref: c.String("prev-commit-ref"),
Refspec: c.String("prev-commit-refspec"),
Branch: c.String("prev-commit-branch"),
Message: c.String("prev-commit-message"),
Author: metadata.Author{
Name: c.String("prev-commit-author-name"),
Email: c.String("prev-commit-author-email"),
Avatar: c.String("prev-commit-author-avatar"),
},
},
},
Workflow: metadata.Workflow{
Name: c.String("workflow-name"),
Number: int(c.Int("workflow-number")),
Matrix: axis,
},
Sys: metadata.System{
Name: c.String("system-name"),
URL: c.String("system-url"),
Host: c.String("system-host"),
Platform: platform,
Version: version.Version,
},
Forge: metadata.Forge{
Type: c.String("forge-type"),
URL: c.String("forge-url"),
},
}, nil
return m, nil
}
// metadataFileAndOverrideOrDefault will either use the flag default or if metadata file is set only overload if explicit set.
func metadataFileAndOverrideOrDefault[T any](c *cli.Command, flag string, setter func(T), getter func(string) T) {
if !c.IsSet("metadata-file") || c.IsSet(flag) {
setter(getter(flag))
}
}

142
cli/exec/metadata_test.go Normal file
View file

@ -0,0 +1,142 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package exec
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
"go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/metadata"
"go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/matrix"
)
func TestMetadataFromContext(t *testing.T) {
sampleMetadata := &metadata.Metadata{
Repo: metadata.Repo{Owner: "test-user", Name: "test-repo"},
Curr: metadata.Pipeline{Number: 5},
}
runCommand := func(flags []cli.Flag, fn func(c *cli.Command)) {
c := &cli.Command{
Flags: flags,
Action: func(_ context.Context, c *cli.Command) error {
fn(c)
return nil
},
}
assert.NoError(t, c.Run(context.Background(), []string{"woodpecker-cli"}))
}
t.Run("LoadFromFile", func(t *testing.T) {
tempFileName := createTempFile(t, sampleMetadata)
flags := []cli.Flag{
&cli.StringFlag{Name: "metadata-file"},
}
runCommand(flags, func(c *cli.Command) {
_ = c.Set("metadata-file", tempFileName)
m, err := metadataFromContext(context.Background(), c, nil, nil)
require.NoError(t, err)
assert.Equal(t, "test-repo", m.Repo.Name)
assert.Equal(t, int64(5), m.Curr.Number)
})
})
t.Run("OverrideFromFlags", func(t *testing.T) {
tempFileName := createTempFile(t, sampleMetadata)
flags := []cli.Flag{
&cli.StringFlag{Name: "metadata-file"},
&cli.StringFlag{Name: "repo-name"},
&cli.IntFlag{Name: "pipeline-number"},
}
runCommand(flags, func(c *cli.Command) {
_ = c.Set("metadata-file", tempFileName)
_ = c.Set("repo-name", "aUser/override-repo")
_ = c.Set("pipeline-number", "10")
m, err := metadataFromContext(context.Background(), c, nil, nil)
require.NoError(t, err)
assert.Equal(t, "override-repo", m.Repo.Name)
assert.Equal(t, int64(10), m.Curr.Number)
})
})
t.Run("InvalidFile", func(t *testing.T) {
tempFile, err := os.CreateTemp("", "invalid.json")
require.NoError(t, err)
t.Cleanup(func() { os.Remove(tempFile.Name()) })
_, err = tempFile.Write([]byte("invalid json"))
require.NoError(t, err)
flags := []cli.Flag{
&cli.StringFlag{Name: "metadata-file"},
}
runCommand(flags, func(c *cli.Command) {
_ = c.Set("metadata-file", tempFile.Name())
_, err = metadataFromContext(context.Background(), c, nil, nil)
assert.Error(t, err)
})
})
t.Run("DefaultValues", func(t *testing.T) {
flags := []cli.Flag{
&cli.StringFlag{Name: "repo-name", Value: "test/default-repo"},
&cli.IntFlag{Name: "pipeline-number", Value: 1},
}
runCommand(flags, func(c *cli.Command) {
m, err := metadataFromContext(context.Background(), c, nil, nil)
require.NoError(t, err)
if assert.NotNil(t, m) {
assert.Equal(t, "test", m.Repo.Owner)
assert.Equal(t, "default-repo", m.Repo.Name)
assert.Equal(t, int64(1), m.Curr.Number)
}
})
})
t.Run("MatrixAxis", func(t *testing.T) {
runCommand([]cli.Flag{}, func(c *cli.Command) {
axis := matrix.Axis{"go": "1.16", "os": "linux"}
m, err := metadataFromContext(context.Background(), c, axis, nil)
require.NoError(t, err)
assert.EqualValues(t, map[string]string{"go": "1.16", "os": "linux"}, m.Workflow.Matrix)
})
})
}
func createTempFile(t *testing.T, content any) string {
t.Helper()
tempFile, err := os.CreateTemp("", "metadata.json")
require.NoError(t, err)
t.Cleanup(func() { os.Remove(tempFile.Name()) })
err = json.NewEncoder(tempFile).Encode(content)
require.NoError(t, err)
return tempFile.Name()
}

View file

@ -3144,6 +3144,49 @@ const docTemplate = `{
}
}
},
"/repos/{repo_id}/pipelines/{number}/metadata": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Pipelines"
],
"summary": "Get metadata for a pipeline or a specific workflow, including previous pipeline info",
"parameters": [
{
"type": "string",
"default": "Bearer \u003cpersonal access token\u003e",
"description": "Insert your personal access token",
"name": "Authorization",
"in": "header",
"required": true
},
{
"type": "integer",
"description": "the repository id",
"name": "repo_id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "the number of the pipeline",
"name": "number",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/metadata.Metadata"
}
}
}
}
},
"/repos/{repo_id}/pull_requests": {
"get": {
"produces": [
@ -5148,6 +5191,225 @@ const docTemplate = `{
"EventManual"
]
},
"metadata.Author": {
"type": "object",
"properties": {
"avatar": {
"type": "string"
},
"email": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"metadata.Commit": {
"type": "object",
"properties": {
"author": {
"$ref": "#/definitions/metadata.Author"
},
"branch": {
"type": "string"
},
"changed_files": {
"type": "array",
"items": {
"type": "string"
}
},
"is_prerelease": {
"type": "boolean"
},
"labels": {
"type": "array",
"items": {
"type": "string"
}
},
"message": {
"type": "string"
},
"ref": {
"type": "string"
},
"refspec": {
"type": "string"
},
"sha": {
"type": "string"
}
}
},
"metadata.Forge": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"metadata.Metadata": {
"type": "object",
"properties": {
"curr": {
"$ref": "#/definitions/metadata.Pipeline"
},
"forge": {
"$ref": "#/definitions/metadata.Forge"
},
"id": {
"type": "string"
},
"prev": {
"$ref": "#/definitions/metadata.Pipeline"
},
"repo": {
"$ref": "#/definitions/metadata.Repo"
},
"step": {
"$ref": "#/definitions/metadata.Step"
},
"sys": {
"$ref": "#/definitions/metadata.System"
},
"workflow": {
"$ref": "#/definitions/metadata.Workflow"
}
}
},
"metadata.Pipeline": {
"type": "object",
"properties": {
"commit": {
"$ref": "#/definitions/metadata.Commit"
},
"created": {
"type": "integer"
},
"cron": {
"type": "string"
},
"event": {
"type": "string"
},
"finished": {
"type": "integer"
},
"forge_url": {
"type": "string"
},
"number": {
"type": "integer"
},
"parent": {
"type": "integer"
},
"started": {
"type": "integer"
},
"status": {
"type": "string"
},
"target": {
"type": "string"
},
"task": {
"type": "string"
}
}
},
"metadata.Repo": {
"type": "object",
"properties": {
"clone_url": {
"type": "string"
},
"clone_url_ssh": {
"type": "string"
},
"default_branch": {
"type": "string"
},
"forge_url": {
"type": "string"
},
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"owner": {
"type": "string"
},
"private": {
"type": "boolean"
},
"remote_id": {
"type": "string"
},
"scm": {
"type": "string"
},
"trusted": {
"type": "boolean"
}
}
},
"metadata.Step": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"number": {
"type": "integer"
}
}
},
"metadata.System": {
"type": "object",
"properties": {
"arch": {
"type": "string"
},
"host": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
},
"version": {
"type": "string"
}
}
},
"metadata.Workflow": {
"type": "object",
"properties": {
"matrix": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"name": {
"type": "string"
},
"number": {
"type": "integer"
}
}
},
"model.ForgeType": {
"type": "string",
"enum": [

View file

@ -30,8 +30,10 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/server"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
"go.woodpecker-ci.org/woodpecker/v2/server/pipeline"
"go.woodpecker-ci.org/woodpecker/v2/server/pipeline/stepbuilder"
"go.woodpecker-ci.org/woodpecker/v2/server/router/middleware/session"
"go.woodpecker-ci.org/woodpecker/v2/server/store"
"go.woodpecker-ci.org/woodpecker/v2/server/store/types"
)
// CreatePipeline
@ -392,6 +394,47 @@ func GetPipelineConfig(c *gin.Context) {
c.JSON(http.StatusOK, configs)
}
// GetPipelineMetadata
//
// @Summary Get metadata for a pipeline or a specific workflow, including previous pipeline info
// @Router /repos/{repo_id}/pipelines/{number}/metadata [get]
// @Produce json
// @Success 200 {object} metadata.Metadata
// @Tags Pipelines
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param repo_id path int true "the repository id"
// @Param number path int true "the number of the pipeline"
func GetPipelineMetadata(c *gin.Context) {
repo := session.Repo(c)
num, err := strconv.ParseInt(c.Param("number"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
_store := store.FromContext(c)
currentPipeline, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
prevPipeline, err := _store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID)
if err != nil && !errors.Is(err, types.RecordNotExist) {
handleDBError(c, err)
return
}
metadata := stepbuilder.MetadataFromStruct(forge, repo, currentPipeline, prevPipeline, nil, server.Config.Server.Host)
c.JSON(http.StatusOK, metadata)
}
// CancelPipeline
//
// @Summary Cancel a pipeline

View file

@ -1,129 +1,217 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/franela/goblin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/metadata"
"go.woodpecker-ci.org/woodpecker/v2/server"
forge_mocks "go.woodpecker-ci.org/woodpecker/v2/server/forge/mocks"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
"go.woodpecker-ci.org/woodpecker/v2/server/store/mocks"
mocks_manager "go.woodpecker-ci.org/woodpecker/v2/server/services/mocks"
store_mocks "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks"
"go.woodpecker-ci.org/woodpecker/v2/server/store/types"
)
var fakePipeline = &model.Pipeline{
ID: 2,
Number: 2,
Status: model.StatusSuccess,
}
func TestGetPipelines(t *testing.T) {
gin.SetMode(gin.TestMode)
g := goblin.Goblin(t)
g.Describe("Pipeline", func() {
g.It("should get pipelines", func() {
pipelines := []*model.Pipeline{fakePipeline}
t.Run("should get pipelines", func(t *testing.T) {
pipelines := []*model.Pipeline{fakePipeline}
mockStore := mocks.NewStore(t)
mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)
mockStore := store_mocks.NewStore(t)
mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
GetPipelines(c)
GetPipelines(c)
mockStore.AssertCalled(t, "GetPipelineList", mock.Anything, mock.Anything, mock.Anything)
assert.Equal(t, http.StatusOK, c.Writer.Status())
})
mockStore.AssertCalled(t, "GetPipelineList", mock.Anything, mock.Anything, mock.Anything)
assert.Equal(t, http.StatusOK, c.Writer.Status())
})
g.It("should not parse pipeline filter", func() {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16&after=2023-01-15", nil)
t.Run("should not parse pipeline filter", func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16&after=2023-01-15", nil)
GetPipelines(c)
GetPipelines(c)
assert.Equal(t, http.StatusBadRequest, c.Writer.Status())
})
assert.Equal(t, http.StatusBadRequest, c.Writer.Status())
})
g.It("should parse pipeline filter", func() {
pipelines := []*model.Pipeline{fakePipeline}
t.Run("should parse pipeline filter", func(t *testing.T) {
pipelines := []*model.Pipeline{fakePipeline}
mockStore := mocks.NewStore(t)
mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)
mockStore := store_mocks.NewStore(t)
mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("store", mockStore)
c.Request, _ = http.NewRequest(http.MethodDelete, "/?2023-01-16T15:00:00Z&after=2023-01-15T15:00:00Z", nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("store", mockStore)
c.Request, _ = http.NewRequest(http.MethodDelete, "/?2023-01-16T15:00:00Z&after=2023-01-15T15:00:00Z", nil)
GetPipelines(c)
GetPipelines(c)
assert.Equal(t, http.StatusOK, c.Writer.Status())
})
assert.Equal(t, http.StatusOK, c.Writer.Status())
})
g.It("should parse pipeline filter with tz offset", func() {
pipelines := []*model.Pipeline{fakePipeline}
t.Run("should parse pipeline filter with tz offset", func(t *testing.T) {
pipelines := []*model.Pipeline{fakePipeline}
mockStore := mocks.NewStore(t)
mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)
mockStore := store_mocks.NewStore(t)
mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("store", mockStore)
c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16T15:00:00%2B01:00&after=2023-01-15T15:00:00%2B01:00", nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("store", mockStore)
c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16T15:00:00%2B01:00&after=2023-01-15T15:00:00%2B01:00", nil)
GetPipelines(c)
GetPipelines(c)
assert.Equal(t, http.StatusOK, c.Writer.Status())
})
assert.Equal(t, http.StatusOK, c.Writer.Status())
})
}
func TestDeletePipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
g := goblin.Goblin(t)
g.Describe("Pipeline", func() {
g.It("should delete pipeline", func() {
mockStore := mocks.NewStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil)
mockStore.On("DeletePipeline", mock.Anything).Return(nil)
t.Run("should delete pipeline", func(t *testing.T) {
mockStore := store_mocks.NewStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil)
mockStore.On("DeletePipeline", mock.Anything).Return(nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "number", Value: "2"}}
DeletePipeline(c)
mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything)
mockStore.AssertCalled(t, "DeletePipeline", mock.Anything)
assert.Equal(t, http.StatusNoContent, c.Writer.Status())
})
t.Run("should not delete without pipeline number", func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
DeletePipeline(c)
assert.Equal(t, http.StatusBadRequest, c.Writer.Status())
})
t.Run("should not delete pending", func(t *testing.T) {
fakePipeline := *fakePipeline
fakePipeline.Status = model.StatusPending
mockStore := store_mocks.NewStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(&fakePipeline, nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "number", Value: "2"}}
DeletePipeline(c)
mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything)
mockStore.AssertNotCalled(t, "DeletePipeline", mock.Anything)
assert.Equal(t, http.StatusUnprocessableEntity, c.Writer.Status())
})
}
func TestGetPipelineMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
prevPipeline := &model.Pipeline{
ID: 1,
Number: 1,
Status: model.StatusFailure,
}
fakeRepo := &model.Repo{ID: 1}
mockForge := forge_mocks.NewForge(t)
mockForge.On("Name").Return("mock")
mockForge.On("URL").Return("https://codeberg.org")
mockManager := mocks_manager.NewManager(t)
mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil)
server.Config.Services.Manager = mockManager
mockStore := store_mocks.NewStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, int64(2)).Return(fakePipeline, nil)
mockStore.On("GetPipelineLastBefore", mock.Anything, mock.Anything, int64(2)).Return(prevPipeline, nil)
t.Run("PipelineMetadata", func(t *testing.T) {
t.Run("should get pipeline metadata", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "number", Value: "2"}}
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "number", Value: "1"}}
c.Set("forge", mockForge)
c.Set("repo", fakeRepo)
DeletePipeline(c)
GetPipelineMetadata(c)
mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything)
mockStore.AssertCalled(t, "DeletePipeline", mock.Anything)
assert.Equal(t, http.StatusNoContent, c.Writer.Status())
assert.Equal(t, http.StatusOK, w.Code)
var response metadata.Metadata
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, int64(1), response.Repo.ID)
assert.Equal(t, int64(2), response.Curr.Number)
assert.Equal(t, int64(1), response.Prev.Number)
})
g.It("should not delete without pipeline number", func() {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
t.Run("should return bad request for invalid pipeline number", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "number", Value: "invalid"}}
DeletePipeline(c)
GetPipelineMetadata(c)
assert.Equal(t, http.StatusBadRequest, c.Writer.Status())
assert.Equal(t, http.StatusBadRequest, w.Code)
})
g.It("should not delete pending", func() {
fakePipeline.Status = model.StatusPending
t.Run("should return not found for non-existent pipeline", func(t *testing.T) {
mockStore := store_mocks.NewStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, int64(3)).Return((*model.Pipeline)(nil), types.RecordNotExist)
mockStore := mocks.NewStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "number", Value: "3"}}
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "number", Value: "1"}}
c.Set("repo", fakeRepo)
DeletePipeline(c)
GetPipelineMetadata(c)
mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything)
mockStore.AssertNotCalled(t, "DeletePipeline", mock.Anything)
assert.Equal(t, http.StatusUnprocessableEntity, c.Writer.Status())
assert.Equal(t, http.StatusNotFound, w.Code)
})
})
}

View file

@ -38,7 +38,7 @@ func parsePipeline(forge forge.Forge, store store.Store, currentPipeline *model.
}
// get the previous pipeline so that we can send status change notifications
last, err := store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID)
prev, err := store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Error().Err(err).Str("repo", repo.FullName).Msgf("error getting last pipeline before pipeline number '%d'", currentPipeline.Number)
}
@ -74,7 +74,7 @@ func parsePipeline(forge forge.Forge, store store.Store, currentPipeline *model.
b := stepbuilder.StepBuilder{
Repo: repo,
Curr: currentPipeline,
Last: last,
Prev: prev,
Netrc: netrc,
Secs: secs,
Regs: regs,

View file

@ -25,7 +25,7 @@ import (
)
// MetadataFromStruct return the metadata from a pipeline will run with.
func MetadataFromStruct(forge metadata.ServerForge, repo *model.Repo, pipeline, last *model.Pipeline, workflow *model.Workflow, sysURL string) metadata.Metadata {
func MetadataFromStruct(forge metadata.ServerForge, repo *model.Repo, pipeline, prev *model.Pipeline, workflow *model.Workflow, sysURL string) metadata.Metadata {
host := sysURL
uri, err := url.Parse(sysURL)
if err == nil {
@ -78,7 +78,7 @@ func MetadataFromStruct(forge metadata.ServerForge, repo *model.Repo, pipeline,
return metadata.Metadata{
Repo: fRepo,
Curr: metadataPipelineFromModelPipeline(pipeline, true),
Prev: metadataPipelineFromModelPipeline(last, false),
Prev: metadataPipelineFromModelPipeline(prev, false),
Workflow: fWorkflow,
Step: metadata.Step{},
Sys: metadata.System{

View file

@ -33,7 +33,7 @@ func TestMetadataFromStruct(t *testing.T) {
name string
forge metadata.ServerForge
repo *model.Repo
pipeline, last *model.Pipeline
pipeline, prev *model.Pipeline
workflow *model.Workflow
sysURL string
expectedMetadata metadata.Metadata
@ -63,7 +63,7 @@ func TestMetadataFromStruct(t *testing.T) {
forge: forge,
repo: &model.Repo{FullName: "testUser/testRepo", ForgeURL: "https://gitea.com/testUser/testRepo", Clone: "https://gitea.com/testUser/testRepo.git", CloneSSH: "git@gitea.com:testUser/testRepo.git", Branch: "main", IsSCMPrivate: true, SCMKind: "git"},
pipeline: &model.Pipeline{Number: 3, ChangedFiles: []string{"test.go", "markdown file.md"}},
last: &model.Pipeline{Number: 2},
prev: &model.Pipeline{Number: 2},
workflow: &model.Workflow{Name: "hello"},
sysURL: "https://example.com",
expectedMetadata: metadata.Metadata{
@ -98,7 +98,7 @@ func TestMetadataFromStruct(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
result := MetadataFromStruct(testCase.forge, testCase.repo, testCase.pipeline, testCase.last, testCase.workflow, testCase.sysURL)
result := MetadataFromStruct(testCase.forge, testCase.repo, testCase.pipeline, testCase.prev, testCase.workflow, testCase.sysURL)
assert.EqualValues(t, testCase.expectedMetadata, result)
assert.EqualValues(t, testCase.expectedEnviron, result.Environ())
})

View file

@ -42,7 +42,7 @@ import (
type StepBuilder struct {
Repo *model.Repo
Curr *model.Pipeline
Last *model.Pipeline
Prev *model.Pipeline
Netrc *model.Netrc
Secs []*model.Secret
Regs []*model.Registry
@ -115,7 +115,7 @@ func (b *StepBuilder) Build() (items []*Item, errorsAndWarnings error) {
}
func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.Axis, data string) (item *Item, errorsAndWarnings error) {
workflowMetadata := MetadataFromStruct(b.Forge, b.Repo, b.Curr, b.Last, workflow, b.Host)
workflowMetadata := MetadataFromStruct(b.Forge, b.Repo, b.Curr, b.Prev, workflow, b.Host)
environ := b.environmentVariables(workflowMetadata, axis)
// add global environment variables for substituting

View file

@ -42,7 +42,7 @@ func TestGlobalEnvsubst(t *testing.T) {
Message: "aaa",
Event: model.EventPush,
},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -81,7 +81,7 @@ func TestMissingGlobalEnvsubst(t *testing.T) {
Message: "aaa",
Event: model.EventPush,
},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -116,7 +116,7 @@ func TestMultilineEnvsubst(t *testing.T) {
Message: `aaa
bbb`,
},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -159,7 +159,7 @@ func TestMultiPipeline(t *testing.T) {
Curr: &model.Pipeline{
Event: model.EventPush,
},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -200,7 +200,7 @@ func TestDependsOn(t *testing.T) {
Curr: &model.Pipeline{
Event: model.EventPush,
},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -255,7 +255,7 @@ func TestRunsOn(t *testing.T) {
Curr: &model.Pipeline{
Event: model.EventPush,
},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -296,7 +296,7 @@ func TestPipelineName(t *testing.T) {
Curr: &model.Pipeline{
Event: model.EventPush,
},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -339,7 +339,7 @@ func TestBranchFilter(t *testing.T) {
Branch: "dev",
Event: model.EventPush,
},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -382,7 +382,7 @@ func TestRootWhenFilter(t *testing.T) {
Forge: getMockForge(t),
Repo: &model.Repo{},
Curr: &model.Pipeline{Event: "tag"},
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -432,7 +432,7 @@ func TestZeroSteps(t *testing.T) {
Forge: getMockForge(t),
Repo: &model.Repo{},
Curr: pipeline,
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -472,7 +472,7 @@ func TestZeroStepsAsMultiPipelineDeps(t *testing.T) {
Forge: getMockForge(t),
Repo: &model.Repo{},
Curr: pipeline,
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},
@ -530,7 +530,7 @@ func TestZeroStepsAsMultiPipelineTransitiveDeps(t *testing.T) {
Forge: getMockForge(t),
Repo: &model.Repo{},
Curr: pipeline,
Last: &model.Pipeline{},
Prev: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
Regs: []*model.Registry{},

View file

@ -102,6 +102,7 @@ func apiRoutes(e *gin.RouterGroup) {
repo.DELETE("/pipelines/:number", session.MustRepoAdmin(), api.DeletePipeline)
repo.GET("/pipelines/:number", api.GetPipeline)
repo.GET("/pipelines/:number/config", api.GetPipelineConfig)
repo.GET("/pipelines/:number/metadata", session.MustPush, api.GetPipelineMetadata)
// requires push permissions
repo.POST("/pipelines/:number", session.MustPush, api.PostPipeline)

View file

@ -246,7 +246,16 @@
"show_errors": "Show errors",
"we_got_some_errors": "Oh no, we got some errors!",
"duration": "Pipeline duration",
"created": "Created: {created}"
"created": "Created: {created}",
"debug": {
"title": "Debug",
"download_metadata": "Download metadata",
"metadata_download_error": "Error downloading metadata",
"metadata_download_successful": "Metadata downloaded successfully",
"no_permission": "You don't have permission to access debug information.",
"metadata_exec_title": "Rerun pipeline locally",
"metadata_exec_desc": "Download this pipeline's metadata to run it locally. This allows you to troubleshoot issues and test changes before committing them."
}
}
},
"org": {

View file

@ -119,6 +119,10 @@ export default class WoodpeckerClient extends ApiClient {
return this._get(`/api/repos/${repoId}/pipelines/${pipelineNumber}/config`) as Promise<PipelineConfig[]>;
}
async getPipelineMetadata(repoId: number, pipelineNumber: number): Promise<any> {
return this._get(`/api/repos/${repoId}/pipelines/${pipelineNumber}/metadata`) as Promise<any>;
}
async getPipelineFeed(): Promise<PipelineFeed[]> {
return this._get(`/api/user/feed`) as Promise<PipelineFeed[]>;
}

View file

@ -94,6 +94,11 @@ const routes: RouteRecordRaw[] = [
component: (): Component => import('~/views/repo/pipeline/PipelineErrors.vue'),
props: true,
},
{
path: 'debug',
name: 'repo-pipeline-debug',
component: (): Component => import('~/views/repo/pipeline/PipelineDebug.vue'),
},
],
},
{

View file

@ -0,0 +1,77 @@
<template>
<template v-if="repoPermissions && repoPermissions.push">
<Panel>
<InputField :label="$t('repo.pipeline.debug.metadata_exec_title')">
<p class="text-sm text-wp-text-alt-100 mb-2">{{ $t('repo.pipeline.debug.metadata_exec_desc') }}</p>
<pre class="code-box">{{ cliExecWithMetadata }}</pre>
</InputField>
<div class="flex items-center space-x-4">
<Button :is-loading="isLoading" :text="$t('repo.pipeline.debug.download_metadata')" @click="downloadMetadata" />
</div>
</Panel>
</template>
<div v-else class="flex items-center justify-center h-full">
<div class="text-center p-8 bg-wp-control-error-100 rounded-lg shadow-lg">
<p class="text-2xl font-bold text-white">{{ $t('repo.pipeline.debug.no_permission') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
import InputField from '~/components/form/InputField.vue';
import Panel from '~/components/layout/Panel.vue';
import useApiClient from '~/compositions/useApiClient';
import useNotifications from '~/compositions/useNotifications';
import type { Pipeline, Repo, RepoPermissions } from '~/lib/api/types';
const { t } = useI18n();
const apiClient = useApiClient();
const notifications = useNotifications();
const repo = inject<Ref<Repo>>('repo');
const pipeline = inject<Ref<Pipeline>>('pipeline');
const repoPermissions = inject<Ref<RepoPermissions>>('repo-permissions');
const isLoading = ref(false);
const metadataFileName = computed(
() => `${repo?.value.full_name.replaceAll('/', '-')}-pipeline-${pipeline?.value.number}-metadata.json`,
);
const cliExecWithMetadata = computed(() => `# woodpecker exec --metadata-file ${metadataFileName.value}`);
async function downloadMetadata() {
if (!repo?.value || !pipeline?.value || !repoPermissions?.value?.push) {
notifications.notify({ type: 'error', title: t('repo.pipeline.debug.metadata_download_error') });
return;
}
isLoading.value = true;
try {
const metadata = await apiClient.getPipelineMetadata(repo.value.id, pipeline.value.number);
// Create a Blob with the JSON data
const blob = new Blob([JSON.stringify(metadata, null, 2)], { type: 'application/json' });
// Create a download link and trigger the download
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = metadataFileName.value;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
notifications.notify({ type: 'success', title: t('repo.pipeline.debug.metadata_download_successful') });
} catch (error) {
console.error('Error fetching metadata:', error);
notifications.notify({ type: 'error', title: t('repo.pipeline.debug.metadata_download_error') });
} finally {
isLoading.value = false;
}
}
</script>

View file

@ -93,6 +93,7 @@
id="changed-files"
:title="$t('repo.pipeline.files', { files: pipeline.changed_files?.length })"
/>
<Tab v-if="repoPermissions && repoPermissions.push" id="debug" :title="$t('repo.pipeline.debug.title')" />
<router-view />
</Scaffold>
@ -219,6 +220,10 @@ const activeTab = computed({
return 'errors';
}
if (route.name === 'repo-pipeline-debug' && repoPermissions.value?.push) {
return 'debug';
}
return 'tasks';
},
set(tab: string) {
@ -237,6 +242,10 @@ const activeTab = computed({
if (tab === 'errors') {
router.replace({ name: 'repo-pipeline-errors' });
}
if (tab === 'debug' && repoPermissions.value?.push) {
router.replace({ name: 'repo-pipeline-debug' });
}
},
});

View file

@ -110,6 +110,9 @@ type Client interface {
// PipelineKill force kills the running pipeline.
PipelineKill(repoID, pipeline int64) error
// PipelineMetadata returns metadata for a pipeline.
PipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error)
// StepLogEntries returns the LogEntries for the given pipeline step
StepLogEntries(repoID, pipeline, stepID int64) ([]*LogEntry, error)

View file

@ -1211,6 +1211,36 @@ func (_m *Client) PipelineList(repoID int64) ([]*woodpecker.Pipeline, error) {
return r0, r1
}
// PipelineMetadata provides a mock function with given fields: repoID, pipelineNumber
func (_m *Client) PipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error) {
ret := _m.Called(repoID, pipelineNumber)
if len(ret) == 0 {
panic("no return value specified for PipelineMetadata")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(int64, int) ([]byte, error)); ok {
return rf(repoID, pipelineNumber)
}
if rf, ok := ret.Get(0).(func(int64, int) []byte); ok {
r0 = rf(repoID, pipelineNumber)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(int64, int) error); ok {
r1 = rf(repoID, pipelineNumber)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// PipelineQueue provides a mock function with given fields:
func (_m *Client) PipelineQueue() ([]*woodpecker.Feed, error) {
ret := _m.Called()

View file

@ -1,8 +1,15 @@
package woodpecker
import "fmt"
import (
"fmt"
"io"
"net/http"
)
const pathPipelineQueue = "%s/api/pipelines"
const (
pathPipelineQueue = "%s/api/pipelines"
pathPipelineMetadata = "%s/api/repos/%d/pipelines/%d/metadata"
)
// PipelineQueue returns a list of enqueued pipelines.
func (c *client) PipelineQueue() ([]*Feed, error) {
@ -11,3 +18,16 @@ func (c *client) PipelineQueue() ([]*Feed, error) {
err := c.get(uri, &out)
return out, err
}
// PipelineMetadata returns metadata for a pipeline, workflow name is optional.
func (c *client) PipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error) {
uri := fmt.Sprintf(pathPipelineMetadata, c.addr, repoID, pipelineNumber)
body, err := c.open(uri, http.MethodGet, nil)
if err != nil {
return nil, err
}
defer body.Close()
return io.ReadAll(body)
}