Repository: kubesphere/ks-devops
Pull request #479 (2022-02-27 09:42:52)

Create a new API to download artifacts from a PipelineRun
<!-- Thanks for sending a pull request! Here are some tips for you:

1. If you want **faster** PR reviews, read how: https://github.com/kubesphere/community/blob/master/developer-guide/development/the-pr-author-guide-to-getting-through-code-review.md
2. In case you want to know how your PR got reviewed, read: https://github.com/kubesphere/community/blob/master/developer-guide/development/code-review-guide.md
3. Here are some coding conventions followed by the KubeSphere community: https://github.com/kubesphere/community/blob/master/developer-guide/development/coding-conventions.md
4. Additional open-source best practice: https://github.com/LinuxSuRen/open-source-best-practice
-->

### What type of PR is this?
<!-- 
Add one of the following kinds:
/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind design
/kind chore

Optionally add one or more of the following kinds if applicable:
/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->
/kind feature
### What this PR does / why we need it:
Please refer to: https://github.com/kubesphere/ks-devops/issues/449
### Which issue(s) this PR fixes:
<!--
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
Please leave it or change # to be None if there is no corresponding issue that exists
-->
Fixes #

### Special notes for reviewers:
<!--
You can use the following command to let the DevOps SIG members help you to review your PR.
/cc @kubesphere/sig-devops 
And please avoid cc any individual.
-->
Please check the following list before waiting reviewers:
An API was created in v1aplha2 : https://github.com/mzmuer/ks-devops/blob/bcdd8765ade8743f65de170238f1e989b92c4955/pkg/kapis/devops/v1alpha2/register.go#L668

### Does this PR introduce a user-facing change??
<!--
If no, just write "None" in the release-note block below.
If yes, a release note is required:
Enter your extended-release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

Please keep the note be same as your PR title if you believe it should be in the release notes.
-->
```release-note
None
```


ks-ci-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/479#" title="Author self-approved">mzmuer</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **linuxsuren** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @linuxsuren` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubesphere%2Fks-devops).

<details open>
Needs approval from an approver in each of these files:

- **[pkg/kapis/devops/v1alpha2/OWNERS](https://github.com/kubesphere/ks-devops/blob/master/pkg/kapis/devops/v1alpha2/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["linuxsuren"]} -->
codecov[bot]: # [Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/479?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) Report
> Merging [#479](https://codecov.io/gh/kubesphere/ks-devops/pull/479?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (73d8e54) into [master](https://codecov.io/gh/kubesphere/ks-devops/commit/6f5a8d610bca7cc4795053e870d0b6cfd3ba0833?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (6f5a8d6) will **decrease** coverage by `0.01%`.
> The diff coverage is `66.66%`.

[![Impacted file tree graph](https://codecov.io/gh/kubesphere/ks-devops/pull/479/graphs/tree.svg?width=650&height=150&src=pr&token=qSK4RBbyNg&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere)](https://codecov.io/gh/kubesphere/ks-devops/pull/479?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere)

```diff
@@            Coverage Diff             @@
##           master     #479      +/-   ##
==========================================
- Coverage   77.18%   77.16%   -0.02%     
==========================================
  Files         133      133              
  Lines       22731    22759      +28     
==========================================
+ Hits        17544    17562      +18     
- Misses       4994     5003       +9     
- Partials      193      194       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `77.16% <66.66%> (-0.02%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/kubesphere/ks-devops/pull/479?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | Coverage Δ | |
|---|---|---|
| [pkg/kapis/devops/v1alpha2/devops.go](https://codecov.io/gh/kubesphere/ks-devops/pull/479/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMi9kZXZvcHMuZ28=) | `25.86% <47.36%> (+0.81%)` | :arrow_up: |
| [pkg/kapis/devops/v1alpha2/handler.go](https://codecov.io/gh/kubesphere/ks-devops/pull/479/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMi9oYW5kbGVyLmdv) | `45.45% <100.00%> (+5.45%)` | :arrow_up: |
| [pkg/kapis/devops/v1alpha2/register.go](https://codecov.io/gh/kubesphere/ks-devops/pull/479/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMi9yZWdpc3Rlci5nbw==) | `91.92% <100.00%> (+0.11%)` | :arrow_up: |
| [controllers/s2irun/s2irun\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/479/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvczJpcnVuL3MyaXJ1bl9jb250cm9sbGVyLmdv) | `33.57% <0.00%> (-3.65%)` | :arrow_down: |
| [...ns/devopscredential/devopscredential\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/479/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9kZXZvcHNjcmVkZW50aWFsL2Rldm9wc2NyZWRlbnRpYWxfY29udHJvbGxlci5nbw==) | `36.73% <0.00%> (+3.40%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/479?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/479?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere). Last update [6f5a8d6...73d8e54](https://codecov.io/gh/kubesphere/ks-devops/pull/479?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere).

mzmuer: /label tide/merge-method-squash
JohnNiang: /hold
I hold this PR util https://github.com/jenkins-zh/jenkins-client/issues/44 merges. See https://github.com/kubesphere/ks-devops/pull/479#discussion_r816571511 for more.
JohnNiang: Hi @mzmuer , I have created a pull request to release Jenkins Client in https://github.com/jenkins-zh/versions/pull/4. After that, you could bump version of Jenkins Client in go.mod to use the artifacts download feature you provided. I hope this will help you.
ks-ci-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/479#pullrequestreview-901911190" title="LGTM">JohnNiang</a>*, *<a href="https://github.com/kubesphere/ks-devops/pull/479#" title="Author self-approved">mzmuer</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **linuxsuren** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @linuxsuren` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubesphere%2Fks-devops).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubesphere/ks-devops/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["linuxsuren"]} -->
ks-ci-bot: New changes are detected. LGTM label has been removed.
LinuxSuRen: Any potential mis-operation?

<img width="753" alt="image" src="https://user-images.githubusercontent.com/1450685/158744962-a045203b-a452-46d5-90f7-3945cdafcec5.png">

ks-ci-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/479#" title="Author self-approved">mzmuer</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **linuxsuren** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @linuxsuren` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubesphere%2Fks-devops).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubesphere/ks-devops/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["linuxsuren"]} -->
mzmuer: > Any potential mis-operation?
> 
> <img alt="image" width="753" src="https://user-images.githubusercontent.com/1450685/158744962-a045203b-a452-46d5-90f7-3945cdafcec5.png">

I think this is this is a display problem because I rebase to master. Under normal circumstances, only my changes should be shown
LinuxSuRen: > I think this is this is a display problem because I rebase to master.

I'm afraid not. Which master branch do you rebase from? The master from kubesphere or yours?
mzmuer: Sorry, I don't think I finished rebase. 
Can I still solve this problem on my current basis by rebase to master?
LinuxSuRen: > Can I still solve this problem on my current basis by rebase to master?

I believe that this git commit issue can be fixed. But don't forget backup your changes, just in case of some bad mis-operation.

Patch:
diff --git a/cmd/apiserver/app/options/options.go b/cmd/apiserver/app/options/options.go
index cd12573..e37271d 100644
--- a/cmd/apiserver/app/options/options.go
+++ b/cmd/apiserver/app/options/options.go
@@ -110,7 +110,7 @@ func (s *ServerRunOptions) NewAPIServer(stopCh <-chan struct{}) (*apiserver.APIS
 		}
 	}
 
-	if s.JenkinsOptions.Host != "" {
+	if !s.JenkinsOptions.SkipVerify && s.JenkinsOptions.Host != "" {
 		devopsClient, err := jclient.NewJenkinsClient(s.JenkinsOptions)
 		if err != nil {
 			return nil, fmt.Errorf("failed to connect to jenkins, please check jenkins status, error: %v", err)
diff --git a/go.mod b/go.mod
index d1870ee..73cca6e 100644
--- a/go.mod
+++ b/go.mod
@@ -25,7 +25,7 @@ require (
 	github.com/google/go-cmp v0.5.5
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/h2non/gock v1.0.9
-	github.com/jenkins-zh/jenkins-client v0.0.8
+	github.com/jenkins-zh/jenkins-client v0.0.9
 	github.com/kubesphere/sonargo v0.0.2
 	github.com/onsi/ginkgo v1.16.4
 	github.com/onsi/gomega v1.15.0
diff --git a/go.sum b/go.sum
index db3f59c..4f6eeaa 100644
--- a/go.sum
+++ b/go.sum
@@ -324,8 +324,8 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jenkins-zh/jenkins-cli v0.0.32/go.mod h1:uE1mH9PNITrg0sugv6HXuM/CSddg0zxXoYu3w57I3JY=
-github.com/jenkins-zh/jenkins-client v0.0.8 h1:leaVU9pYvWcAhpG4VkX7KUFIcvNp1qxwnnGVjkhSfMU=
-github.com/jenkins-zh/jenkins-client v0.0.8/go.mod h1:ICBk7OOoTafVP//f/VfKZ34c0ff8vJwVnOsF9btiMYU=
+github.com/jenkins-zh/jenkins-client v0.0.9 h1:yqWtjLifYWbVxR+wFdExh/cHSQ54i/Cdco2MYkc/5vI=
+github.com/jenkins-zh/jenkins-client v0.0.9/go.mod h1:ICBk7OOoTafVP//f/VfKZ34c0ff8vJwVnOsF9btiMYU=
 github.com/jenkins-zh/jenkins-formulas v0.0.5/go.mod h1:zS8fm8u5L6FcjZM0QznXsLV9T2UtSVK+hT6Sm76iUZ4=
 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
diff --git a/pkg/client/devops/fake/fakedevops.go b/pkg/client/devops/fake/fakedevops.go
index ace75d1..27ea525 100644
--- a/pkg/client/devops/fake/fakedevops.go
+++ b/pkg/client/devops/fake/fakedevops.go
@@ -18,6 +18,7 @@ package fake
 
 import (
 	"fmt"
+	"io"
 	"io/ioutil"
 	"net/http"
 	"net/url"
@@ -189,6 +190,10 @@ func (d *Devops) RunPipeline(projectName, pipelineName string, httpParameters *d
 func (d *Devops) GetArtifacts(projectName, pipelineName, runId string, httpParameters *devops.HttpParameters) ([]devops.Artifacts, error) {
 	return nil, nil
 }
+func (d *Devops) DownloadArtifact(projectName, pipelineName, runId, filename string) (io.ReadCloser, error) {
+	return nil, nil
+}
+
 func (d *Devops) GetRunLog(projectName, pipelineName, runId string, httpParameters *devops.HttpParameters) ([]byte, error) {
 	return nil, nil
 }
diff --git a/pkg/client/devops/jclient/pipeline.go b/pkg/client/devops/jclient/pipeline.go
index c494e33..8217421 100644
--- a/pkg/client/devops/jclient/pipeline.go
+++ b/pkg/client/devops/jclient/pipeline.go
@@ -17,7 +17,12 @@ limitations under the License.
 package jclient
 
 import (
+	"fmt"
+	"io"
 	"net/http"
+	"strconv"
+
+	"github.com/jenkins-zh/jenkins-client/pkg/artifact"
 
 	"kubesphere.io/devops/pkg/client/devops"
 )
@@ -62,6 +67,16 @@ func (j *JenkinsClient) GetArtifacts(projectName, pipelineName, runID string, ht
 	return j.jenkins.GetArtifacts(projectName, pipelineName, runID, httpParameters)
 }
 
+// DownloadArtifact download an artifact
+func (j *JenkinsClient) DownloadArtifact(projectName, pipelineName, runID, filename string) (io.ReadCloser, error) {
+	jobRunID, err := strconv.Atoi(runID)
+	if err != nil {
+		return nil, fmt.Errorf("runId error, not a number: %v", err)
+	}
+	c := artifact.Client{JenkinsCore: j.Core}
+	return c.GetArtifact(projectName, pipelineName, jobRunID, filename)
+}
+
 // GetRunLog returns the log output of a pipeline run
 func (j *JenkinsClient) GetRunLog(projectName, pipelineName, runID string, httpParameters *devops.HttpParameters) ([]byte, error) {
 	return j.jenkins.GetRunLog(projectName, pipelineName, runID, httpParameters)
diff --git a/pkg/client/devops/pipeline.go b/pkg/client/devops/pipeline.go
index 016cf20..f61e49c 100644
--- a/pkg/client/devops/pipeline.go
+++ b/pkg/client/devops/pipeline.go
@@ -1135,6 +1135,7 @@ type PipelineOperator interface {
 	ReplayPipeline(projectName, pipelineName, runId string, httpParameters *HttpParameters) (*ReplayPipeline, error)
 	RunPipeline(projectName, pipelineName string, httpParameters *HttpParameters) (*RunPipeline, error)
 	GetArtifacts(projectName, pipelineName, runId string, httpParameters *HttpParameters) ([]Artifacts, error)
+	DownloadArtifact(projectName, pipelineName, runId, filename string) (io.ReadCloser, error)
 	GetRunLog(projectName, pipelineName, runId string, httpParameters *HttpParameters) ([]byte, error)
 	GetStepLog(projectName, pipelineName, runId, nodeId, stepId string, httpParameters *HttpParameters) ([]byte, http.Header, error)
 	GetNodeSteps(projectName, pipelineName, runId, nodeId string, httpParameters *HttpParameters) ([]NodeSteps, error)
diff --git a/pkg/kapis/devops/v1alpha2/devops.go b/pkg/kapis/devops/v1alpha2/devops.go
index 9734716..95d06b3 100644
--- a/pkg/kapis/devops/v1alpha2/devops.go
+++ b/pkg/kapis/devops/v1alpha2/devops.go
@@ -20,10 +20,11 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"kubesphere.io/devops/pkg/kapis"
 	"net/http"
 	"strings"
 
+	"kubesphere.io/devops/pkg/kapis"
+
 	"kubesphere.io/devops/pkg/apiserver/query"
 	"kubesphere.io/devops/pkg/apiserver/request"
 
diff --git a/pkg/kapis/devops/v1alpha2/register.go b/pkg/kapis/devops/v1alpha2/register.go
index 4ece5ef..36c502e 100644
--- a/pkg/kapis/devops/v1alpha2/register.go
+++ b/pkg/kapis/devops/v1alpha2/register.go
@@ -19,11 +19,12 @@ package v1alpha2
 import (
 	"context"
 	"fmt"
+	"net/url"
+	"strings"
+
 	"github.com/jenkins-zh/jenkins-client/pkg/core"
 	"kubesphere.io/devops/pkg/apiserver/runtime"
 	"kubesphere.io/devops/pkg/client/k8s"
-	"net/url"
-	"strings"
 
 	"github.com/emicklei/go-restful"
 	restfulspec "github.com/emicklei/go-restful-openapi"
@@ -725,6 +726,7 @@ func addJenkinsToContainer(webservice *restful.WebService, devopsClient devops.I
 		To(jenkinsProxy.proxyWithDevOps).
 		Returns(http.StatusOK, api.StatusOK, nil).
 		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsJenkinsTag}))
+
 	return nil
 }
 
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go b/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
index e2f83b7..5596d1a 100644
--- a/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
@@ -17,13 +17,16 @@ limitations under the License.
 package pipelinerun
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
 	"io"
-	"kubesphere.io/devops/pkg/kapis"
+	"net/url"
 	"strconv"
 
+	"kubesphere.io/devops/pkg/kapis"
+
 	"github.com/emicklei/go-restful"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/klog"
@@ -31,6 +34,7 @@ import (
 	"kubesphere.io/devops/pkg/apiserver/query"
 	apiserverrequest "kubesphere.io/devops/pkg/apiserver/request"
 	"kubesphere.io/devops/pkg/client/devops"
+	devopsClient "kubesphere.io/devops/pkg/client/devops"
 	"kubesphere.io/devops/pkg/models/pipelinerun"
 	resourcesV1alpha3 "kubesphere.io/devops/pkg/models/resources/v1alpha3"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -38,7 +42,8 @@ import (
 
 // apiHandlerOption holds some useful tools for API handler.
 type apiHandlerOption struct {
-	client client.Client
+	devopsClient devopsClient.Interface
+	client       client.Client
 }
 
 // apiHandler contains functions to handle coming request and give a response.
@@ -220,3 +225,56 @@ func (h *apiHandler) getNodeDetails(request *restful.Request, response *restful.
 
 	_ = response.WriteEntity(&stages)
 }
+
+// downloadArtifact API to download artifacts from Jenkins
+func (h *apiHandler) downloadArtifact(request *restful.Request, response *restful.Response) {
+	namespaceName := request.PathParameter("namespace")
+	pipelineRunName := request.PathParameter("pipelinerun")
+	filename := request.QueryParameter("filename")
+
+	// get pipelinerun
+	pr := &v1alpha3.PipelineRun{}
+	err := h.client.Get(context.Background(), client.ObjectKey{Namespace: namespaceName, Name: pipelineRunName}, pr)
+	if err != nil {
+		kapis.HandleError(request, response, err)
+		return
+	}
+
+	filename, err = url.QueryUnescape(filename)
+	if err != nil {
+		kapis.HandleError(request, response, err)
+		return
+	}
+
+	buildID, exists := pr.GetPipelineRunID()
+	if !exists {
+		kapis.HandleError(request, response, fmt.Errorf("unable to get PipelineRun nodes due to not found run ID"))
+		return
+	}
+	pipelineName := pr.Labels[v1alpha3.PipelineNameLabelKey]
+
+	// request the Jenkins API to download artifact
+	body, err := h.devopsClient.DownloadArtifact(namespaceName, pipelineName, buildID, filename)
+	if err != nil {
+		kapis.HandleError(request, response, err)
+		return
+	}
+	defer func() {
+		_ = body.Close()
+	}()
+
+	buf := &bytes.Buffer{}
+	if _, err = io.Copy(buf, body); err != nil {
+		kapis.HandleError(request, response, err)
+		return
+	}
+
+	// add download header
+	response.AddHeader("Content-Type", "application/octet-stream")
+	response.AddHeader("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
+	_, err = response.Write(buf.Bytes())
+	if err != nil {
+		kapis.HandleError(request, response, err)
+		return
+	}
+}
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/register.go b/pkg/kapis/devops/v1alpha3/pipelinerun/register.go
index 7fb8ed8..3a7c01b 100644
--- a/pkg/kapis/devops/v1alpha3/pipelinerun/register.go
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/register.go
@@ -19,19 +19,24 @@ package pipelinerun
 import (
 	"net/http"
 
+	restfulspec "github.com/emicklei/go-restful-openapi"
+	"kubesphere.io/devops/pkg/constants"
+
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	"kubesphere.io/devops/pkg/models/pipelinerun"
 
 	"github.com/emicklei/go-restful"
 	"kubesphere.io/devops/pkg/api"
 	"kubesphere.io/devops/pkg/client/devops"
+	devopsClient "kubesphere.io/devops/pkg/client/devops"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
 // RegisterRoutes register routes into web service.
-func RegisterRoutes(ws *restful.WebService, c client.Client) {
+func RegisterRoutes(ws *restful.WebService, devopsClient devopsClient.Interface, c client.Client) {
 	handler := newAPIHandler(apiHandlerOption{
-		client: c,
+		devopsClient: devopsClient,
+		client:       c,
 	})
 
 	ws.Route(ws.GET("/namespaces/{namespace}/pipelines/{pipeline}/pipelineruns").
@@ -69,4 +74,13 @@ func RegisterRoutes(ws *restful.WebService, c client.Client) {
 		Param(ws.PathParameter("namespace", "Namespace of the PipelineRun")).
 		Param(ws.PathParameter("pipelinerun", "Name of the PipelineRun")).
 		Returns(http.StatusOK, api.StatusOK, []pipelinerun.NodeDetail{}))
+
+	// download PipelineRun artifact
+	ws.Route(ws.GET("/namespaces/{namespace}/pipelineruns/{pipelinerun}/artifacts/download").
+		Param(ws.PathParameter("namespace", "Namespace of the PipelineRun")).
+		Param(ws.PathParameter("pipelinerun", "Name of the PipelineRun")).
+		Param(ws.QueryParameter("filename", "artifact filename. e.g. artifact:v1.0.1")).
+		To(handler.downloadArtifact).
+		Returns(http.StatusOK, api.StatusOK, nil).
+		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsPipelineTag}))
 }
diff --git a/pkg/kapis/devops/v1alpha3/register.go b/pkg/kapis/devops/v1alpha3/register.go
index fec6877..6429de2 100644
--- a/pkg/kapis/devops/v1alpha3/register.go
+++ b/pkg/kapis/devops/v1alpha3/register.go
@@ -19,10 +19,11 @@
 package v1alpha3
 
 import (
+	"net/http"
+
 	"kubesphere.io/devops/pkg/kapis/devops/v1alpha3/common"
 	"kubesphere.io/devops/pkg/kapis/devops/v1alpha3/scm"
 	"kubesphere.io/devops/pkg/kapis/devops/v1alpha3/template"
-	"net/http"
 
 	restful "github.com/emicklei/go-restful"
 	restfulspec "github.com/emicklei/go-restful-openapi"
@@ -56,7 +57,7 @@ func AddToContainer(container *restful.Container, devopsClient devopsClient.Inte
 
 	for _, service := range services {
 		registerRoutes(devopsClient, k8sClient, client, service)
-		pipelinerun.RegisterRoutes(service, client)
+		pipelinerun.RegisterRoutes(service, devopsClient, client)
 		pipeline.RegisterRoutes(service, client)
 		template.RegisterRoutes(service, &common.Options{
 			GenericClient: client,


Test patch:
diff --git a/pkg/kapis/devops/v1alpha2/register_test.go b/pkg/kapis/devops/v1alpha2/register_test.go
index 9dfb0a0..09c654c 100644
--- a/pkg/kapis/devops/v1alpha2/register_test.go
+++ b/pkg/kapis/devops/v1alpha2/register_test.go
@@ -18,6 +18,10 @@ package v1alpha2
 
 import (
 	"context"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
 	"github.com/emicklei/go-restful"
 	"github.com/jenkins-zh/jenkins-client/pkg/core"
 	"github.com/stretchr/testify/assert"
@@ -28,9 +32,6 @@ import (
 	fakedevops "kubesphere.io/devops/pkg/client/devops/fake"
 	"kubesphere.io/devops/pkg/client/k8s"
 	"kubesphere.io/devops/pkg/constants"
-	"net/http"
-	"net/http/httptest"
-	"testing"
 )
 
 func TestAPIsExist(t *testing.T) {
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/register_test.go b/pkg/kapis/devops/v1alpha3/pipelinerun/register_test.go
index b9be6e7..c81e362 100644
--- a/pkg/kapis/devops/v1alpha3/pipelinerun/register_test.go
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/register_test.go
@@ -17,14 +17,16 @@ limitations under the License.
 package pipelinerun
 
 import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
 	"github.com/emicklei/go-restful"
 	"github.com/stretchr/testify/assert"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
 	"kubesphere.io/devops/pkg/apiserver/runtime"
-	"net/http"
-	"net/http/httptest"
+	fakedevops "kubesphere.io/devops/pkg/client/devops/fake"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
-	"testing"
 )
 
 func TestAPIsExist(t *testing.T) {
@@ -34,7 +36,7 @@ func TestAPIsExist(t *testing.T) {
 	schema, err := v1alpha1.SchemeBuilder.Register().Build()
 	assert.Nil(t, err)
 
-	RegisterRoutes(wsWithGroup, fake.NewFakeClientWithScheme(schema))
+	RegisterRoutes(wsWithGroup, fakedevops.NewFakeDevops(nil), fake.NewFakeClientWithScheme(schema))
 	restful.DefaultContainer.Add(wsWithGroup)
 
 	type args struct {
@@ -68,6 +70,12 @@ func TestAPIsExist(t *testing.T) {
 			method: http.MethodGet,
 			uri:    "/namespaces/fake/pipelineruns/fake/nodedetails",
 		},
+	}, {
+		name: "download artifact",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/namespaces/fake/pipelineruns/fake/artifacts/download",
+		},
 	}}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {