Skip to content
Merged
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
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ module github.com/depot/cli
go 1.26.8

require (
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261014111152-cc501a74ca30.1
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261014111152-cc501a74ca30.1
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/huh/v2 v2.0.0
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261014111152-cc501a74ca30.1 h1:ZQ5/7K2ub9YN2fIc5vBebzuancfsrPxwQlRX/gjECdQ=
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261014111152-cc501a74ca30.1/go.mod h1:2WAK2lzTTtuGkq+lAQmIRKQTpJG4u1Whk2UWzDFOP7Q=
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261014111152-cc501a74ca30.1 h1:hq0u/t/hSIp5RcHGj1P5hMkK+ljNEgS7YxD0JrErtwM=
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261014111152-cc501a74ca30.1/go.mod h1:MP5BDdzxwFdhf/KEm27tKfz9dKjtC9C1kXdpuSOb09g=
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1 h1:euMjmAIWUpU2cDOTtQ+7TEExRs8J9Fw340KgRcovbnQ=
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1/go.mod h1:ocyCo/PaEMnHD7rXfviQM0YvUEaOy0k3494BeBWP3Ak=
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1 h1:w7R+EBUQqZQ8u73PwJ8XPWEz1TjW82bvCoxokx6Yk2k=
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1/go.mod h1:MP5BDdzxwFdhf/KEm27tKfz9dKjtC9C1kXdpuSOb09g=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
Expand Down
7 changes: 5 additions & 2 deletions pkg/cmd/jobs/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Use the returned nextPageToken with --page-token to retrieve another page.`,
cmd.Use = "jobs [job-id]"
cmd.Short = "List or inspect GitHub Actions jobs run on Depot"
cmd.Args = cobra.MaximumNArgs(1)
cmd.Long += "\n\nSupply a job ID and one --repo to inspect a job. Step timings are not\navailable from the API. Use jobs metrics for individual job CPU and memory\nmetrics, or jobs analytics for aggregate metrics."
cmd.Long += "\n\nSupply a job ID and one --repo to inspect a job. Step timings are not\navailable from the API. Use jobs metrics for individual job CPU, memory, and OOM\nmetrics, or jobs analytics for aggregate metrics."
}
if allowGet {
cmd.Example = ` depot jobs --repo acme/api --output json
Expand Down Expand Up @@ -157,7 +157,10 @@ Use the returned nextPageToken with --page-token to retrieve another page.`,
if o.output == "json" {
return writeJSON(cmd.OutOrStdout(), resp)
}
return writeJob(cmd.OutOrStdout(), resp.Job)
if err := writeJob(cmd.OutOrStdout(), resp.Job); err != nil {
return err
}
return writeOOM(cmd.OutOrStdout(), resp.GetOom())
}
tr, err := p.timeRange()
if err != nil {
Expand Down
10 changes: 8 additions & 2 deletions pkg/cmd/jobs/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func newCmdMetrics() *cobra.Command {
var repos []string
cmd := &cobra.Command{
Use: "metrics <job-id>",
Short: "Fetch GitHub Actions job CPU and memory metrics",
Short: "Fetch GitHub Actions job CPU, memory, and OOM events",
Args: cobra.ExactArgs(1),
Long: `Fetch CPU and memory utilization for one GitHub Actions job execution.
Requires a GitHub job ID and exactly one --repo owner/name.
Expand All @@ -26,6 +26,9 @@ JSON includes time-ordered samples, statistics, availability, and sample-cap
metadata. Utilization is a fraction from 0 to 1 in JSON and a percentage in
tables. CPU measures user-mode utilization averaged across cores; memory is
used memory as a fraction of total memory.
OOM evidence includes kernel kills observed on the runner during this job.
No observed events does not rule out OOM; events may arrive after completion.
At most 100 OOM events are returned, with truncation indicated in JSON.
The API limits execution windows to seven days and returns at most 5,000 samples.
Statistics cover all observed samples before downsampling; returned samples
may omit intermediate peaks. JSON is automatic when stdout is piped and does
Expand Down Expand Up @@ -91,7 +94,10 @@ func writeMetrics(w io.Writer, resp *corev1.GetGithubActionsJobMetricsResponse)
fmt.Fprintln(tw, "Returned samples may omit intermediate peaks; statistics use all observed samples.")
}
fmt.Fprintln(tw, "Full samples: use --output json.")
return tw.Flush()
if err := tw.Flush(); err != nil {
return err
}
return writeOOM(w, resp.GetOom())
}

func metricPercent(value *float64) string {
Expand Down
30 changes: 30 additions & 0 deletions pkg/cmd/jobs/oom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package jobs

import (
"fmt"
"io"
"text/tabwriter"

corev1 "buf.build/gen/go/depot/api/protocolbuffers/go/depot/core/v1"
)

func writeOOM(w io.Writer, oom *corev1.GithubActionsJobOOM) error {
status := "unavailable"
if oom != nil && oom.Detected != nil {
status = "none observed (does not rule out OOM)"
if oom.GetDetected() {
status = "detected"
}
} else if oom.GetUnavailableReason() != "" {
status += " (" + cell(oom.GetUnavailableReason()) + ")"
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "OOM:\t%s\n", status)
for _, event := range oom.GetEvents() {
fmt.Fprintf(tw, "OOM event:\t%s PID %d %s\n", timestamp(event.GetTimestamp()), event.GetProcessPid(), cell(event.GetProcessName()))
}
if oom.GetTruncated() {
fmt.Fprintln(tw, "OOM events truncated: only the first 100 are shown.")
}
return tw.Flush()
}
72 changes: 72 additions & 0 deletions pkg/cmd/jobs/oom_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package jobs

import (
"encoding/json"
"testing"
"time"

corev1 "buf.build/gen/go/depot/api/protocolbuffers/go/depot/core/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)

func TestJobOOMOutput(t *testing.T) {
for _, tc := range []struct {
name string
oom *corev1.GithubActionsJobOOM
want string
}{
{"older server", nil, "unavailable"},
{"no runner", &corev1.GithubActionsJobOOM{UnavailableReason: "missing_runner_identity"}, "unavailable (missing_runner_identity)"},
{"none observed", &corev1.GithubActionsJobOOM{Detected: proto.Bool(false)}, "none observed (does not rule out OOM)"},
{"detected", &corev1.GithubActionsJobOOM{
Detected: proto.Bool(true), Truncated: true,
Events: []*corev1.GithubActionsJobOOMEvent{{
Timestamp: timestamppb.New(time.Date(2026, 10, 20, 10, 0, 1, 250000000, time.UTC)),
ProcessPid: 42, ProcessName: "compiler\nforged\trow\x1b[2J\u202e",
}},
}, "detected"},
} {
t.Run(tc.name, func(t *testing.T) {
job := &corev1.GithubActionsJob{JobId: "123"}
serve(t, &jobServer{
get: func(*connect.Request[corev1.GetGithubActionsJobRequest]) (*corev1.GetGithubActionsJobResponse, error) {
return &corev1.GetGithubActionsJobResponse{Job: job, Oom: tc.oom}, nil
},
metrics: func(*connect.Request[corev1.GetGithubActionsJobMetricsRequest]) (*corev1.GetGithubActionsJobMetricsResponse, error) {
return &corev1.GetGithubActionsJobMetricsResponse{Job: job, Oom: tc.oom}, nil
},
})
for _, args := range [][]string{{"123"}, {"metrics", "123"}} {
for _, format := range []string{"json", "table"} {
cmdArgs := append(append([]string{}, args...), "--repo", "acme/api", "--token", "test-token", "--output", format)
out, err := execute(NewCmdJobs(), cmdArgs...)
require.NoError(t, err)
if format == "json" {
var document map[string]json.RawMessage
require.NoError(t, json.Unmarshal([]byte(out), &document))
if tc.oom != nil {
var actual corev1.GithubActionsJobOOM
require.NoError(t, protojson.Unmarshal(document["oom"], &actual))
require.True(t, proto.Equal(tc.oom, &actual))
}
} else {
require.Contains(t, out, "OOM:")
require.Contains(t, out, tc.want)
if tc.oom.GetDetected() {
require.Contains(t, out, "PID 42")
require.Contains(t, out, "2026-10-20T10:00:01")
require.Contains(t, out, "first 100")
}
for _, unsafe := range []string{"\nforged", "\x1b", "\u202e"} {
require.NotContains(t, out, unsafe)
}
}
}
}
})
}
}
Loading