Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
95 changes: 81 additions & 14 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,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 {
Expand Down Expand Up @@ -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)
}
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