Skip to content

feat: support workflow ID in depot ci logs#461

Open
robstolarz wants to merge 3 commits intomainfrom
rob/dep-3881-depot-ci-logs-should-take-a-workflow
Open

feat: support workflow ID in depot ci logs#461
robstolarz wants to merge 3 commits intomainfrom
rob/dep-3881-depot-ci-logs-should-take-a-workflow

Conversation

@robstolarz
Copy link
Copy Markdown
Contributor

@robstolarz robstolarz commented Mar 20, 2026

Summary

  • depot ci logs <workflow-id> now resolves a workflow ID (from the /workflows/<id> URL) by searching recent runs for a matching workflow
  • Auto-filters to the matched workflow's jobs so multi-workflow runs resolve correctly
  • Includes workflow resolution errors in the combined error output when all paths fail

Closes DEP-3881

Test plan

  • Unit tests for workflow ID auto-filtering in findLogsJob and resolveAttempt
  • Existing tests pass
  • Live tested with depot ci logs n1bf3p63c1 --org cl0wyyk6k39487ebgraxasinja

🤖 Generated with Claude Code


Note

Medium Risk
Adds a new resolution path that lists recent runs and fetches run status to match workflow IDs, increasing API call volume/latency and introducing new error-handling paths for log retrieval.

Overview
depot ci logs now accepts a workflow ID as the positional argument and will resolve it to the latest job attempt by searching recent runs and selecting jobs within the matched workflow.

The command also auto-filters jobs when the provided ID matches a workflow in a run status response, refactors log printing into printLogs, and improves failure output to surface workflow-resolution errors alongside run/attempt lookup failures.

Written by Cursor Bugbot for commit a69a1ca. This will update automatically on new commits. Configure here.

Resolves DEP-3881. The positional arg now accepts a workflow ID (e.g.
from the /workflows/<id> URL). When the ID doesn't match a run or job,
the CLI lists recent runs and searches for a matching workflow, then
auto-filters to that workflow's jobs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@linear
Copy link
Copy Markdown

linear bot commented Mar 20, 2026

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Workflow search adds major latency to attempt ID path
    • Reordered fallback logic to try the attempt ID path (single API call) before the expensive workflow search (up to 51 API calls), reducing latency from ~53 to 2 API calls for attempt ID lookups.

Create PR

Or push these changes by commenting:

@cursor push d143c7461f
Preview (d143c7461f)
diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go
--- a/pkg/cmd/ci/logs.go
+++ b/pkg/cmd/ci/logs.go
@@ -88,6 +88,20 @@
 				return printLogs(ctx, tokenVal, orgID, attemptID)
 			}
 
+			// If --job or --workflow flags are NOT specified, try attempt ID
+			// early (single API call) before the expensive workflow search.
+			var attemptErr error
+			if job == "" && workflow == "" {
+				lines, err := api.CIGetJobAttemptLogs(ctx, tokenVal, orgID, id)
+				if err == nil {
+					for _, line := range lines {
+						fmt.Println(line.Body)
+					}
+					return nil
+				}
+				attemptErr = err
+			}
+
 			// Try resolving as a workflow ID by searching recent runs.
 			resp, wfPath, wfErr := resolveWorkflow(ctx, tokenVal, orgID, id)
 			if wfErr == nil {
@@ -104,24 +118,13 @@
 				return printLogs(ctx, tokenVal, orgID, attemptID)
 			}
 
-			// Fall back to treating the ID as an attempt ID directly.
-			// Don't fall back if --job or --workflow were specified — those
-			// only make sense for run-level resolution.
+			// All paths failed — show errors so the user can
+			// distinguish "bad ID" from "auth/network failure".
 			if job != "" || workflow != "" {
 				return fmt.Errorf("failed to look up run: %w\n  as workflow: %v", runErr, wfErr)
 			}
 
-			lines, err := api.CIGetJobAttemptLogs(ctx, tokenVal, orgID, id)
-			if err != nil {
-				// All paths failed — show errors so the user can
-				// distinguish "bad ID" from "auth/network failure".
-				return fmt.Errorf("could not resolve %q as a run, job, workflow, or attempt ID:\n  as run: %v\n  as workflow: %v\n  as attempt: %v", id, runErr, wfErr, err)
-			}
-
-			for _, line := range lines {
-				fmt.Println(line.Body)
-			}
-			return nil
+			return fmt.Errorf("could not resolve %q as a run, job, workflow, or attempt ID:\n  as run: %v\n  as workflow: %v\n  as attempt: %v", id, runErr, wfErr, attemptErr)
 		},
 	}

Move the workflow ID resolution (which lists up to 50 runs) after the
attempt ID fallback so that direct attempt IDs resolve in ~2 calls
instead of ~51.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Workflow auto-filter fails for inline workflows with empty path
    • Added a separate workflowIDFilter parameter to findLogsJob and resolveAttempt that filters by workflow ID when the path is empty, allowing inline workflows to be properly filtered.

Create PR

Or push these changes by commenting:

@cursor push b67b3dfbe2
Preview (b67b3dfbe2)
diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go
--- a/pkg/cmd/ci/logs.go
+++ b/pkg/cmd/ci/logs.go
@@ -71,16 +71,18 @@
 				// If the positional arg matches a workflow ID in the response,
 				// auto-filter to that workflow's jobs.
 				wfFilter := workflow
+				wfIDFilter := ""
 				if wfFilter == "" {
 					for _, wf := range resp.Workflows {
 						if wf.WorkflowId == id {
 							wfFilter = wf.WorkflowPath
+							wfIDFilter = id // Use workflow ID for inline workflows with empty path
 							break
 						}
 					}
 				}
 
-				attemptID, err := resolveAttempt(resp, id, job, wfFilter)
+				attemptID, err := resolveAttempt(resp, id, job, wfFilter, wfIDFilter)
 				if err != nil {
 					return err
 				}
@@ -107,11 +109,13 @@
 			resp, wfPath, wfErr := resolveWorkflow(ctx, tokenVal, orgID, id)
 			if wfErr == nil {
 				wfFilter := workflow
+				wfIDFilter := ""
 				if wfFilter == "" {
 					wfFilter = wfPath
+					wfIDFilter = id // Use workflow ID for inline workflows with empty path
 				}
 
-				attemptID, err := resolveAttempt(resp, id, job, wfFilter)
+				attemptID, err := resolveAttempt(resp, id, job, wfFilter, wfIDFilter)
 				if err != nil {
 					return err
 				}
@@ -171,8 +175,8 @@
 // resolveAttempt finds the target attempt from a run status response.
 // It selects a job (by --job flag, by job ID match, or auto-select), then
 // picks the latest attempt and prints informational messages about what was chosen.
-func resolveAttempt(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter string) (string, error) {
-	targetJob, workflowPath, err := findLogsJob(resp, originalID, jobKey, workflowFilter)
+func resolveAttempt(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter, workflowIDFilter string) (string, error) {
+	targetJob, workflowPath, err := findLogsJob(resp, originalID, jobKey, workflowFilter, workflowIDFilter)
 	if err != nil {
 		return "", err
 	}
@@ -217,12 +221,16 @@
 
 // findLogsJob locates the target job in the run status response.
 // Returns the job and the workflow path it belongs to.
-func findLogsJob(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter string) (*civ1.JobStatus, string, error) {
+func findLogsJob(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter, workflowIDFilter string) (*civ1.JobStatus, string, error) {
 	var candidates []jobCandidate
 	for _, wf := range resp.Workflows {
 		if workflowFilter != "" && !workflowPathMatches(wf.WorkflowPath, workflowFilter) {
 			continue
 		}
+		// Filter by workflow ID for inline workflows (which have empty paths)
+		if workflowIDFilter != "" && wf.WorkflowId != workflowIDFilter {
+			continue
+		}
 		for _, j := range wf.Jobs {
 			candidates = append(candidates, jobCandidate{
 				job:          j,

diff --git a/pkg/cmd/ci/logs_test.go b/pkg/cmd/ci/logs_test.go
--- a/pkg/cmd/ci/logs_test.go
+++ b/pkg/cmd/ci/logs_test.go
@@ -19,7 +19,7 @@
 		},
 	}
 
-	job, path, err := findLogsJob(resp, "run-1", "", "")
+	job, path, err := findLogsJob(resp, "run-1", "", "", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -45,7 +45,7 @@
 		},
 	}
 
-	_, _, err := findLogsJob(resp, "run-1", "", "")
+	_, _, err := findLogsJob(resp, "run-1", "", "", "")
 	if err == nil {
 		t.Fatal("expected error for multiple jobs without --job flag")
 	}
@@ -65,7 +65,7 @@
 		},
 	}
 
-	job, _, err := findLogsJob(resp, "run-1", "test", "")
+	job, _, err := findLogsJob(resp, "run-1", "test", "", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -88,7 +88,7 @@
 		},
 	}
 
-	job, _, err := findLogsJob(resp, "job-2", "", "")
+	job, _, err := findLogsJob(resp, "job-2", "", "", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -116,7 +116,7 @@
 		},
 	}
 
-	_, _, err := findLogsJob(resp, "run-1", "build", "")
+	_, _, err := findLogsJob(resp, "run-1", "build", "", "")
 	if err == nil {
 		t.Fatal("expected error for duplicate job key without --workflow")
 	}
@@ -141,7 +141,7 @@
 		},
 	}
 
-	job, path, err := findLogsJob(resp, "run-1", "build", "ci.yml")
+	job, path, err := findLogsJob(resp, "run-1", "build", "ci.yml", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -166,7 +166,7 @@
 		},
 	}
 
-	_, _, err := findLogsJob(resp, "run-1", "", "release.yml")
+	_, _, err := findLogsJob(resp, "run-1", "", "release.yml", "")
 	if err == nil {
 		t.Fatal("expected error for non-matching workflow filter")
 	}
@@ -193,7 +193,7 @@
 		},
 	}
 
-	attemptID, err := resolveAttempt(resp, "run-1", "build", "")
+	attemptID, err := resolveAttempt(resp, "run-1", "build", "", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -215,7 +215,7 @@
 		},
 	}
 
-	_, err := resolveAttempt(resp, "run-1", "", "")
+	_, err := resolveAttempt(resp, "run-1", "", "", "")
 	if err == nil {
 		t.Fatal("expected error for job with no attempts")
 	}
@@ -235,7 +235,7 @@
 		},
 	}
 
-	job, _, err := findLogsJob(resp, "run-1", "test", "")
+	job, _, err := findLogsJob(resp, "run-1", "test", "", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -263,7 +263,7 @@
 		},
 	}
 
-	_, _, err := findLogsJob(resp, "run-1", "build", "")
+	_, _, err := findLogsJob(resp, "run-1", "build", "", "")
 	if err == nil {
 		t.Fatal("expected error for ambiguous suffix match across workflows")
 	}
@@ -345,7 +345,7 @@
 	}
 
 	// Filtering by the first workflow's path should only see its jobs.
-	job, path, err := findLogsJob(resp, "wf-1", "", ".depot/workflows/ci.yml")
+	job, path, err := findLogsJob(resp, "wf-1", "", ".depot/workflows/ci.yml", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -393,7 +393,7 @@
 	}
 
 	// Passing workflow path as the filter should auto-select the single job in that workflow.
-	attemptID, err := resolveAttempt(resp, "wf-1", "", ".depot/workflows/ci.yml")
+	attemptID, err := resolveAttempt(resp, "wf-1", "", ".depot/workflows/ci.yml", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -402,6 +402,42 @@
 	}
 }
 
+func TestFindLogsJob_InlineWorkflowIDFilter(t *testing.T) {
+	// Test that inline workflows (with empty paths) can be filtered by workflow ID.
+	resp := &civ1.GetRunStatusResponse{
+		RunId: "run-1",
+		Workflows: []*civ1.WorkflowStatus{
+			{
+				WorkflowId:   "wf-1",
+				WorkflowPath: "", // Inline workflow has empty path
+				Name:         "inline-workflow",
+				Jobs: []*civ1.JobStatus{
+					{JobId: "job-1", JobKey: "build", Status: "finished"},
+				},
+			},
+			{
+				WorkflowId:   "wf-2",
+				WorkflowPath: ".depot/workflows/release.yml",
+				Jobs: []*civ1.JobStatus{
+					{JobId: "job-2", JobKey: "deploy", Status: "running"},
+				},
+			},
+		},
+	}
+
+	// Filter by workflow ID should only see the inline workflow's jobs.
+	job, path, err := findLogsJob(resp, "wf-1", "", "", "wf-1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if job.JobId != "job-1" {
+		t.Fatalf("expected job ID %q, got %q", "job-1", job.JobId)
+	}
+	if path != "" {
+		t.Fatalf("expected empty workflow path for inline workflow, got %q", path)
+	}
+}
+
 func TestWorkflowPathMatches(t *testing.T) {
 	tests := []struct {
 		path   string

When a workflow has no path (inline workflows), fall back to using the
workflow ID as the filter value, and match against WorkflowId in
findLogsJob so filtering still works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants