feat(ci): align job id resolution ( #7422)

This commit is contained in:
Andreas Weber 2023-12-06 09:11:57 +00:00 committed by Shekhar Patnaik
parent d6a7b20cd2
commit 54dfd62e5e
20 changed files with 1028 additions and 404 deletions

View File

@ -50,7 +50,7 @@ func NewCmdSet(f *cmdutils.Factory, runF func(*SetOptions) error) *cobra.Command
Example: heredoc.Doc(`
$ glab alias set mrv 'mr view'
$ glab mrv -w 123
#=> glab mr view -w 123
# glab mr view -w 123
$ glab alias set createissue 'glab create issue --title "$1"'
$ glab createissue "My Issue" --description "Something is broken."
@ -58,7 +58,7 @@ func NewCmdSet(f *cmdutils.Factory, runF func(*SetOptions) error) *cobra.Command
$ glab alias set --shell igrep 'glab issue list --assignee="$1" | grep $2'
$ glab igrep user foo
#=> glab issue list --assignee="user" | grep "foo"
# glab issue list --assignee="user" | grep "foo"
`),
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {

View File

@ -28,7 +28,7 @@ func NewCmdCI(f *cmdutils.Factory) *cobra.Command {
cmdutils.EnableRepoOverride(ciCmd, f)
ciCmd.AddCommand(legacyCICmd.NewCmdCI(f))
ciCmd.AddCommand(ciTraceCmd.NewCmdTrace(f, nil))
ciCmd.AddCommand(ciTraceCmd.NewCmdTrace(f))
ciCmd.AddCommand(ciViewCmd.NewCmdView(f))
ciCmd.AddCommand(ciLintCmd.NewCmdLint(f))
ciCmd.AddCommand(pipeDeleteCmd.NewCmdDelete(f))

View File

@ -4,15 +4,22 @@ import (
"context"
"fmt"
"io"
"regexp"
"strconv"
"sync"
"time"
"gitlab.com/gitlab-org/cli/internal/glrepo"
"gitlab.com/gitlab-org/cli/pkg/git"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"gitlab.com/gitlab-org/cli/pkg/prompt"
"gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/pkg/tableprinter"
"gitlab.com/gitlab-org/cli/pkg/utils"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/pkg/errors"
"github.com/xanzy/go-gitlab"
)
@ -78,17 +85,17 @@ func RunTraceSha(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid
if err != nil || job == nil {
return errors.Wrap(err, "failed to find job")
}
return RunTrace(ctx, apiClient, w, pid, job, name)
return runTrace(ctx, apiClient, w, pid, job.ID)
}
func RunTrace(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid interface{}, job *gitlab.Job, name string) error {
func runTrace(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid interface{}, jobId int) error {
fmt.Fprintln(w, "Getting job trace...")
for range time.NewTicker(time.Second * 3).C {
if ctx.Err() == context.Canceled {
break
}
trace, _, err := apiClient.Jobs.GetTraceFile(pid, job.ID)
if err != nil || trace == nil {
job, _, err := apiClient.Jobs.GetJob(pid, jobId)
if err != nil {
return errors.Wrap(err, "failed to find job")
}
switch job.Status {
@ -102,11 +109,12 @@ func RunTrace(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid in
fmt.Fprintf(w, "%s has been skipped\n", job.Name)
}
once.Do(func() {
if name == "" {
name = job.Name
}
fmt.Fprintf(w, "Showing logs for %s job #%d\n", job.Name, job.ID)
})
trace, _, err := apiClient.Jobs.GetTraceFile(pid, jobId)
if err != nil || trace == nil {
return errors.Wrap(err, "failed to find job")
}
_, _ = io.CopyN(io.Discard, trace, offset)
lenT, err := io.Copy(w, trace)
if err != nil {
@ -122,3 +130,158 @@ func RunTrace(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid in
}
return nil
}
func GetJobId(inputs *JobInputs, opts *JobOptions) (int, error) {
// If the user hasn't supplied an argument, we display the jobs list interactively.
if inputs.JobName == "" {
return getJobIdInteractive(inputs, opts)
}
// If the user supplied a job ID, we can use it directly.
if jobID, err := strconv.Atoi(inputs.JobName); err == nil {
return jobID, nil
}
// Otherwise, we try to find the latest job ID based on the job name.
pipelineId, err := getPipelineId(inputs, opts)
if err != nil {
return 0, fmt.Errorf("get pipeline: %w", err)
}
jobs, _, err := opts.ApiClient.Jobs.ListPipelineJobs(opts.Repo.FullName(), pipelineId, nil)
if err != nil {
return 0, fmt.Errorf("list pipeline jobs: %w", err)
}
for _, job := range jobs {
if job.Name == inputs.JobName {
return job.ID, nil
}
}
return 0, fmt.Errorf("pipeline %d contains no jobs", pipelineId)
}
func getPipelineId(inputs *JobInputs, opts *JobOptions) (int, error) {
if inputs.PipelineId != 0 {
return inputs.PipelineId, nil
}
branch, err := getBranch(inputs.Branch, opts)
if err != nil {
return 0, fmt.Errorf("get branch: %w", err)
}
pipeline, err := api.GetLastPipeline(opts.ApiClient, opts.Repo.FullName(), branch)
if err != nil {
return 0, fmt.Errorf("get last pipeline: %w", err)
}
return pipeline.ID, err
}
func getBranch(branch string, opts *JobOptions) (string, error) {
if branch != "" {
return branch, nil
}
branch, err := git.CurrentBranch()
if err != nil {
return "", err
}
return branch, nil
}
func getJobIdInteractive(inputs *JobInputs, opts *JobOptions) (int, error) {
pipelineId, err := getPipelineId(inputs, opts)
if err != nil {
return 0, err
}
fmt.Fprintf(opts.IO.StdOut, "Getting jobs for pipeline %d...\n\n", pipelineId)
jobs, err := api.GetPipelineJobs(opts.ApiClient, pipelineId, opts.Repo.FullName())
if err != nil {
return 0, err
}
var jobOptions []string
var selectedJob string
for _, job := range jobs {
jobOptions = append(jobOptions, fmt.Sprintf("%s (%d) - %s", job.Name, job.ID, job.Status))
}
promptOpts := &survey.Select{
Message: "Select pipeline job to trace:",
Options: jobOptions,
}
err = prompt.AskOne(promptOpts, &selectedJob)
if err != nil {
if errors.Is(err, terminal.InterruptErr) {
return 0, nil
}
return 0, err
}
if selectedJob != "" {
re := regexp.MustCompile(`(?s)\((.*)\)`)
m := re.FindAllStringSubmatch(selectedJob, -1)
return utils.StringToInt(m[0][1]), nil
} else if len(jobs) > 0 {
return jobs[0].ID, nil
} else {
pipeline, err := api.GetPipeline(opts.ApiClient, pipelineId, nil, opts.Repo.FullName())
if err != nil {
return 0, err
}
// use commit statuses to show external jobs
cs, err := api.GetCommitStatuses(opts.ApiClient, opts.Repo.FullName(), pipeline.SHA)
if err != nil {
return 0, nil
}
c := opts.IO.Color()
fmt.Fprint(opts.IO.StdOut, "Getting external jobs...")
for _, status := range cs {
var s string
switch status.Status {
case "success":
s = c.Green(status.Status)
case "error":
s = c.Red(status.Status)
default:
s = c.Gray(status.Status)
}
fmt.Fprintf(opts.IO.StdOut, "(%s) %s\nURL: %s\n\n", s, c.Bold(status.Name), c.Gray(status.TargetURL))
}
return 0, nil
}
}
type JobInputs struct {
JobName string
Branch string
PipelineId int
}
type JobOptions struct {
ApiClient *gitlab.Client
Repo glrepo.Interface
IO *iostreams.IOStreams
}
func TraceJob(inputs *JobInputs, opts *JobOptions) error {
jobID, err := GetJobId(inputs, opts)
if err != nil {
fmt.Fprintln(opts.IO.StdErr, "invalid job ID:", inputs.JobName)
return err
}
fmt.Fprintln(opts.IO.StdOut)
return runTrace(context.Background(), opts.ApiClient, opts.IO.StdOut, opts.Repo.FullName(), jobID)
}

View File

@ -0,0 +1,349 @@
package ciutils
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/cli/commands/cmdtest"
"gitlab.com/gitlab-org/cli/pkg/httpmock"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"gitlab.com/gitlab-org/cli/pkg/prompt"
)
func TestGetJobId(t *testing.T) {
type httpMock struct {
method string
path string
status int
body string
}
tests := []struct {
name string
jobName string
pipelineId int
httpMocks []httpMock
askOneStubs []string
expectedOut int
expectedError string
}{
{
name: "when getJobId with integer is requested",
jobName: "1122",
expectedOut: 1122,
httpMocks: []httpMock{},
}, {
name: "when getJobId with name and pipelineId is requested",
jobName: "lint",
pipelineId: 123,
expectedOut: 1122,
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs",
http.StatusOK,
`[{
"id": 1122,
"name": "lint",
"status": "failed"
}, {
"id": 1124,
"name": "publish",
"status": "failed"
}]`,
},
},
}, {
name: "when getJobId with name and pipelineId is requested and listJobs throws error",
jobName: "lint",
pipelineId: 123,
expectedError: "list pipeline jobs: GET https://gitlab.com/api/v4/projects/OWNER/REPO/pipelines/123/jobs: 403 ",
expectedOut: 0,
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs",
http.StatusForbidden,
`{}`,
},
},
}, {
name: "when getJobId with name and last pipeline is requested",
jobName: "lint",
pipelineId: 0,
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/repository/commits/main",
http.StatusOK,
`{
"last_pipeline" : {
"id": 123
}
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs",
http.StatusOK,
`[{
"id": 1122,
"name": "lint",
"status": "failed"
}, {
"id": 1124,
"name": "publish",
"status": "failed"
}]`,
},
},
expectedOut: 1122,
}, {
name: "when getJobId with name and last pipeline is requested and getCommits throws error",
jobName: "lint",
pipelineId: 0,
expectedError: "get pipeline: get last pipeline: GET https://gitlab.com/api/v4/projects/OWNER/REPO/repository/commits/main: 403 ",
expectedOut: 0,
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/repository/commits/main",
http.StatusForbidden,
`{}`,
},
},
}, {
name: "when getJobId with name and last pipeline is requested and getJobs throws error",
jobName: "lint",
pipelineId: 0,
expectedError: "list pipeline jobs: GET https://gitlab.com/api/v4/projects/OWNER/REPO/pipelines/123/jobs: 403 ",
expectedOut: 0,
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/repository/commits/main",
http.StatusOK,
`{
"last_pipeline" : {
"id": 123
}
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs",
http.StatusForbidden,
`{}`,
},
},
}, {
name: "when getJobId with pipelineId is requested, ask for job and answer",
jobName: "",
pipelineId: 123,
expectedOut: 1122,
askOneStubs: []string{"lint (1122) - failed"},
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs?per_page=100",
http.StatusOK,
`[{
"id": 1122,
"name": "lint",
"status": "failed"
}, {
"id": 1124,
"name": "publish",
"status": "failed"
}]`,
},
},
}, {
name: "when getJobId with pipelineId is requested, ask for job and give no answer",
jobName: "",
pipelineId: 123,
expectedOut: 1122,
askOneStubs: []string{""},
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs?per_page=100",
http.StatusOK,
`[{
"id": 1122,
"name": "lint",
"status": "failed"
}, {
"id": 1124,
"name": "publish",
"status": "failed"
}]`,
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fakeHTTP := &httpmock.Mocker{
MatchURL: httpmock.PathAndQuerystring,
}
if tc.askOneStubs != nil {
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
for _, value := range tc.askOneStubs {
as.StubOne(value)
}
}
defer fakeHTTP.Verify(t)
for _, mock := range tc.httpMocks {
fakeHTTP.RegisterResponder(mock.method, mock.path, httpmock.NewStringResponse(mock.status, mock.body))
}
ios, _, _, _ := iostreams.Test()
f := cmdtest.InitFactory(ios, fakeHTTP)
_, _ = f.HttpClient()
apiClient, _ := f.HttpClient()
repo, _ := f.BaseRepo()
output, err := GetJobId(&JobInputs{
JobName: tc.jobName,
PipelineId: tc.pipelineId,
Branch: "main",
}, &JobOptions{
IO: f.IO,
Repo: repo,
ApiClient: apiClient,
})
if tc.expectedError == "" {
require.Nil(t, err)
} else {
require.NotNil(t, err)
require.Equal(t, tc.expectedError, err.Error())
}
assert.Equal(t, tc.expectedOut, output)
})
}
}
func TestTraceJob(t *testing.T) {
type httpMock struct {
method string
path string
status int
body string
}
tests := []struct {
name string
jobName string
pipelineId int
httpMocks []httpMock
expectedError string
}{
{
name: "when traceJob is requested",
jobName: "1122",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122/trace",
http.StatusOK,
`Lorem ipsum`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122",
http.StatusOK,
`{
"id": 1122,
"name": "lint",
"status": "success"
}`,
},
},
},
{
name: "when traceJob is requested and getJob throws error",
jobName: "1122",
expectedError: "failed to find job: GET https://gitlab.com/api/v4/projects/OWNER/REPO/jobs/1122: 403 ",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122",
http.StatusForbidden,
`{}`,
},
},
},
{
name: "when traceJob is requested and getJob throws error",
jobName: "1122",
expectedError: "failed to find job: GET https://gitlab.com/api/v4/projects/OWNER/REPO/jobs/1122/trace: 403 ",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122/trace",
http.StatusForbidden,
`{}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122",
http.StatusOK,
`{
"id": 1122,
"name": "lint",
"status": "success"
}`,
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fakeHTTP := &httpmock.Mocker{
MatchURL: httpmock.PathAndQuerystring,
}
defer fakeHTTP.Verify(t)
for _, mock := range tc.httpMocks {
fakeHTTP.RegisterResponder(mock.method, mock.path, httpmock.NewStringResponse(mock.status, mock.body))
}
ios, _, _, _ := iostreams.Test()
f := cmdtest.InitFactory(ios, fakeHTTP)
_, _ = f.HttpClient()
apiClient, _ := f.HttpClient()
repo, _ := f.BaseRepo()
err := TraceJob(&JobInputs{
JobName: tc.jobName,
PipelineId: tc.pipelineId,
Branch: "main",
}, &JobOptions{
IO: f.IO,
Repo: repo,
ApiClient: apiClient,
})
if tc.expectedError == "" {
require.Nil(t, err)
} else {
require.NotNil(t, err)
require.Equal(t, tc.expectedError, err.Error())
}
})
}
}

View File

@ -22,7 +22,7 @@ func NewCmdCI(f *cmdutils.Factory) *cobra.Command {
`),
}
pipelineCICmd.AddCommand(ciTraceCmd.NewCmdTrace(f, nil))
pipelineCICmd.AddCommand(ciTraceCmd.NewCmdTrace(f))
pipelineCICmd.AddCommand(ciViewCmd.NewCmdView(f))
pipelineCICmd.AddCommand(ciLintCmd.NewCmdLint(f))
pipelineCICmd.Deprecated = "This command is deprecated. All the commands under it has been moved to `ci` or `pipeline` command. See https://gitlab.com/gitlab-org/cli/issues/372 for more info."

View File

@ -22,7 +22,7 @@ func NewCmdLint(f *cmdutils.Factory) *cobra.Command {
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
$ glab ci lint
#=> Uses .gitlab-ci.yml in the current directory
# Uses .gitlab-ci.yml in the current directory
$ glab ci lint .gitlab-ci.yml

View File

@ -4,8 +4,8 @@ import (
"fmt"
"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/utils"
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
@ -17,39 +17,60 @@ func NewCmdRetry(f *cmdutils.Factory) *cobra.Command {
Short: `Retry a CI/CD job`,
Aliases: []string{},
Example: heredoc.Doc(`
glab ci retry 871528
$ glab ci retry
# Interactively select a job to retry
$ glab ci retry 224356863
# Retry job with ID 224356863
$ glab ci retry lint
# Retry job with the name 'lint'
`),
Long: ``,
Args: cobra.ExactArgs(1),
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
}
apiClient, err := f.HttpClient()
if err != nil {
return err
}
jobID := utils.StringToInt(args[0])
jobName := ""
if len(args) != 0 {
jobName = args[0]
}
branch, _ := cmd.Flags().GetString("branch")
pipelineId, _ := cmd.Flags().GetInt("pipeline-id")
if jobID < 1 {
fmt.Fprintln(f.IO.StdErr, "invalid job id:", args[0])
return cmdutils.SilentError
jobID, err := ciutils.GetJobId(&ciutils.JobInputs{
JobName: jobName,
Branch: branch,
PipelineId: pipelineId,
}, &ciutils.JobOptions{
ApiClient: apiClient,
IO: f.IO,
Repo: repo,
})
if err != nil {
fmt.Fprintln(f.IO.StdErr, "invalid job ID:", args[0])
return err
}
job, err := api.RetryPipelineJob(apiClient, jobID, repo.FullName())
if err != nil {
return cmdutils.WrapError(err, fmt.Sprintf("Could not retry job with ID: %d", jobID))
}
fmt.Fprintln(f.IO.StdOut, "Retried job (id:", job.ID, "), status:", job.Status, ", ref:", job.Ref, ", weburl: ", job.WebURL, ")")
fmt.Fprintln(f.IO.StdOut, "Retried job (ID:", job.ID, "), status:", job.Status, ", ref:", job.Ref, ", weburl: ", job.WebURL, ")")
return nil
},
}
pipelineRetryCmd.Flags().StringP("branch", "b", "", "The branch to search for the job. (Default: current branch)")
pipelineRetryCmd.Flags().IntP("pipeline-id", "p", 0, "The pipeline ID to search for the job.")
return pipelineRetryCmd
}

View File

@ -4,17 +4,16 @@ import (
"net/http"
"testing"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"github.com/MakeNowJust/heredoc"
"gitlab.com/gitlab-org/cli/commands/cmdtest"
"github.com/stretchr/testify/assert"
"gitlab.com/gitlab-org/cli/commands/cmdtest"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/cli/pkg/httpmock"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"gitlab.com/gitlab-org/cli/test"
)
func runCommand(rt http.RoundTripper, cli string) (*test.CmdOut, error) {
func runCommand(rt http.RoundTripper, args string) (*test.CmdOut, error) {
ios, _, stdout, stderr := iostreams.Test()
factory := cmdtest.InitFactory(ios, rt)
@ -22,40 +21,191 @@ func runCommand(rt http.RoundTripper, cli string) (*test.CmdOut, error) {
cmd := NewCmdRetry(factory)
return cmdtest.ExecuteCommand(cmd, cli, stdout, stderr)
return cmdtest.ExecuteCommand(cmd, args, stdout, stderr)
}
func TestCiRetry(t *testing.T) {
fakeHTTP := httpmock.New()
defer fakeHTTP.Verify(t)
// test will fail with unmatched HTTP stub if this POST is not performed
fakeHTTP.RegisterResponder(http.MethodPost, "/projects/OWNER/REPO/jobs/1122/retry",
httpmock.NewStringResponse(http.StatusCreated, `
{
"id": 1123,
"status": "pending",
"stage": "build",
"name": "build-job",
"ref": "branch-name",
"tag": false,
"coverage": null,
"allow_failure": false,
"created_at": "2022-12-01T05:13:13.703Z",
"web_url": "https://gitlab.com/OWNER/REPO/-/jobs/1123"
}
`))
jobId := "1122"
output, err := runCommand(fakeHTTP, jobId)
if err != nil {
t.Errorf("error running command `ci retry %s`: %v", jobId, err)
type httpMock struct {
method string
path string
status int
body string
}
out := output.String()
tests := []struct {
name string
args string
httpMocks []httpMock
expectedError string
expectedStderr string
expectedOut string
}{
{
name: "when retry with job-id",
args: "1122",
expectedOut: "Retried job (ID: 1123 ), status: pending , ref: branch-name , weburl: https://gitlab.com/OWNER/REPO/-/jobs/1123 )\n",
httpMocks: []httpMock{
{
http.MethodPost,
"/api/v4/projects/OWNER/REPO/jobs/1122/retry",
http.StatusCreated,
`{
"id": 1123,
"status": "pending",
"stage": "build",
"name": "build-job",
"ref": "branch-name",
"tag": false,
"coverage": null,
"allow_failure": false,
"created_at": "2022-12-01T05:13:13.703Z",
"web_url": "https://gitlab.com/OWNER/REPO/-/jobs/1123"
}`,
},
},
},
{
name: "when retry with job-id throws error",
args: "1122",
expectedError: "POST https://gitlab.com/api/v4/projects/OWNER/REPO/jobs/1122/retry: 403 ",
expectedOut: "",
httpMocks: []httpMock{
{
http.MethodPost,
"/api/v4/projects/OWNER/REPO/jobs/1122/retry",
http.StatusForbidden,
`{}`,
},
},
},
{
name: "when retry with job-name",
args: "lint -b main -p 123",
expectedOut: "Retried job (ID: 1123 ), status: pending , ref: branch-name , weburl: https://gitlab.com/OWNER/REPO/-/jobs/1123 )\n",
httpMocks: []httpMock{
{
http.MethodPost,
"/api/v4/projects/OWNER/REPO/jobs/1122/retry",
http.StatusCreated,
`{
"id": 1123,
"status": "pending",
"stage": "build",
"name": "build-job",
"ref": "branch-name",
"tag": false,
"coverage": null,
"allow_failure": false,
"created_at": "2022-12-01T05:13:13.703Z",
"web_url": "https://gitlab.com/OWNER/REPO/-/jobs/1123"
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs",
http.StatusOK,
`[{
"id": 1122,
"name": "lint",
"status": "failed"
}, {
"id": 1124,
"name": "publish",
"status": "failed"
}]`,
},
},
},
{
name: "when retry with job-name throws error",
args: "lint -b main -p 123",
expectedError: "list pipeline jobs: GET https://gitlab.com/api/v4/projects/OWNER/REPO/pipelines/123/jobs: 403 ",
expectedStderr: "invalid job ID: lint\n",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs",
http.StatusForbidden,
`{}`,
},
},
},
{
name: "when retry with job-name and last pipeline",
args: "lint -b main",
expectedOut: "Retried job (ID: 1123 ), status: pending , ref: branch-name , weburl: https://gitlab.com/OWNER/REPO/-/jobs/1123 )\n",
httpMocks: []httpMock{
{
http.MethodPost,
"/api/v4/projects/OWNER/REPO/jobs/1122/retry",
http.StatusCreated,
`{
"id": 1123,
"status": "pending",
"stage": "build",
"name": "build-job",
"ref": "branch-name",
"tag": false,
"coverage": null,
"allow_failure": false,
"created_at": "2022-12-01T05:13:13.703Z",
"web_url": "https://gitlab.com/OWNER/REPO/-/jobs/1123"
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/repository/commits/main",
http.StatusOK,
`{
"last_pipeline" : {
"id": 123
}
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER%2FREPO/pipelines/123/jobs",
http.StatusOK,
`[{
"id": 1122,
"name": "lint",
"status": "failed"
}, {
"id": 1124,
"name": "publish",
"status": "failed"
}]`,
},
},
},
}
assert.Equal(t, heredoc.Doc(`
Retried job (id: 1123 ), status: pending , ref: branch-name , weburl: https://gitlab.com/OWNER/REPO/-/jobs/1123 )
`), out)
assert.Empty(t, output.Stderr())
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fakeHTTP := &httpmock.Mocker{
MatchURL: httpmock.PathAndQuerystring,
}
defer fakeHTTP.Verify(t)
for _, mock := range tc.httpMocks {
fakeHTTP.RegisterResponder(mock.method, mock.path, httpmock.NewStringResponse(mock.status, mock.body))
}
output, err := runCommand(fakeHTTP, tc.args)
if tc.expectedError == "" {
require.Nil(t, err)
} else {
require.NotNil(t, err)
require.Equal(t, tc.expectedError, err.Error())
}
assert.Equal(t, tc.expectedOut, output.String())
if tc.expectedStderr != "" {
assert.Equal(t, tc.expectedStderr, output.Stderr())
} else {
assert.Empty(t, output.Stderr())
}
})
}
}

View File

@ -5,7 +5,7 @@ import (
"time"
"gitlab.com/gitlab-org/cli/api"
ciTraceCmd "gitlab.com/gitlab-org/cli/commands/ci/trace"
"gitlab.com/gitlab-org/cli/commands/ci/ciutils"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/pkg/git"
"gitlab.com/gitlab-org/cli/pkg/utils"
@ -144,16 +144,13 @@ func NewCmdStatus(f *cmdutils.Factory) *cobra.Command {
isRunning = false
}
}
if retry == "View Logs" {
// ToDo: bad idea to call another sub-command. should be fixed to avoid cyclo imports
// and the a shared function placed in the ciutils sub-module
return ciTraceCmd.TraceRun(&ciTraceCmd.TraceOpts{
Branch: branch,
JobID: 0,
BaseRepo: f.BaseRepo,
HTTPClient: f.HttpClient,
IO: f.IO,
return ciutils.TraceJob(&ciutils.JobInputs{
Branch: branch,
}, &ciutils.JobOptions{
Repo: repo,
ApiClient: apiClient,
IO: f.IO,
})
}
}

View File

@ -1,174 +1,57 @@
package trace
import (
"context"
"errors"
"fmt"
"regexp"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"gitlab.com/gitlab-org/cli/internal/glrepo"
"gitlab.com/gitlab-org/cli/pkg/prompt"
"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"
"gitlab.com/gitlab-org/cli/pkg/utils"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
"github.com/xanzy/go-gitlab"
)
type TraceOpts struct {
Branch string
JobID int
BaseRepo func() (glrepo.Interface, error)
HTTPClient func() (*gitlab.Client, error)
IO *iostreams.IOStreams
}
func NewCmdTrace(f *cmdutils.Factory, runE func(traceOpts *TraceOpts) error) *cobra.Command {
opts := &TraceOpts{
IO: f.IO,
}
func NewCmdTrace(f *cmdutils.Factory) *cobra.Command {
pipelineCITraceCmd := &cobra.Command{
Use: "trace [<job-id>] [flags]",
Short: `Trace a CI/CD job log in real time`,
Example: heredoc.Doc(`
$ glab ci trace
#=> interactively select a job to trace
# Interactively select a job to trace
$ glab ci trace 224356863
#=> trace job with id 224356863
# Trace job with ID 224356863
$ glab ci trace lint
# Trace job with the name 'lint'
`),
RunE: func(cmd *cobra.Command, args []string) error {
var err error
// support `-R, --repo` override
//
// NOTE: it is important to assign the BaseRepo and HTTPClient in RunE because
// they are overridden in a PersistentRun hook (when `-R, --repo` is specified)
// which runs before RunE is executed
opts.BaseRepo = f.BaseRepo
opts.HTTPClient = f.HttpClient
repo, err := f.BaseRepo()
if err != nil {
return err
}
apiClient, err := f.HttpClient()
if err != nil {
return err
}
jobName := ""
if len(args) != 0 {
opts.JobID = utils.StringToInt(args[0])
jobName = args[0]
}
if opts.Branch == "" {
opts.Branch, err = git.CurrentBranch()
if err != nil {
return err
}
}
if runE != nil {
return runE(opts)
}
return TraceRun(opts)
branch, _ := cmd.Flags().GetString("branch")
pipelineId, _ := cmd.Flags().GetInt("pipeline-id")
return ciutils.TraceJob(&ciutils.JobInputs{
JobName: jobName,
Branch: branch,
PipelineId: pipelineId,
}, &ciutils.JobOptions{
ApiClient: apiClient,
IO: f.IO,
Repo: repo,
})
},
}
pipelineCITraceCmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Check pipeline status for a branch. (Default is the current branch)")
pipelineCITraceCmd.Flags().StringP("branch", "b", "", "The branch to search for the job. (Default: current branch)")
pipelineCITraceCmd.Flags().IntP("pipeline-id", "p", 0, "The pipeline ID to search for the job.")
return pipelineCITraceCmd
}
func TraceRun(opts *TraceOpts) error {
apiClient, err := opts.HTTPClient()
if err != nil {
return err
}
repo, err := opts.BaseRepo()
if err != nil {
return err
}
if opts.JobID < 1 {
fmt.Fprintf(opts.IO.StdOut, "\nSearching for latest pipeline on %s...\n", opts.Branch)
pipeline, err := api.GetLastPipeline(apiClient, repo.FullName(), opts.Branch)
if err != nil {
return err
}
fmt.Fprintf(opts.IO.StdOut, "Getting jobs for pipeline %d...\n\n", pipeline.ID)
jobs, err := api.GetPipelineJobs(apiClient, pipeline.ID, repo.FullName())
if err != nil {
return err
}
var jobOptions []string
var selectedJob string
for _, job := range jobs {
jobOptions = append(jobOptions, fmt.Sprintf("%s (%d) - %s", job.Name, job.ID, job.Status))
}
promptOpts := &survey.Select{
Message: "Select pipeline job to trace:",
Options: jobOptions,
}
err = prompt.AskOne(promptOpts, &selectedJob)
if err != nil {
if errors.Is(err, terminal.InterruptErr) {
return nil
}
return err
}
if selectedJob != "" {
re := regexp.MustCompile(`(?s)\((.*)\)`)
m := re.FindAllStringSubmatch(selectedJob, -1)
opts.JobID = utils.StringToInt(m[0][1])
} else if len(jobs) > 0 {
opts.JobID = jobs[0].ID
} else {
// use commit statuses to show external jobs
cs, err := api.GetCommitStatuses(apiClient, repo.FullName(), pipeline.SHA)
if err != nil {
return nil
}
c := opts.IO.Color()
fmt.Fprint(opts.IO.StdOut, "Getting external jobs...")
for _, status := range cs {
var s string
switch status.Status {
case "success":
s = c.Green(status.Status)
case "error":
s = c.Red(status.Status)
default:
s = c.Gray(status.Status)
}
fmt.Fprintf(opts.IO.StdOut, "(%s) %s\nURL: %s\n\n", s, c.Bold(status.Name), c.Gray(status.TargetURL))
}
return nil
}
}
job, err := api.GetPipelineJob(apiClient, opts.JobID, repo.FullName())
if err != nil {
return err
}
fmt.Fprintln(opts.IO.StdOut)
err = ciutils.RunTrace(context.Background(), apiClient, opts.IO.StdOut, repo.FullName(), job, job.Name)
if err != nil {
return err
}
return nil
}

View File

@ -1,174 +0,0 @@
package trace
import (
"bytes"
"testing"
"gitlab.com/gitlab-org/cli/test"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"gitlab.com/gitlab-org/cli/pkg/prompt"
"github.com/spf13/cobra"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/internal/config"
"gitlab.com/gitlab-org/cli/commands/cmdtest"
)
var (
stubFactory *cmdutils.Factory
cmd *cobra.Command
stdout *bytes.Buffer
)
func TestMain(m *testing.M) {
cmdtest.InitTest(m, "ci_trace_test")
}
func TestNewCmdTrace_Integration(t *testing.T) {
glTestHost := test.GetHostOrSkip(t)
defer config.StubConfig(`---
git_protocol: https
hosts:
gitlab.com:
username: root
`, "")()
var io *iostreams.IOStreams
io, _, stdout, _ = iostreams.Test()
stubFactory, _ = cmdtest.StubFactoryWithConfig(glTestHost + "/cli-automated-testing/test.git")
stubFactory.IO = io
stubFactory.IO.IsaTTY = true
stubFactory.IO.IsErrTTY = true
tests := []struct {
name string
args string
wantOpts *TraceOpts
}{
{
name: "Has no arg",
args: ``,
wantOpts: &TraceOpts{
Branch: "master",
JobID: 0,
},
},
{
name: "Has arg with job-id",
args: `3509632552`,
wantOpts: &TraceOpts{
Branch: "master",
JobID: 3509632552,
},
},
{
name: "On a specified repo with job ID",
args: "3509632552 -X cli-automated-testing/test",
wantOpts: &TraceOpts{
Branch: "master",
JobID: 3509632552,
},
},
}
var actualOpts *TraceOpts
cmd = NewCmdTrace(stubFactory, func(opts *TraceOpts) error {
actualOpts = opts
return nil
})
cmd.Flags().StringP("repo", "X", "", "")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.wantOpts.IO = stubFactory.IO
argv, err := shlex.Split(tt.args)
if err != nil {
t.Fatal(err)
}
cmd.SetArgs(argv)
_, err = cmd.ExecuteC()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, tt.wantOpts.JobID, actualOpts.JobID)
assert.Equal(t, tt.wantOpts.Branch, actualOpts.Branch)
assert.Equal(t, tt.wantOpts.Branch, actualOpts.Branch)
assert.Equal(t, tt.wantOpts.IO, actualOpts.IO)
})
}
}
func TestTraceRun(t *testing.T) {
glTestHost := test.GetHostOrSkip(t)
var io *iostreams.IOStreams
io, _, stdout, _ = iostreams.Test()
stubFactory = cmdtest.StubFactory(glTestHost + "/cli-automated-testing/test.git")
stubFactory.IO = io
stubFactory.IO.IsaTTY = true
stubFactory.IO.IsErrTTY = true
tests := []struct {
desc string
args string
assertContains func(t *testing.T, out string)
}{
{
desc: "Has no arg",
args: ``,
assertContains: func(t *testing.T, out string) {
assert.Contains(t, out, "Getting job trace...")
assert.Contains(t, out, "Showing logs for ")
assert.Contains(t, out, `Preparing the "docker+machine"`)
assert.Contains(t, out, `$ echo "This is a after script section"`)
assert.Contains(t, out, "Job succeeded")
},
},
{
desc: "Has arg with job-id",
args: `3509632552`,
assertContains: func(t *testing.T, out string) {
assert.Contains(t, out, "Getting job trace...\n")
},
},
{
desc: "On a specified repo with job ID",
args: "3509632552 -X cli-automated-testing/test",
assertContains: func(t *testing.T, out string) {
assert.Contains(t, out, "Getting job trace...\n")
},
},
}
cmd = NewCmdTrace(stubFactory, nil)
cmd.Flags().StringP("repo", "X", "", "")
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
if tt.args == "" {
as, teardown := prompt.InitAskStubber()
defer teardown()
as.StubOne("cleanup4 (3509632552) - success")
}
argv, err := shlex.Split(tt.args)
if err != nil {
t.Fatal(err)
}
cmd.SetArgs(argv)
_, err = cmd.ExecuteC()
if err != nil {
t.Fatal(err)
}
tt.assertContains(t, stdout.String())
})
}
}

View File

@ -0,0 +1,213 @@
package trace
import (
"net/http"
"testing"
"gitlab.com/gitlab-org/cli/commands/cmdtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/cli/pkg/httpmock"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"gitlab.com/gitlab-org/cli/test"
)
func runCommand(rt http.RoundTripper, args string) (*test.CmdOut, error) {
ios, _, stdout, stderr := iostreams.Test()
factory := cmdtest.InitFactory(ios, rt)
_, _ = factory.HttpClient()
cmd := NewCmdTrace(factory)
return cmdtest.ExecuteCommand(cmd, args, stdout, stderr)
}
func TestCiTrace(t *testing.T) {
type httpMock struct {
method string
path string
status int
body string
}
tests := []struct {
name string
args string
httpMocks []httpMock
expectedOut string
expectedError string
}{
{
name: "when trace for job-id is requested",
args: "1122",
expectedOut: "\nGetting job trace...\nShowing logs for lint job #1122\nLorem ipsum",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122/trace",
http.StatusOK,
`Lorem ipsum`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122",
http.StatusOK,
`{
"id": 1122,
"name": "lint",
"status": "success"
}`,
},
},
},
{
name: "when trace for job-id is requested and getTrace throws error",
args: "1122",
expectedError: "failed to find job: GET https://gitlab.com/api/v4/projects/OWNER/REPO/jobs/1122/trace: 403 ",
expectedOut: "\nGetting job trace...\n",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122",
http.StatusOK,
`{
"id": 1122,
"name": "lint",
"status": "success"
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122/trace",
http.StatusForbidden,
`{}`,
},
},
},
{
name: "when trace for job-id is requested and getJob throws error",
args: "1122",
expectedError: "failed to find job: GET https://gitlab.com/api/v4/projects/OWNER/REPO/jobs/1122: 403 ",
expectedOut: "\nGetting job trace...\n",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122",
http.StatusForbidden,
`{}`,
},
},
},
{
name: "when trace for job-name is requested",
args: "lint -b main -p 123",
expectedOut: "\nGetting job trace...\n",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122/trace",
http.StatusOK,
`Lorem ipsum`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122",
http.StatusOK,
`{
"id": 1122,
"name": "lint",
"status": "success"
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/pipelines/123/jobs",
http.StatusOK,
`[{
"id": 1122,
"name": "lint",
"status": "failed"
}, {
"id": 1124,
"name": "publish",
"status": "failed"
}]`,
},
},
},
{
name: "when trace for job-name and last pipeline is requested",
args: "lint -b main",
expectedOut: "\nGetting job trace...\n",
httpMocks: []httpMock{
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122/trace",
http.StatusOK,
`Lorem ipsum`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/jobs/1122",
http.StatusOK,
`{
"id": 1122,
"name": "lint",
"status": "success"
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/repository/commits/main",
http.StatusOK,
`{
"last_pipeline" : {
"id": 123
}
}`,
},
{
http.MethodGet,
"/api/v4/projects/OWNER/REPO/pipelines/123/jobs",
http.StatusOK,
`[{
"id": 1122,
"name": "lint",
"status": "failed"
}, {
"id": 1124,
"name": "publish",
"status": "failed"
}]`,
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fakeHTTP := &httpmock.Mocker{
MatchURL: httpmock.PathAndQuerystring,
}
defer fakeHTTP.Verify(t)
for _, mock := range tc.httpMocks {
fakeHTTP.RegisterResponder(mock.method, mock.path, httpmock.NewStringResponse(mock.status, mock.body))
}
output, err := runCommand(fakeHTTP, tc.args)
if tc.expectedError == "" {
require.Nil(t, err)
} else {
require.NotNil(t, err)
require.Equal(t, tc.expectedError, err.Error())
}
assert.Equal(t, tc.expectedOut, output.String())
assert.Empty(t, output.Stderr())
})
}
}

View File

@ -36,7 +36,7 @@ func NewCmdContributors(f *cmdutils.Factory) *cobra.Command {
$ glab repo contributors
$ glab repo contributors -R gitlab-com/www-gitlab-com
#=> Supports repo override
# Supports repo override
`),
Args: cobra.ExactArgs(0),
Aliases: []string{"users"},

View File

@ -39,7 +39,7 @@ glab alias set <alias name> '<command>' [flags]
```plaintext
$ glab alias set mrv 'mr view'
$ glab mrv -w 123
#=> glab mr view -w 123
# glab mr view -w 123
$ glab alias set createissue 'glab create issue --title "$1"'
$ glab createissue "My Issue" --description "Something is broken."
@ -47,7 +47,7 @@ $ glab createissue "My Issue" --description "Something is broken."
$ glab alias set --shell igrep 'glab issue list --assignee="$1" | grep $2'
$ glab igrep user foo
#=> glab issue list --assignee="user" | grep "foo"
# glab issue list --assignee="user" | grep "foo"
```

View File

@ -21,7 +21,7 @@ glab ci ci lint [flags]
```plaintext
$ glab ci lint
#=> Uses .gitlab-ci.yml in the current directory
# Uses .gitlab-ci.yml in the current directory
$ glab ci lint .gitlab-ci.yml

View File

@ -21,17 +21,21 @@ glab ci ci trace [<job-id>] [flags]
```plaintext
$ glab ci trace
#=> interactively select a job to trace
# Interactively select a job to trace
$ glab ci trace 224356863
#=> trace job with id 224356863
# Trace job with ID 224356863
$ glab ci trace lint
# Trace job with the name 'lint'
```
## Options
```plaintext
-b, --branch string Check pipeline status for a branch. (Default is the current branch)
-b, --branch string The branch to search for the job. (Default: current branch)
-p, --pipeline-id int The pipeline ID to search for the job.
```
## Options inherited from parent commands

View File

@ -21,7 +21,7 @@ glab ci lint [flags]
```plaintext
$ glab ci lint
#=> Uses .gitlab-ci.yml in the current directory
# Uses .gitlab-ci.yml in the current directory
$ glab ci lint .gitlab-ci.yml

View File

@ -20,8 +20,22 @@ glab ci retry <job-id> [flags]
## Examples
```plaintext
glab ci retry 871528
$ glab ci retry
# Interactively select a job to retry
$ glab ci retry 224356863
# Retry job with ID 224356863
$ glab ci retry lint
# Retry job with the name 'lint'
```
## Options
```plaintext
-b, --branch string The branch to search for the job. (Default: current branch)
-p, --pipeline-id int The pipeline ID to search for the job.
```
## Options inherited from parent commands

View File

@ -21,17 +21,21 @@ glab ci trace [<job-id>] [flags]
```plaintext
$ glab ci trace
#=> interactively select a job to trace
# Interactively select a job to trace
$ glab ci trace 224356863
#=> trace job with id 224356863
# Trace job with ID 224356863
$ glab ci trace lint
# Trace job with the name 'lint'
```
## Options
```plaintext
-b, --branch string Check pipeline status for a branch. (Default is the current branch)
-b, --branch string The branch to search for the job. (Default: current branch)
-p, --pipeline-id int The pipeline ID to search for the job.
```
## Options inherited from parent commands

View File

@ -29,7 +29,7 @@ users
$ glab repo contributors
$ glab repo contributors -R gitlab-com/www-gitlab-com
#=> Supports repo override
# Supports repo override
```