diff --git a/internal/commands/ci/ciutils/utils.go b/internal/commands/ci/ciutils/utils.go index 84f3a4f6a1ba00e3674bfbec806ae85a090acf61..8cca2c15270a1e98710038ffd99fbea106a42de5 100644 --- a/internal/commands/ci/ciutils/utils.go +++ b/internal/commands/ci/ciutils/utils.go @@ -28,6 +28,112 @@ func makeHyperlink(s *iostreams.IOStreams, pipeline *gitlab.PipelineInfo) string return s.Hyperlink(fmt.Sprintf("%d", pipeline.ID), pipeline.WebURL) } +// GetPipelineWithFallback gets the latest pipeline for a branch, falling back to MR head pipeline +// for merged results pipelines where the direct branch lookup may fail or returns a pipeline with no jobs. +func GetPipelineWithFallback(client *gitlab.Client, repoName, branch string, ios *iostreams.IOStreams) (*gitlab.Pipeline, error) { + // First try: Get pipeline by branch name + pipeline, _, err := client.Pipelines.GetLatestPipeline(repoName, &gitlab.GetLatestPipelineOptions{Ref: gitlab.Ptr(branch)}) + if err == nil { + // Check if the pipeline has jobs - some pipelines (e.g., external pipelines) may have no jobs + jobs, _, jobsErr := client.Jobs.ListPipelineJobs(repoName, pipeline.ID, &gitlab.ListJobsOptions{ + ListOptions: gitlab.ListOptions{PerPage: 1}, + }) + if jobsErr == nil && len(jobs) > 0 { + // Pipeline has jobs, return it + return pipeline, nil + } + // Pipeline has no jobs, try MR fallback below + } + + // Fallback: Look for MR pipeline (for merged results pipelines or when branch pipeline has no jobs) + mr, mrErr := getMRForBranch(client, repoName, branch, ios) + if mrErr != nil { + // If we had a pipeline from the branch lookup (even with no jobs), return it + if pipeline != nil { + return pipeline, nil + } + return nil, fmt.Errorf("no pipeline found for branch %s and failed to find associated merge request: %v", branch, mrErr) + } + + if mr.HeadPipeline == nil { + // If we had a pipeline from the branch lookup (even with no jobs), return it + if pipeline != nil { + return pipeline, nil + } + return nil, fmt.Errorf("no pipeline found. It might not exist yet. Check your pipeline configuration") + } + + // Get the full pipeline details using the MR's head pipeline ID + mrPipeline, _, pipelineErr := client.Pipelines.GetPipeline(repoName, mr.HeadPipeline.ID) + if pipelineErr != nil { + // If we had a pipeline from the branch lookup, return it as fallback + if pipeline != nil { + return pipeline, nil + } + return nil, pipelineErr + } + + return mrPipeline, nil +} + +// getMRForBranch finds a merge request for the given branch +func getMRForBranch(client *gitlab.Client, repoName, branch string, ios *iostreams.IOStreams) (*gitlab.MergeRequest, error) { + opts := &gitlab.ListProjectMergeRequestsOptions{ + SourceBranch: gitlab.Ptr(branch), + } + + mrs, err := api.ListMRs(client, repoName, opts) + if err != nil { + return nil, fmt.Errorf("failed to get merge requests for %q: %w", branch, err) + } + + if len(mrs) == 0 { + return nil, fmt.Errorf("no merge request available for %q", branch) + } + + var selectedMR *gitlab.BasicMergeRequest + + // If exactly one MR, use it + if len(mrs) == 1 { + selectedMR = mrs[0] + } else { + // Multiple MRs exist - need to handle selection + if ios == nil || !ios.PromptEnabled() { + // Build error message with list of possible MRs + var mrNames []string + for _, mr := range mrs { + mrNames = append(mrNames, fmt.Sprintf("!%d (%s) by @%s", mr.IID, branch, mr.Author.Username)) + } + return nil, fmt.Errorf("merge request ID number required. Possible matches:\n\n%s", strings.Join(mrNames, "\n")) + } + + // Prompt user to select + mrMap := map[string]*gitlab.BasicMergeRequest{} + var mrNames []string + for i := range mrs { + t := fmt.Sprintf("!%d (%s) by @%s", mrs[i].IID, branch, mrs[i].Author.Username) + mrMap[t] = mrs[i] + mrNames = append(mrNames, t) + } + + pickedMR := mrNames[0] + err = ios.Select(context.Background(), &pickedMR, "Multiple merge requests exist for this branch. Select one:", mrNames) + if err != nil { + return nil, fmt.Errorf("you must select a merge request: %w", err) + } + + selectedMR = mrMap[pickedMR] + } + + // Fetch the full MR to get HeadPipeline + fullMR, _, err := client.MergeRequests.GetMergeRequest(repoName, selectedMR.IID, &gitlab.GetMergeRequestsOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get merge request details: %w", err) + } + + return fullMR, nil +} + func DisplaySchedules(i *iostreams.IOStreams, s []*gitlab.PipelineSchedule, projectID string) string { if len(s) > 0 { table := tableprinter.NewTablePrinter() diff --git a/internal/commands/ci/status/status.go b/internal/commands/ci/status/status.go index 21b0df4d854e39d892ebcc68449cf06750662e80..d4d8cff25687d101da13b5ab6167337ddd531529 100644 --- a/internal/commands/ci/status/status.go +++ b/internal/commands/ci/status/status.go @@ -13,7 +13,6 @@ import ( "gitlab.com/gitlab-org/cli/internal/cmdutils" "gitlab.com/gitlab-org/cli/internal/commands/ci/ciutils" - "gitlab.com/gitlab-org/cli/internal/commands/mr/mrutils" "gitlab.com/gitlab-org/cli/internal/dbg" "gitlab.com/gitlab-org/cli/internal/mcpannotations" "gitlab.com/gitlab-org/cli/internal/utils" @@ -67,7 +66,7 @@ func NewCmdStatus(f cmdutils.Factory) *cobra.Command { dbg.Debug("Using branch:", branch) // Use fallback logic for robust pipeline lookup - runningPipeline, err := getPipelineWithFallback(client, f, repoName, branch) + runningPipeline, err := ciutils.GetPipelineWithFallback(client, repoName, branch, f.IO()) if err != nil { redCheck := c.Red("✘") fmt.Fprintf(f.IO().StdOut, "%s %v\n", redCheck, err) @@ -125,7 +124,7 @@ func NewCmdStatus(f cmdutils.Factory) *cobra.Command { if (runningPipeline.Status == "pending" || runningPipeline.Status == "running") && live { // Use fallback logic for live updates - updatedPipeline, err := getPipelineWithFallback(client, f, repoName, branch) + updatedPipeline, err := ciutils.GetPipelineWithFallback(client, repoName, branch, f.IO()) if err != nil { // Final fallback: refresh current pipeline by ID updatedPipeline, _, err = client.Pipelines.GetPipeline(repoName, runningPipeline.ID) @@ -156,7 +155,7 @@ func NewCmdStatus(f cmdutils.Factory) *cobra.Command { if err != nil { return err } - updatedPipeline, err := getPipelineWithFallback(client, f, repoName, branch) + updatedPipeline, err := ciutils.GetPipelineWithFallback(client, repoName, branch, f.IO()) if err != nil { // Fallback: refresh by pipeline ID if MR lookup fails updatedPipeline, _, err = client.Pipelines.GetPipeline(repoName, runningPipeline.ID) @@ -186,29 +185,3 @@ func NewCmdStatus(f cmdutils.Factory) *cobra.Command { return pipelineStatusCmd } - -func getPipelineWithFallback(client *gitlab.Client, f cmdutils.Factory, repoName, branch string) (*gitlab.Pipeline, error) { - // First try: Get pipeline by branch name - pipeline, _, err := client.Pipelines.GetLatestPipeline(repoName, &gitlab.GetLatestPipelineOptions{Ref: gitlab.Ptr(branch)}) - if err == nil { - return pipeline, nil - } - - // Fallback: Look for MR pipeline - mr, _, mrErr := mrutils.MRFromArgs(f, []string{}, "any") - if mr == nil || mrErr != nil { - return nil, fmt.Errorf("no pipeline found for branch %s and no associated merge request found. Branch may not have an open MR", branch) - } - - if mr.HeadPipeline == nil { - return nil, fmt.Errorf("no pipeline found. It might not exist yet. If this problem continues, check your pipeline configuration.") - } - - // Get the full pipeline details using the MR's head pipeline ID - pipeline, _, pipelineErr := client.Pipelines.GetPipeline(repoName, mr.HeadPipeline.ID) - if pipelineErr != nil { - return nil, pipelineErr - } - - return pipeline, nil -} diff --git a/internal/commands/ci/status/status_test.go b/internal/commands/ci/status/status_test.go index 536e914a7f7ef6f7e614610dcfa4eb99571148c4..c8195899d16ecac55a7150dc09e24e6f484a0c4f 100644 --- a/internal/commands/ci/status/status_test.go +++ b/internal/commands/ci/status/status_test.go @@ -13,6 +13,7 @@ import ( gitlab "gitlab.com/gitlab-org/api/client-go" gitlabtesting "gitlab.com/gitlab-org/api/client-go/testing" + "gitlab.com/gitlab-org/cli/internal/commands/ci/ciutils" "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" ) @@ -32,10 +33,51 @@ func Test_getPipelineWithFallback(t *testing.T) { tc.MockPipelines.EXPECT(). GetLatestPipeline("OWNER/REPO", &gitlab.GetLatestPipelineOptions{Ref: gitlab.Ptr("main")}). Return(&gitlab.Pipeline{ID: 1, Status: "success"}, nil, nil) + + // Mock job check to verify pipeline has jobs + tc.MockJobs.EXPECT(). + ListPipelineJobs("OWNER/REPO", int64(1), gomock.Any()). + Return([]*gitlab.Job{{ID: 1, Name: "test"}}, nil, nil) }, wantPipeline: &gitlab.Pipeline{ID: 1, Status: "success"}, wantErr: false, }, + { + name: "falls back to MR pipeline when branch pipeline has no jobs", + branch: "feature", + setupMocks: func(tc *gitlabtesting.TestClient) { + // Latest pipeline found but has no jobs (e.g., external pipeline) + tc.MockPipelines.EXPECT(). + GetLatestPipeline("OWNER/REPO", &gitlab.GetLatestPipelineOptions{Ref: gitlab.Ptr("feature")}). + Return(&gitlab.Pipeline{ID: 1, Status: "success"}, nil, nil) + + // Mock job check returns empty list + tc.MockJobs.EXPECT(). + ListPipelineJobs("OWNER/REPO", int64(1), gomock.Any()). + Return([]*gitlab.Job{}, nil, nil) + + // Find and get MR + tc.MockMergeRequests.EXPECT(). + ListProjectMergeRequests("OWNER/REPO", gomock.Any()). + Return([]*gitlab.BasicMergeRequest{{IID: 1}}, nil, nil) + + tc.MockMergeRequests.EXPECT(). + GetMergeRequest("OWNER/REPO", int64(1), gomock.Any()). + Return(&gitlab.MergeRequest{ + BasicMergeRequest: gitlab.BasicMergeRequest{IID: 1}, + HeadPipeline: &gitlab.Pipeline{ID: 2, Status: "running"}, + }, nil, nil) + + tc.MockPipelines.EXPECT(). + GetPipeline("OWNER/REPO", int64(2), gomock.Any()). + Return(&gitlab.Pipeline{ + ID: 2, + Status: "running", + }, nil, nil) + }, + wantPipeline: &gitlab.Pipeline{ID: 2, Status: "running"}, + wantErr: false, + }, { name: "falls back to MR pipeline when latest not found", branch: "feature", @@ -83,7 +125,7 @@ func Test_getPipelineWithFallback(t *testing.T) { }, wantPipeline: nil, wantErr: true, - expectedErrMsg: "no pipeline found for branch feature and no associated merge request found", + expectedErrMsg: "no pipeline found for branch feature and failed to find associated merge request", }, { name: "returns error when MR has no pipeline", @@ -115,11 +157,10 @@ func Test_getPipelineWithFallback(t *testing.T) { tc := gitlabtesting.NewTestClient(t) tt.setupMocks(tc) - // Create a test factory with test IO streams + // Create test IO streams ios, _, _, _ := cmdtest.TestIOStreams(cmdtest.WithTestIOStreamsAsTTY(false)) - factory := cmdtest.NewTestFactory(ios, cmdtest.WithGitLabClient(tc.Client)) - pipeline, err := getPipelineWithFallback(tc.Client, factory, "OWNER/REPO", tt.branch) + pipeline, err := ciutils.GetPipelineWithFallback(tc.Client, "OWNER/REPO", tt.branch, ios) if tt.wantErr { assert.Error(t, err) @@ -149,6 +190,11 @@ func TestCiStatusCommand_NoPrompt(t *testing.T) { GetLatestPipeline("OWNER/REPO", &gitlab.GetLatestPipelineOptions{Ref: gitlab.Ptr("main")}). Return(&gitlab.Pipeline{ID: 1, Status: "success"}, nil, nil), + // Mock job check in GetPipelineWithFallback + tc.MockJobs.EXPECT(). + ListPipelineJobs("OWNER/REPO", int64(1), gomock.Any()). + Return([]*gitlab.Job{{ID: 1, Name: "test"}}, nil, nil), + // Mock jobs for the pipeline - need to handle pagination tc.MockJobs.EXPECT(). ListPipelineJobs("OWNER/REPO", int64(1), gomock.Any(), gomock.Any()). @@ -183,6 +229,11 @@ func TestCiStatusCommand_WithPromptsEnabled_FinishedPipeline(t *testing.T) { GetLatestPipeline("OWNER/REPO", &gitlab.GetLatestPipelineOptions{Ref: gitlab.Ptr("main")}). Return(&gitlab.Pipeline{ID: 1, Status: "success"}, nil, nil), + // Mock job check in GetPipelineWithFallback + tc.MockJobs.EXPECT(). + ListPipelineJobs("OWNER/REPO", int64(1), gomock.Any()). + Return([]*gitlab.Job{{ID: 1, Name: "test"}}, nil, nil), + // Mock jobs for the pipeline - need to handle pagination tc.MockJobs.EXPECT(). ListPipelineJobs("OWNER/REPO", int64(1), gomock.Any(), gomock.Any()). diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index f440d659dc7b1ee67b895f08774afc6080e45517..836b5c2517594b313c0cba501ddfacc5f40a748e 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -210,19 +210,22 @@ func (o *options) run() error { return err } } else { - commit, _, err = client.Commits.GetCommit(projectID, o.refName, nil) + // Get pipeline by branch reference (not by commit's LastPipeline) + pipeline, err := ciutils.GetPipelineWithFallback(client, projectID, o.refName, o.io) if err != nil { return err } - if commit.LastPipeline == nil { - return fmt.Errorf("Can't find pipeline for commit: %s", commit.ID) - } + pipelineID = pipeline.ID + webURL = pipeline.WebURL + pipelineCreatedAt = *pipeline.CreatedAt + commitSHA = pipeline.SHA - pipelineID = commit.LastPipeline.ID - webURL = commit.LastPipeline.WebURL - pipelineCreatedAt = *commit.LastPipeline.CreatedAt - commitSHA = commit.ID + // Get commit details for display purposes + commit, _, err = client.Commits.GetCommit(projectID, commitSHA, nil) + if err != nil { + return err + } } if o.openInBrowser { // open in browser if --web flag is specified diff --git a/internal/commands/ci/view/view_test.go b/internal/commands/ci/view/view_test.go index 8644c83314b0d8f54ccc0da904c757f380ea110e..0ccb6ac4e304000f0e6181677c9aa3259242805d 100644 --- a/internal/commands/ci/view/view_test.go +++ b/internal/commands/ci/view/view_test.go @@ -1254,13 +1254,26 @@ func TestCIView(t *testing.T) { httpMocks: []httpMock{ { http.MethodGet, - "https://gitlab.com/api/v4/projects/OWNER%2FREPO/repository/commits/foo", + "https://gitlab.com/api/v4/projects/OWNER%2FREPO/pipelines/latest?ref=foo", http.StatusOK, `{ - "id": "6104942438c14ec7bd21c6cd5bd995272b3faff6", + "id": 8, + "ref": "foo", + "sha": "2dc6aa325a317eda67812f05600bdf0fcdc70ab0", + "status": "created", + "web_url": "https://gitlab.com/OWNER/REPO/-/pipelines/225", + "created_at": "2025-10-28T16:52:39.000+01:00" + }`, + }, + { + http.MethodGet, + "https://gitlab.com/api/v4/projects/OWNER%2FREPO/repository/commits/2dc6aa325a317eda67812f05600bdf0fcdc70ab0", + http.StatusOK, + `{ + "id": "2dc6aa325a317eda67812f05600bdf0fcdc70ab0", "last_pipeline": { "id": 8, - "ref": "main", + "ref": "foo", "sha": "2dc6aa325a317eda67812f05600bdf0fcdc70ab0", "status": "created", "web_url": "https://gitlab.com/OWNER/REPO/-/pipelines/225",