From 0f9f365162618073e5ff4a47b25bc461e96019f6 Mon Sep 17 00:00:00 2001 From: Rob Stolarz Date: Fri, 20 Mar 2026 11:44:30 -0700 Subject: [PATCH 1/3] feat: support workflow ID in `depot ci logs` Resolves DEP-3881. The positional arg now accepts a workflow ID (e.g. from the /workflows/ 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) --- pkg/cmd/ci/logs.go | 95 +++++++++++++++++++++++++++++++++++------ pkg/cmd/ci/logs_test.go | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go index 9b981793..7b7543fc 100644 --- a/pkg/cmd/ci/logs.go +++ b/pkg/cmd/ci/logs.go @@ -1,6 +1,7 @@ package ci import ( + "context" "fmt" "os" "strings" @@ -21,13 +22,13 @@ func NewCmdLogs() *cobra.Command { ) cmd := &cobra.Command{ - Use: "logs ", + Use: "logs ", Short: "Fetch logs for a CI job [beta]", Long: `Fetch and display log output for a CI job. -Accepts a run ID, job ID, or attempt ID. When given a run or job ID, the -command resolves to the latest attempt automatically. Use --job and --workflow -to disambiguate when a run has multiple jobs. +Accepts a run ID, job ID, attempt ID, or workflow ID. When given a run, job, +or workflow ID, the command resolves to the latest attempt automatically. +Use --job and --workflow to disambiguate when a run has multiple jobs. This command is in beta and subject to change.`, Example: ` # Logs for a specific attempt @@ -36,6 +37,9 @@ This command is in beta and subject to change.`, # Logs for a run (auto-selects job if only one) depot ci logs + # Logs for a workflow (auto-selects from the workflow's jobs) + depot ci logs + # Logs for a specific job in a run depot ci logs --job test @@ -64,34 +68,54 @@ This command is in beta and subject to change.`, // First, try resolving as a run ID (or job ID — the API accepts both). resp, runErr := api.CIGetRunStatus(ctx, tokenVal, orgID, id) if runErr == nil { - attemptID, err := resolveAttempt(resp, id, job, workflow) + // If the positional arg matches a workflow ID in the response, + // auto-filter to that workflow's jobs. + wfFilter := workflow + if wfFilter == "" { + for _, wf := range resp.Workflows { + if wf.WorkflowId == id { + wfFilter = wf.WorkflowPath + break + } + } + } + + attemptID, err := resolveAttempt(resp, id, job, wfFilter) if err != nil { return err } - lines, err := api.CIGetJobAttemptLogs(ctx, tokenVal, orgID, attemptID) - if err != nil { - return fmt.Errorf("failed to get logs: %w", err) + return printLogs(ctx, tokenVal, orgID, attemptID) + } + + // Try resolving as a workflow ID by searching recent runs. + resp, wfPath, wfErr := resolveWorkflow(ctx, tokenVal, orgID, id) + if wfErr == nil { + wfFilter := workflow + if wfFilter == "" { + wfFilter = wfPath } - for _, line := range lines { - fmt.Println(line.Body) + attemptID, err := resolveAttempt(resp, id, job, wfFilter) + if err != nil { + return err } - return nil + + 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. if job != "" || workflow != "" { - return fmt.Errorf("failed to look up run: %w", runErr) + 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 { - // Both paths failed — show both errors so the user can + // 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, or attempt ID:\n as run: %v\n as attempt: %v", id, runErr, err) + 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 { @@ -334,3 +358,46 @@ func formatJobList(candidates []jobCandidate) string { } return b.String() } + +// printLogs fetches and prints all log lines for the given attempt. +func printLogs(ctx context.Context, token, orgID, attemptID string) error { + lines, err := api.CIGetJobAttemptLogs(ctx, token, orgID, attemptID) + if err != nil { + return fmt.Errorf("failed to get logs: %w", err) + } + for _, line := range lines { + fmt.Println(line.Body) + } + return nil +} + +// resolveWorkflow searches recent runs for a workflow matching the given ID. +// Returns the run status, the matching workflow path, and any error. +func resolveWorkflow(ctx context.Context, token, orgID, workflowID string) (*civ1.GetRunStatusResponse, string, error) { + allStatuses := []civ1.CIRunStatus{ + civ1.CIRunStatus_CI_RUN_STATUS_QUEUED, + civ1.CIRunStatus_CI_RUN_STATUS_RUNNING, + civ1.CIRunStatus_CI_RUN_STATUS_FINISHED, + civ1.CIRunStatus_CI_RUN_STATUS_FAILED, + civ1.CIRunStatus_CI_RUN_STATUS_CANCELLED, + } + + runs, err := api.CIListRuns(ctx, token, orgID, allStatuses, 50) + if err != nil { + return nil, "", fmt.Errorf("failed to list runs: %w", err) + } + + for _, run := range runs { + resp, err := api.CIGetRunStatus(ctx, token, orgID, run.RunId) + if err != nil { + continue + } + for _, wf := range resp.Workflows { + if wf.WorkflowId == workflowID { + return resp, wf.WorkflowPath, nil + } + } + } + + return nil, "", fmt.Errorf("workflow %q not found in recent runs", workflowID) +} diff --git a/pkg/cmd/ci/logs_test.go b/pkg/cmd/ci/logs_test.go index 452f4fbe..5549a22e 100644 --- a/pkg/cmd/ci/logs_test.go +++ b/pkg/cmd/ci/logs_test.go @@ -321,6 +321,87 @@ func TestJobKeyShort_MultipleColons(t *testing.T) { } } +func TestFindLogsJob_WorkflowIDAutoFilter(t *testing.T) { + // When the positional arg matches a workflow ID, findLogsJob should + // filter to that workflow's jobs when the workflow filter is set. + resp := &civ1.GetRunStatusResponse{ + RunId: "run-1", + Workflows: []*civ1.WorkflowStatus{ + { + WorkflowId: "wf-1", + WorkflowPath: ".depot/workflows/ci.yml", + 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"}, + }, + }, + }, + } + + // Filtering by the first workflow's path should only see its jobs. + job, path, err := findLogsJob(resp, "wf-1", "", ".depot/workflows/ci.yml") + 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 != ".depot/workflows/ci.yml" { + t.Fatalf("expected workflow path %q, got %q", ".depot/workflows/ci.yml", path) + } +} + +func TestResolveAttempt_WorkflowIDAutoFilter(t *testing.T) { + resp := &civ1.GetRunStatusResponse{ + RunId: "run-1", + Workflows: []*civ1.WorkflowStatus{ + { + WorkflowId: "wf-1", + WorkflowPath: ".depot/workflows/ci.yml", + Jobs: []*civ1.JobStatus{ + { + JobId: "job-1", + JobKey: "build", + Status: "finished", + Attempts: []*civ1.AttemptStatus{ + {AttemptId: "att-1", Attempt: 1, Status: "finished"}, + }, + }, + }, + }, + { + WorkflowId: "wf-2", + WorkflowPath: ".depot/workflows/release.yml", + Jobs: []*civ1.JobStatus{ + { + JobId: "job-2", + JobKey: "deploy", + Status: "running", + Attempts: []*civ1.AttemptStatus{ + {AttemptId: "att-2", Attempt: 1, Status: "running"}, + }, + }, + }, + }, + }, + } + + // 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") + if err != nil { + t.Fatal(err) + } + if attemptID != "att-1" { + t.Fatalf("expected attempt ID %q, got %q", "att-1", attemptID) + } +} + func TestWorkflowPathMatches(t *testing.T) { tests := []struct { path string From 1400e1f792dccd74b94379e9e2e1a52a86d7a9ac Mon Sep 17 00:00:00 2001 From: Rob Stolarz Date: Fri, 20 Mar 2026 12:23:04 -0700 Subject: [PATCH 2/3] fix: try attempt ID before workflow search to avoid latency 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) --- pkg/cmd/ci/logs.go | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go index 7b7543fc..72c22133 100644 --- a/pkg/cmd/ci/logs.go +++ b/pkg/cmd/ci/logs.go @@ -88,7 +88,22 @@ This command is in beta and subject to change.`, 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. + if job == "" && workflow == "" { + lines, attemptErr := api.CIGetJobAttemptLogs(ctx, tokenVal, orgID, id) + if attemptErr == nil { + for _, line := range lines { + fmt.Println(line.Body) + } + return nil + } + runErr = fmt.Errorf("%v\n as attempt: %v", runErr, attemptErr) + } + // Try resolving as a workflow ID by searching recent runs. + // This is the slowest path (lists runs then checks each), so try it last. resp, wfPath, wfErr := resolveWorkflow(ctx, tokenVal, orgID, id) if wfErr == nil { wfFilter := workflow @@ -104,24 +119,7 @@ This command is in beta and subject to change.`, 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. - 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", id, runErr, wfErr) }, } From a69a1cafca893d440ba1a6f7b90bd5c287816b02 Mon Sep 17 00:00:00 2001 From: Rob Stolarz Date: Fri, 20 Mar 2026 12:33:32 -0700 Subject: [PATCH 3/3] fix: handle inline workflows with empty path in workflow ID filter 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) --- pkg/cmd/ci/logs.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go index 72c22133..15551c1e 100644 --- a/pkg/cmd/ci/logs.go +++ b/pkg/cmd/ci/logs.go @@ -75,6 +75,9 @@ This command is in beta and subject to change.`, for _, wf := range resp.Workflows { if wf.WorkflowId == id { wfFilter = wf.WorkflowPath + if wfFilter == "" { + wfFilter = wf.WorkflowId + } break } } @@ -220,7 +223,7 @@ func resolveAttempt(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflo func findLogsJob(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter string) (*civ1.JobStatus, string, error) { var candidates []jobCandidate for _, wf := range resp.Workflows { - if workflowFilter != "" && !workflowPathMatches(wf.WorkflowPath, workflowFilter) { + if workflowFilter != "" && wf.WorkflowId != workflowFilter && !workflowPathMatches(wf.WorkflowPath, workflowFilter) { continue } for _, j := range wf.Jobs { @@ -392,7 +395,11 @@ func resolveWorkflow(ctx context.Context, token, orgID, workflowID string) (*civ } for _, wf := range resp.Workflows { if wf.WorkflowId == workflowID { - return resp, wf.WorkflowPath, nil + path := wf.WorkflowPath + if path == "" { + path = wf.WorkflowId + } + return resp, path, nil } } }