Skip to content

Commit 3d6e090

Browse files
authored
Merge pull request #474 from depot/rob/dep-4015-cli-ci-run-job-flag-regressions
fix: match --job flag against reusable workflow job keys
2 parents 98faeb8 + d93cc99 commit 3d6e090

4 files changed

Lines changed: 400 additions & 24 deletions

File tree

pkg/cmd/ci/logs.go

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -217,21 +217,20 @@ func findLogsJob(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFi
217217
return nil, "", fmt.Errorf("run %s has no jobs", resp.RunId)
218218
}
219219

220-
// Match by job key (--job flag): exact match on full key or short name.
220+
// Match by job key (--job flag): exact > suffix > segment, best tier wins.
221221
if jobKey != "" {
222-
var exact, short []jobCandidate
222+
bestTier := 0
223+
tierMatches := map[int][]jobCandidate{}
223224
for _, c := range candidates {
224-
if c.job.JobKey == jobKey {
225-
exact = append(exact, c)
226-
} else if jobKeyShort(c.job.JobKey) == jobKey {
227-
short = append(short, c)
225+
if tier := matchJobKey(c.job.JobKey, jobKey); tier > 0 {
226+
tierMatches[tier] = append(tierMatches[tier], c)
227+
if bestTier == 0 || tier < bestTier {
228+
bestTier = tier
229+
}
228230
}
229231
}
230232

231-
matches := exact
232-
if len(matches) == 0 {
233-
matches = short
234-
}
233+
matches := tierMatches[bestTier]
235234

236235
displayNames := jobDisplayNames(candidates)
237236
switch len(matches) {
@@ -244,12 +243,23 @@ func findLogsJob(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFi
244243
case 1:
245244
return matches[0].job, matches[0].workflowPath, nil
246245
default:
247-
// Same job key in multiple workflows — need --workflow.
248-
var paths []string
246+
// Check if ambiguity is cross-workflow or within a single workflow.
247+
uniquePaths := map[string]struct{}{}
249248
for _, m := range matches {
250-
paths = append(paths, m.workflowPath)
249+
uniquePaths[m.workflowPath] = struct{}{}
250+
}
251+
if len(uniquePaths) > 1 {
252+
paths := make([]string, 0, len(uniquePaths))
253+
for path := range uniquePaths {
254+
paths = append(paths, path)
255+
}
256+
return nil, "", fmt.Errorf("job %q exists in multiple workflows, specify one with --workflow: %s", jobKey, strings.Join(paths, ", "))
257+
}
258+
keys := make([]string, len(matches))
259+
for i, m := range matches {
260+
keys[i] = displayNames[m.job.JobKey]
251261
}
252-
return nil, "", fmt.Errorf("job %q exists in multiple workflows, specify one with --workflow: %s", jobKey, strings.Join(paths, ", "))
262+
return nil, "", fmt.Errorf("job %q matches multiple jobs, use a more specific --job value: %s", jobKey, strings.Join(keys, ", "))
253263
}
254264
}
255265

pkg/cmd/ci/logs_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ci
22

33
import (
4+
"strings"
45
"testing"
56

67
civ1 "github.com/depot/cli/pkg/proto/depot/ci/v1"
@@ -314,6 +315,107 @@ func TestJobDisplayNames_NoColon(t *testing.T) {
314315
}
315316
}
316317

318+
func TestFindLogsJob_SegmentMatch_ReusableWorkflow(t *testing.T) {
319+
// Reusable workflow: user passes "bazel" but key is "pr.yaml:bazel:build"
320+
resp := &civ1.GetRunStatusResponse{
321+
RunId: "run-1",
322+
Workflows: []*civ1.WorkflowStatus{
323+
{
324+
WorkflowPath: ".depot/workflows/pr.yaml",
325+
Jobs: []*civ1.JobStatus{
326+
{JobId: "job-1", JobKey: "pr.yaml:detect_changes:build", Status: "finished"},
327+
{JobId: "job-2", JobKey: "pr.yaml:bazel:build", Status: "running"},
328+
{JobId: "job-3", JobKey: "pr.yaml:test_dashboard:test", Status: "queued"},
329+
},
330+
},
331+
},
332+
}
333+
334+
job, _, err := findLogsJob(resp, "run-1", "bazel", "")
335+
if err != nil {
336+
t.Fatal(err)
337+
}
338+
if job.JobId != "job-2" {
339+
t.Fatalf("expected job ID %q, got %q", "job-2", job.JobId)
340+
}
341+
}
342+
343+
func TestFindLogsJob_SegmentMatch_InlineWorkflow(t *testing.T) {
344+
// CLI-triggered run: key is "_inline_0.yaml:bazel:build"
345+
resp := &civ1.GetRunStatusResponse{
346+
RunId: "run-1",
347+
Workflows: []*civ1.WorkflowStatus{
348+
{
349+
WorkflowPath: "",
350+
Jobs: []*civ1.JobStatus{
351+
{JobId: "job-1", JobKey: "_inline_0.yaml:bazel:build", Status: "running"},
352+
},
353+
},
354+
},
355+
}
356+
357+
job, _, err := findLogsJob(resp, "run-1", "bazel", "")
358+
if err != nil {
359+
t.Fatal(err)
360+
}
361+
if job.JobId != "job-1" {
362+
t.Fatalf("expected job ID %q, got %q", "job-1", job.JobId)
363+
}
364+
}
365+
366+
func TestFindLogsJob_SegmentMatch_AmbiguousSameWorkflow(t *testing.T) {
367+
// Parent job expands to multiple nested jobs within the same workflow.
368+
// Error should suggest a more specific --job, not --workflow.
369+
resp := &civ1.GetRunStatusResponse{
370+
RunId: "run-1",
371+
Workflows: []*civ1.WorkflowStatus{
372+
{
373+
WorkflowPath: ".depot/workflows/ci.yml",
374+
Jobs: []*civ1.JobStatus{
375+
{JobId: "job-1", JobKey: "ci.yml:backend:build", Status: "running"},
376+
{JobId: "job-2", JobKey: "ci.yml:backend:test", Status: "running"},
377+
},
378+
},
379+
},
380+
}
381+
382+
_, _, err := findLogsJob(resp, "run-1", "backend", "")
383+
if err == nil {
384+
t.Fatal("expected error for ambiguous segment match")
385+
}
386+
if strings.Contains(err.Error(), "--workflow") {
387+
t.Fatalf("error should suggest --job, not --workflow: %v", err)
388+
}
389+
if !strings.Contains(err.Error(), "more specific --job") {
390+
t.Fatalf("expected 'more specific --job' hint, got: %v", err)
391+
}
392+
}
393+
394+
func TestFindLogsJob_ShortPreferredOverSegment(t *testing.T) {
395+
// Short name match should take priority over segment match
396+
resp := &civ1.GetRunStatusResponse{
397+
RunId: "run-1",
398+
Workflows: []*civ1.WorkflowStatus{
399+
{
400+
WorkflowPath: ".depot/workflows/ci.yml",
401+
Jobs: []*civ1.JobStatus{
402+
{JobId: "job-segment", JobKey: "ci.yml:build:test", Status: "running"},
403+
{JobId: "job-short", JobKey: "ci.yml:build", Status: "running"},
404+
},
405+
},
406+
},
407+
}
408+
409+
// "build" short-matches "ci.yml:build" and segment-matches "ci.yml:build:test"
410+
job, _, err := findLogsJob(resp, "run-1", "build", "")
411+
if err != nil {
412+
t.Fatal(err)
413+
}
414+
if job.JobId != "job-short" {
415+
t.Fatalf("expected short match (job-short), got %q", job.JobId)
416+
}
417+
}
418+
317419
func TestJobKeyShort_MultipleColons(t *testing.T) {
318420
got := jobKeyShort("ci.yml:foo:bar")
319421
if got != "foo:bar" {

pkg/cmd/ci/ssh.go

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -208,22 +208,31 @@ func findJob(resp *civ1.GetRunStatusResponse, jobKey, originalID string) (*civ1.
208208
return nil, &retryableJobError{msg: fmt.Sprintf("run %s has no jobs yet", resp.RunId)}
209209
}
210210

211-
// Match by job key (--job flag).
211+
// Match by job key (--job flag), preferring exact > suffix > segment matches.
212212
if jobKey != "" {
213+
bestTier := 0
214+
tierMatches := map[int][]*civ1.JobStatus{}
213215
for _, j := range allJobs {
214-
if j.JobKey == jobKey {
215-
return j, nil
216+
if tier := matchJobKey(j.JobKey, jobKey); tier > 0 {
217+
tierMatches[tier] = append(tierMatches[tier], j)
218+
if bestTier == 0 || tier < bestTier {
219+
bestTier = tier
220+
}
216221
}
217222
}
218-
// Inline workflows get prefixed keys (e.g. "_inline_0.yaml:lint_typecheck"),
219-
// so fall back to a suffix match when the user passes just the job name.
220-
for _, j := range allJobs {
221-
if strings.HasSuffix(j.JobKey, ":"+jobKey) {
222-
return j, nil
223+
matches := tierMatches[bestTier]
224+
if len(matches) == 0 {
225+
// Job might not exist yet if workflows are still being expanded.
226+
return nil, &retryableJobError{msg: fmt.Sprintf("job %q not found yet", jobKey)}
227+
}
228+
if len(matches) > 1 {
229+
keys := make([]string, len(matches))
230+
for i, j := range matches {
231+
keys[i] = j.JobKey
223232
}
233+
fmt.Fprintf(os.Stderr, "Note: %q matched multiple jobs (%s), using %s\n", jobKey, strings.Join(keys, ", "), matches[0].JobKey)
224234
}
225-
// Job might not exist yet if workflows are still being expanded.
226-
return nil, &retryableJobError{msg: fmt.Sprintf("job %q not found yet", jobKey)}
235+
return matches[0], nil
227236
}
228237

229238
// Match by job ID (user passed a job ID as the positional arg).
@@ -270,6 +279,28 @@ func workflowErrorMessage(resp *civ1.GetRunStatusResponse) string {
270279
return ""
271280
}
272281

282+
// matchJobKey returns the match tier of userKey against the full jobKey.
283+
// Returns 0 for no match. Lower non-zero values are higher priority:
284+
//
285+
// 1 = exact match ("build" == "build")
286+
// 2 = suffix match ("_inline_0.yaml:build" ends with ":build")
287+
// 3 = segment match ("pr.yaml:bazel:build" contains ":bazel:" as a segment)
288+
func matchJobKey(jobKey, userKey string) int {
289+
if jobKey == userKey {
290+
return 1
291+
}
292+
if strings.HasSuffix(jobKey, ":"+userKey) {
293+
return 2
294+
}
295+
// Check if userKey appears as a complete colon-delimited segment anywhere
296+
// in the key. Handles reusable workflow keys like "pr.yaml:bazel:build"
297+
// where "bazel" is an intermediate segment.
298+
if strings.Contains(":"+jobKey+":", ":"+userKey+":") {
299+
return 3
300+
}
301+
return 0
302+
}
303+
273304
func printSSHInfo(sandboxID, sessionID, output string) error {
274305
if output == "json" {
275306
enc := json.NewEncoder(os.Stdout)

0 commit comments

Comments
 (0)