Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 97 additions & 25 deletions pkg/cmd/ci/logs.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ci

import (
"context"
"fmt"
"os"
"strings"
Expand All @@ -21,13 +22,13 @@ func NewCmdLogs() *cobra.Command {
)

cmd := &cobra.Command{
Use: "logs <run-id | job-id | attempt-id>",
Use: "logs <run-id | job-id | attempt-id | workflow-id>",
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
Expand All @@ -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 <run-id>

# Logs for a workflow (auto-selects from the workflow's jobs)
depot ci logs <workflow-id>

# Logs for a specific job in a run
depot ci logs <run-id> --job test

Expand Down Expand Up @@ -64,40 +68,61 @@ 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 err != nil {
return err
// 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
if wfFilter == "" {
wfFilter = wf.WorkflowId
}
break
}
}
}

lines, err := api.CIGetJobAttemptLogs(ctx, tokenVal, orgID, attemptID)
attemptID, err := resolveAttempt(resp, id, job, wfFilter)
if err != nil {
return fmt.Errorf("failed to get logs: %w", err)
return err
}

for _, line := range lines {
fmt.Println(line.Body)
}
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)
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)
}

lines, err := api.CIGetJobAttemptLogs(ctx, tokenVal, orgID, id)
if err != nil {
// Both paths failed — show both 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)
}
// 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
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 printLogs(ctx, tokenVal, orgID, attemptID)
}
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)
},
}

Expand Down Expand Up @@ -198,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 {
Expand Down Expand Up @@ -334,3 +359,50 @@ 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 {
path := wf.WorkflowPath
if path == "" {
path = wf.WorkflowId
}
return resp, path, nil
}
}
}

return nil, "", fmt.Errorf("workflow %q not found in recent runs", workflowID)
}
81 changes: 81 additions & 0 deletions pkg/cmd/ci/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading