mirror of https://gitlab.com/gitlab-org/cli.git
fix: update "glab ci run-trig" to use new go-gitlab functions
- Resolve merge conflicts - Update "glab ci run-trig" to use new go-gitlab pointer functions Fixes #7504 Signed-off-by: Justen Stall <justenstall@gmail.com>
This commit is contained in:
commit
7c7896a4fb
|
@ -1,2 +1,2 @@
|
|||
golang 1.21.5
|
||||
golang 1.21.9
|
||||
vale 3.0.7
|
||||
|
|
|
@ -438,6 +438,14 @@ var CreatePipeline = func(client *gitlab.Client, projectID interface{}, opts *gi
|
|||
return pipe, err
|
||||
}
|
||||
|
||||
var RunPipelineTrigger = func(client *gitlab.Client, projectID interface{}, opts *gitlab.RunPipelineTriggerOptions) (*gitlab.Pipeline, error) {
|
||||
if client == nil {
|
||||
client = apiClient.Lab()
|
||||
}
|
||||
pipe, _, err := client.PipelineTriggers.RunPipelineTrigger(projectID, opts)
|
||||
return pipe, err
|
||||
}
|
||||
|
||||
var DownloadArtifactJob = func(client *gitlab.Client, repo string, ref string, opts *gitlab.DownloadArtifactsFileOptions) (*bytes.Reader, error) {
|
||||
if client == nil {
|
||||
client = apiClient.Lab()
|
||||
|
|
|
@ -10,9 +10,10 @@ import (
|
|||
pipeListCmd "gitlab.com/gitlab-org/cli/commands/ci/list"
|
||||
pipeRetryCmd "gitlab.com/gitlab-org/cli/commands/ci/retry"
|
||||
pipeRunCmd "gitlab.com/gitlab-org/cli/commands/ci/run"
|
||||
pipeRunTrigCmd "gitlab.com/gitlab-org/cli/commands/ci/run_trig"
|
||||
pipeStatusCmd "gitlab.com/gitlab-org/cli/commands/ci/status"
|
||||
ciTraceCmd "gitlab.com/gitlab-org/cli/commands/ci/trace"
|
||||
pipeTriggerCmd "gitlab.com/gitlab-org/cli/commands/ci/trigger"
|
||||
jobPlayCmd "gitlab.com/gitlab-org/cli/commands/ci/trigger"
|
||||
ciViewCmd "gitlab.com/gitlab-org/cli/commands/ci/view"
|
||||
"gitlab.com/gitlab-org/cli/commands/cmdutils"
|
||||
|
||||
|
@ -38,7 +39,8 @@ func NewCmdCI(f *cmdutils.Factory) *cobra.Command {
|
|||
ciCmd.AddCommand(pipeStatusCmd.NewCmdStatus(f))
|
||||
ciCmd.AddCommand(pipeRetryCmd.NewCmdRetry(f))
|
||||
ciCmd.AddCommand(pipeRunCmd.NewCmdRun(f))
|
||||
ciCmd.AddCommand(pipeTriggerCmd.NewCmdTrigger(f))
|
||||
ciCmd.AddCommand(jobPlayCmd.NewCmdTrigger(f))
|
||||
ciCmd.AddCommand(pipeRunTrigCmd.NewCmdRunTrig(f))
|
||||
ciCmd.AddCommand(jobArtifactCmd.NewCmdRun(f))
|
||||
ciCmd.AddCommand(pipeGetCmd.NewCmdGet(f))
|
||||
ciCmd.AddCommand(ciConfigCmd.NewCmdConfig(f))
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"gitlab.com/gitlab-org/cli/commands/cmdutils"
|
||||
"gitlab.com/gitlab-org/cli/internal/glrepo"
|
||||
"gitlab.com/gitlab-org/cli/pkg/git"
|
||||
"gitlab.com/gitlab-org/cli/pkg/iostreams"
|
||||
|
@ -179,6 +180,27 @@ func getPipelineId(inputs *JobInputs, opts *JobOptions) (int, error) {
|
|||
return pipeline.ID, err
|
||||
}
|
||||
|
||||
func GetDefaultBranch(f *cmdutils.Factory) string {
|
||||
repo, err := f.BaseRepo()
|
||||
if err != nil {
|
||||
return "master"
|
||||
}
|
||||
|
||||
remotes, err := f.Remotes()
|
||||
if err != nil {
|
||||
return "master"
|
||||
}
|
||||
|
||||
repoRemote, err := remotes.FindByRepo(repo.RepoOwner(), repo.RepoName())
|
||||
if err != nil {
|
||||
return "master"
|
||||
}
|
||||
|
||||
branch, _ := git.GetDefaultBranch(repoRemote.Name)
|
||||
|
||||
return branch
|
||||
}
|
||||
|
||||
func getBranch(branch string, opts *JobOptions) (string, error) {
|
||||
if branch != "" {
|
||||
return branch, nil
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/xanzy/go-gitlab"
|
||||
"gitlab.com/gitlab-org/cli/api"
|
||||
"gitlab.com/gitlab-org/cli/commands/cmdutils"
|
||||
"gitlab.com/gitlab-org/cli/commands/mr/mrutils"
|
||||
"gitlab.com/gitlab-org/cli/pkg/git"
|
||||
"gitlab.com/gitlab-org/cli/pkg/tableprinter"
|
||||
|
||||
|
@ -63,7 +64,24 @@ func NewCmdGet(f *cmdutils.Factory) *cobra.Command {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pipelineId = commit.LastPipeline.ID
|
||||
|
||||
// The latest commit on the branch won't work with a merged
|
||||
// result pipeline
|
||||
if commit.LastPipeline == nil {
|
||||
mr, _, err := mrutils.MRFromArgs(f, args, "any")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mr.HeadPipeline == nil {
|
||||
return fmt.Errorf("no pipeline found. It might not exist yet. If this problem continues, check your pipeline configuration.")
|
||||
} else {
|
||||
pipelineId = mr.HeadPipeline.ID
|
||||
}
|
||||
|
||||
} else {
|
||||
pipelineId = commit.LastPipeline.ID
|
||||
}
|
||||
}
|
||||
|
||||
pipeline, err := api.GetPipeline(apiClient, pipelineId, nil, repo.FullName())
|
||||
|
|
|
@ -392,6 +392,88 @@ updated: 2023-10-10 00:00:00 +0000 UTC
|
|||
|
||||
# Variables:
|
||||
No variables found in pipeline.
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "when there is a merged result pipeline and no commit pipeline",
|
||||
args: "-b=main",
|
||||
httpMocks: []httpMock{
|
||||
{
|
||||
http.MethodGet,
|
||||
"/api/v4/projects/OWNER%2FREPO/pipelines/123",
|
||||
http.StatusOK,
|
||||
`{
|
||||
"id": 123,
|
||||
"iid": 123,
|
||||
"project_id": 5,
|
||||
"status": "pending",
|
||||
"source": "push",
|
||||
"ref": "main",
|
||||
"sha": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd",
|
||||
"user": {
|
||||
"username": "test"
|
||||
},
|
||||
"yaml_errors": "-",
|
||||
"created_at": "2023-10-10T00:00:00Z",
|
||||
"started_at": "2023-10-10T00:00:00Z",
|
||||
"updated_at": "2023-10-10T00:00:00Z"
|
||||
}`,
|
||||
InlineBody,
|
||||
},
|
||||
{
|
||||
http.MethodGet,
|
||||
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs?per_page=100",
|
||||
http.StatusOK,
|
||||
`[]`,
|
||||
InlineBody,
|
||||
},
|
||||
{
|
||||
http.MethodGet,
|
||||
"/api/v4/projects/OWNER%2FREPO/merge_requests/1",
|
||||
http.StatusOK,
|
||||
`{
|
||||
"head_pipeline": {
|
||||
"id": 123
|
||||
}
|
||||
}`,
|
||||
InlineBody,
|
||||
},
|
||||
{
|
||||
http.MethodGet,
|
||||
"/api/v4/projects/OWNER%2FREPO/merge_requests?per_page=30&source_branch=main",
|
||||
http.StatusOK,
|
||||
`[
|
||||
{
|
||||
"iid": 1
|
||||
}
|
||||
]`,
|
||||
InlineBody,
|
||||
},
|
||||
{
|
||||
http.MethodGet,
|
||||
"/api/v4/projects/OWNER%2FREPO/repository/commits/main",
|
||||
http.StatusOK,
|
||||
`{
|
||||
"last_pipeline": null
|
||||
}`,
|
||||
InlineBody,
|
||||
},
|
||||
},
|
||||
expectedOut: `# Pipeline:
|
||||
id: 123
|
||||
status: pending
|
||||
source: push
|
||||
ref: main
|
||||
sha: 0ff3ae198f8601a285adcf5c0fff204ee6fba5fd
|
||||
tag: false
|
||||
yaml Errors: -
|
||||
user: test
|
||||
created: 2023-10-10 00:00:00 +0000 UTC
|
||||
started: 2023-10-10 00:00:00 +0000 UTC
|
||||
updated: 2023-10-10 00:00:00 +0000 UTC
|
||||
|
||||
# Jobs:
|
||||
|
||||
`,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -7,8 +7,8 @@ import (
|
|||
"strings"
|
||||
|
||||
"gitlab.com/gitlab-org/cli/api"
|
||||
"gitlab.com/gitlab-org/cli/commands/ci/ciutils"
|
||||
"gitlab.com/gitlab-org/cli/commands/cmdutils"
|
||||
"gitlab.com/gitlab-org/cli/pkg/git"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/spf13/cobra"
|
||||
|
@ -22,27 +22,6 @@ var (
|
|||
|
||||
var envVariables = []string{}
|
||||
|
||||
func getDefaultBranch(f *cmdutils.Factory) string {
|
||||
repo, err := f.BaseRepo()
|
||||
if err != nil {
|
||||
return "master"
|
||||
}
|
||||
|
||||
remotes, err := f.Remotes()
|
||||
if err != nil {
|
||||
return "master"
|
||||
}
|
||||
|
||||
repoRemote, err := remotes.FindByRepo(repo.RepoOwner(), repo.RepoName())
|
||||
if err != nil {
|
||||
return "master"
|
||||
}
|
||||
|
||||
branch, _ := git.GetDefaultBranch(repoRemote.Name)
|
||||
|
||||
return branch
|
||||
}
|
||||
|
||||
func parseVarArg(s string) (*gitlab.PipelineVariableOptions, error) {
|
||||
// From https://pkg.go.dev/strings#Split:
|
||||
//
|
||||
|
@ -167,7 +146,7 @@ func NewCmdRun(f *cmdutils.Factory) *cobra.Command {
|
|||
} else {
|
||||
// `ci run` is running out of a git repo
|
||||
fmt.Fprintln(f.IO.StdOut, "not in a git repository, using repository argument")
|
||||
c.Ref = gitlab.Ptr(getDefaultBranch(f))
|
||||
c.Ref = gitlab.Ptr(ciutils.GetDefaultBranch(f))
|
||||
}
|
||||
|
||||
pipe, err := api.CreatePipeline(apiClient, repo.FullName(), c)
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
package run_trig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/gitlab-org/cli/api"
|
||||
"gitlab.com/gitlab-org/cli/commands/ci/ciutils"
|
||||
"gitlab.com/gitlab-org/cli/commands/cmdutils"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
var envVariables = []string{}
|
||||
|
||||
func parseVarArg(s string) (key string, val string, err error) {
|
||||
// From https://pkg.go.dev/strings#Split:
|
||||
//
|
||||
// > If s does not contain sep and sep is not empty,
|
||||
// > Split returns a slice of length 1 whose only element is s.
|
||||
//
|
||||
// Therefore, the function will always return a slice of min length 1.
|
||||
v := strings.SplitN(s, ":", 2)
|
||||
if len(v) == 1 {
|
||||
return "", "", fmt.Errorf("invalid argument structure")
|
||||
}
|
||||
return v[0], v[1], nil
|
||||
}
|
||||
|
||||
func NewCmdRunTrig(f *cmdutils.Factory) *cobra.Command {
|
||||
pipelineRunCmd := &cobra.Command{
|
||||
Use: "run-trig [flags]",
|
||||
Short: `Run a CI/CD pipeline trigger`,
|
||||
Aliases: []string{"run-trig"},
|
||||
Example: heredoc.Doc(`
|
||||
glab ci run-trig -t xxxx
|
||||
glab ci run-trig -t xxxx -b main
|
||||
glab ci run-trig -t xxxx -b main --variables key1:val1
|
||||
glab ci run-trig -t xxxx -b main --variables key1:val1,key2:val2
|
||||
glab ci run-trig -t xxxx -b main --variables key1:val1 --variables key2:val2
|
||||
`),
|
||||
Long: ``,
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
|
||||
apiClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repo, err := f.BaseRepo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c := &gitlab.RunPipelineTriggerOptions{
|
||||
Variables: make(map[string]string),
|
||||
}
|
||||
|
||||
if customPipelineVars, _ := cmd.Flags().GetStringSlice("variables"); len(customPipelineVars) > 0 {
|
||||
for _, v := range customPipelineVars {
|
||||
key, val, err := parseVarArg(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing pipeline variable expected format KEY:VALUE: %w", err)
|
||||
}
|
||||
c.Variables[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
branch, err := cmd.Flags().GetString("branch")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if branch != "" {
|
||||
c.Ref = gitlab.Ptr(branch)
|
||||
} else if currentBranch, err := f.Branch(); err == nil {
|
||||
c.Ref = gitlab.Ptr(currentBranch)
|
||||
} else {
|
||||
// `ci run-trig` is running out of a git repo
|
||||
fmt.Fprintln(f.IO.StdOut, "not in a git repository, using repository argument")
|
||||
c.Ref = gitlab.Ptr(ciutils.GetDefaultBranch(f))
|
||||
}
|
||||
|
||||
token, err := cmd.Flags().GetString("token")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv("CI_JOB_TOKEN")
|
||||
}
|
||||
if token == "" {
|
||||
return errors.New("--token parameter can be omitted only if CI_JOB_TOKEN environment variable is set")
|
||||
}
|
||||
c.Token = &token
|
||||
|
||||
pipe, err := api.RunPipelineTrigger(apiClient, repo.FullName(), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IO.StdOut, "Created pipeline (id:", pipe.ID, "), status:", pipe.Status, ", ref:", pipe.Ref, ", weburl: ", pipe.WebURL, ")")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
pipelineRunCmd.Flags().StringP("token", "t", "", "Pipeline trigger token (can be omitted only if CI_JOB_TOKEN environment variable is set)")
|
||||
pipelineRunCmd.Flags().StringP("branch", "b", "", "Create pipeline on branch/ref <string>")
|
||||
pipelineRunCmd.Flags().StringSliceVarP(&envVariables, "variables", "", []string{}, "Pass variables to pipeline in format <key>:<value>")
|
||||
|
||||
return pipelineRunCmd
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package run_trig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitlab.com/gitlab-org/cli/commands/cmdtest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gitlab.com/gitlab-org/cli/pkg/httpmock"
|
||||
"gitlab.com/gitlab-org/cli/test"
|
||||
)
|
||||
|
||||
type ResponseJSON struct {
|
||||
Token string `json:"token"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||
ios, _, stdout, stderr := cmdtest.InitIOStreams(isTTY, "")
|
||||
|
||||
factory := cmdtest.InitFactory(ios, rt)
|
||||
|
||||
factory.Branch = func() (string, error) {
|
||||
return "custom-branch-123", nil
|
||||
}
|
||||
|
||||
_, _ = factory.HttpClient()
|
||||
|
||||
cmd := NewCmdRunTrig(factory)
|
||||
|
||||
return cmdtest.ExecuteCommand(cmd, cli, stdout, stderr)
|
||||
}
|
||||
|
||||
func TestCIRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
ciJobToken string
|
||||
expectedPOSTBody string
|
||||
expectedOut string
|
||||
}{
|
||||
{
|
||||
name: "when running `ci run-trig` without branch parameter, defaults to current branch",
|
||||
cli: "-t foobar",
|
||||
ciJobToken: "",
|
||||
expectedPOSTBody: fmt.Sprintf(`"ref":"%s"`, "custom-branch-123"),
|
||||
expectedOut: fmt.Sprintf("Created pipeline (id: 123 ), status: created , ref: %s , weburl: https://gitlab.com/OWNER/REPO/-/pipelines/123 )\n", "custom-branch-123"),
|
||||
},
|
||||
{
|
||||
name: "when running `ci run-trig` with branch parameter, run CI at branch",
|
||||
cli: "-t foobar -b ci-cd-improvement-399",
|
||||
ciJobToken: "",
|
||||
expectedPOSTBody: `"ref":"ci-cd-improvement-399"`,
|
||||
expectedOut: "Created pipeline (id: 123 ), status: created , ref: ci-cd-improvement-399 , weburl: https://gitlab.com/OWNER/REPO/-/pipelines/123 )\n",
|
||||
},
|
||||
{
|
||||
name: "when running `ci run-trig` without any parameter, takes trigger token from env variable",
|
||||
cli: "",
|
||||
ciJobToken: "foobar",
|
||||
expectedPOSTBody: fmt.Sprintf(`"ref":"%s"`, "custom-branch-123"),
|
||||
expectedOut: fmt.Sprintf("Created pipeline (id: 123 ), status: created , ref: %s , weburl: https://gitlab.com/OWNER/REPO/-/pipelines/123 )\n", "custom-branch-123"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fakeHTTP := &httpmock.Mocker{
|
||||
MatchURL: httpmock.PathAndQuerystring,
|
||||
}
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
initialEnvValue := os.Getenv("CI_JOB_TOKEN")
|
||||
os.Setenv("CI_JOB_TOKEN", tc.ciJobToken)
|
||||
defer os.Setenv("CI_JOB_TOKEN", initialEnvValue)
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/projects/OWNER/REPO/trigger/pipeline",
|
||||
func(req *http.Request) (*http.Response, error) {
|
||||
rb, _ := io.ReadAll(req.Body)
|
||||
|
||||
var response ResponseJSON
|
||||
err := json.Unmarshal(rb, &response)
|
||||
if err != nil {
|
||||
fmt.Printf("Error when parsing response body %s\n", rb)
|
||||
}
|
||||
|
||||
if response.Token != "foobar" {
|
||||
fmt.Printf("Invalid token %s\n", rb)
|
||||
}
|
||||
|
||||
// ensure CLI runs CI on correct branch
|
||||
assert.Contains(t, string(rb), tc.expectedPOSTBody)
|
||||
resp, _ := httpmock.NewStringResponse(http.StatusOK, fmt.Sprintf(`{
|
||||
"id": 123,
|
||||
"iid": 123,
|
||||
"project_id": 3,
|
||||
"status": "created",
|
||||
"ref": "%s",
|
||||
"web_url": "https://gitlab.com/OWNER/REPO/-/pipelines/123"}`, response.Ref))(req)
|
||||
return resp, nil
|
||||
},
|
||||
)
|
||||
|
||||
output, _ := runCommand(fakeHTTP, false, tc.cli)
|
||||
|
||||
out := output.String()
|
||||
|
||||
assert.Equal(t, tc.expectedOut, out)
|
||||
assert.Empty(t, output.Stderr())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -162,6 +162,9 @@ func InitFactory(ios *iostreams.IOStreams, rt http.RoundTripper) *cmdutils.Facto
|
|||
BaseRepo: func() (glrepo.Interface, error) {
|
||||
return glrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
Branch: func() (string, error) {
|
||||
return "main", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ pipeline
|
|||
- [`list`](list.md)
|
||||
- [`retry`](retry.md)
|
||||
- [`run`](run.md)
|
||||
- [`run-trig`](run-trig.md)
|
||||
- [`status`](status.md)
|
||||
- [`trace`](trace.md)
|
||||
- [`trigger`](trigger.md)
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
stage: Create
|
||||
group: Code Review
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
<!--
|
||||
This documentation is auto generated by a script.
|
||||
Please do not edit this file directly. Run `make gen-docs` instead.
|
||||
-->
|
||||
|
||||
# `glab ci run-trig`
|
||||
|
||||
Run a CI/CD pipeline trigger
|
||||
|
||||
```plaintext
|
||||
glab ci run-trig [flags]
|
||||
```
|
||||
|
||||
## Aliases
|
||||
|
||||
```plaintext
|
||||
run-trig
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```plaintext
|
||||
glab ci run-trig -t xxxx
|
||||
glab ci run-trig -t xxxx -b main
|
||||
glab ci run-trig -t xxxx -b main --variables key1:val1
|
||||
glab ci run-trig -t xxxx -b main --variables key1:val1,key2:val2
|
||||
glab ci run-trig -t xxxx -b main --variables key1:val1 --variables key2:val2
|
||||
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```plaintext
|
||||
-b, --branch string Create pipeline on branch/ref <string>
|
||||
-t, --token string Pipeline trigger token (can be omitted only if CI_JOB_TOKEN environment variable is set)
|
||||
--variables strings Pass variables to pipeline in format <key>:<value>
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
```plaintext
|
||||
--help Show help for command
|
||||
-R, --repo OWNER/REPO Select another repository using the OWNER/REPO or `GROUP/NAMESPACE/REPO` format or full URL or git URL
|
||||
```
|
Loading…
Reference in New Issue