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
10 changes: 10 additions & 0 deletions pkg/api/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ func CIGetRunMetrics(ctx context.Context, token, orgID, runID string) (*civ1.Get
return resp.Msg, nil
}

// CIGetJobSummary returns authored step summary markdown for a job, a concrete attempt, or both.
func CIGetJobSummary(ctx context.Context, token, orgID string, req *civ1.GetJobSummaryRequest) (*civ1.GetJobSummaryResponse, error) {
client := newCIServiceClient()
resp, err := client.GetJobSummary(ctx, WithAuthenticationAndOrg(connect.NewRequest(req), token, orgID))
if err != nil {
return nil, err
}
return resp.Msg, nil
}

// CIGetJobAttemptLogs returns all log lines for a job attempt, paginating through all pages.
func CIGetJobAttemptLogs(ctx context.Context, token, orgID, attemptID string) ([]*civ1.LogLine, error) {
client := newCIServiceClient()
Expand Down
44 changes: 44 additions & 0 deletions pkg/api/ci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ func (h ciServiceTestHandler) GetRunMetrics(_ context.Context, req *connect.Requ
return connect.NewResponse(&civ1.GetRunMetricsResponse{SnapshotAt: "2026-05-03T12:00:00Z"}), nil
}

func (h ciServiceTestHandler) GetJobSummary(_ context.Context, req *connect.Request[civ1.GetJobSummaryRequest]) (*connect.Response[civ1.GetJobSummaryResponse], error) {
assertAuthAndOrg(h.t, req.Header())
if req.Msg.AttemptId != "" {
if req.Msg.AttemptId != "attempt-123" {
h.t.Fatalf("AttemptId = %q, want attempt-123", req.Msg.AttemptId)
}
return connect.NewResponse(&civ1.GetJobSummaryResponse{AttemptId: req.Msg.AttemptId, HasSummary: true, Markdown: "attempt summary"}), nil
}
if req.Msg.JobId != "job-123" {
h.t.Fatalf("JobId = %q, want job-123", req.Msg.JobId)
}
return connect.NewResponse(&civ1.GetJobSummaryResponse{JobId: req.Msg.JobId, AttemptId: "attempt-456", HasSummary: true, Markdown: "job summary"}), nil
}

func (h ciServiceTestHandler) GetJobAttemptLogs(context.Context, *connect.Request[civ1.GetJobAttemptLogsRequest]) (*connect.Response[civ1.GetJobAttemptLogsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, nil)
}
Expand Down Expand Up @@ -188,6 +202,36 @@ func TestCIMetricsWrappers(t *testing.T) {
})
}

func TestCISummaryWrappers(t *testing.T) {
withTestCIService(t, func() {
attemptResp, err := CIGetJobSummary(
context.Background(),
"token-123",
"org-123",
&civ1.GetJobSummaryRequest{AttemptId: "attempt-123"},
)
if err != nil {
t.Fatalf("CIGetJobSummary attempt request returned error: %v", err)
}
if attemptResp.GetMarkdown() != "attempt summary" {
t.Fatalf("unexpected attempt summary: %+v", attemptResp)
}

jobResp, err := CIGetJobSummary(
context.Background(),
"token-123",
"org-123",
&civ1.GetJobSummaryRequest{JobId: "job-123"},
)
if err != nil {
t.Fatalf("CIGetJobSummary job request returned error: %v", err)
}
if jobResp.GetAttemptId() != "attempt-456" || jobResp.GetMarkdown() != "job summary" {
t.Fatalf("unexpected job summary: %+v", jobResp)
}
})
}

func withTestCIService(t *testing.T, fn func()) {
t.Helper()

Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/ci/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func NewCmdCI() *cobra.Command {
cmd.AddCommand(NewCmdSecrets())
cmd.AddCommand(NewCmdSSH())
cmd.AddCommand(NewCmdStatus())
cmd.AddCommand(NewCmdSummary())
cmd.AddCommand(NewCmdVars())
cmd.AddCommand(NewCmdWorkflow())

Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/ci/ci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestCICommandRegistration(t *testing.T) {
"run",
"status",
"logs",
"summary",
"ssh",
// mutation verbs (added in DEP-4221)
"cancel",
Expand Down
19 changes: 4 additions & 15 deletions pkg/cmd/ci/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func NewCmdMetrics() *cobra.Command {
depot ci metrics --job job_123 --output json
depot ci metrics --run run_123 --output json`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateMetricsOutput(output); err != nil {
if err := validateTextOrJSONOutput(output); err != nil {
return err
}
if len(args) > 1 {
Expand Down Expand Up @@ -84,7 +84,7 @@ func NewCmdMetrics() *cobra.Command {
}
return fmt.Errorf("failed to get job metrics: %w", err)
}
if metricsOutputJSON(output) {
if outputIsJSON(output) {
return writeJSON(buildJobMetricsJSON(resp))
}
printJobMetrics(resp, orgFlag)
Expand All @@ -96,7 +96,7 @@ func NewCmdMetrics() *cobra.Command {
}
return fmt.Errorf("failed to get run metrics: %w", err)
}
if metricsOutputJSON(output) {
if outputIsJSON(output) {
return writeJSON(buildRunMetricsJSON(resp))
}
printRunMetrics(resp, orgFlag)
Expand All @@ -108,7 +108,7 @@ func NewCmdMetrics() *cobra.Command {
}
return fmt.Errorf("failed to get attempt metrics: %w", err)
}
if metricsOutputJSON(output) {
if outputIsJSON(output) {
return writeJSON(buildAttemptMetricsJSON(resp))
}
printAttemptMetrics(resp, orgFlag)
Expand Down Expand Up @@ -451,17 +451,6 @@ func connectErrorText(err error) string {
return ""
}

func validateMetricsOutput(output string) error {
if output == "" || output == "text" || output == "json" {
return nil
}
return fmt.Errorf("unsupported output %q (valid: text, json)", output)
}

func metricsOutputJSON(output string) bool {
return output == "json"
}

func countNonEmpty(values ...string) int {
count := 0
for _, value := range values {
Expand Down
17 changes: 17 additions & 0 deletions pkg/cmd/ci/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,30 @@ package ci

import (
"encoding/json"
"fmt"
"os"
)

const (
outputFormatText = "text"
outputFormatJSON = "json"
)

// writeJSON encodes v as indented JSON to stdout. Used by the `--output json`
// path on `depot ci` verbs so every verb formats RPC responses identically.
func writeJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}

func validateTextOrJSONOutput(output string) error {
if output == "" || output == outputFormatText || output == outputFormatJSON {
return nil
}
return fmt.Errorf("unsupported output %q (valid: text, json)", output)
}

func outputIsJSON(output string) bool {
return output == outputFormatJSON
}
160 changes: 160 additions & 0 deletions pkg/cmd/ci/summary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package ci

import (
"fmt"
"io"
"strings"

"connectrpc.com/connect"
"github.com/depot/cli/pkg/api"
"github.com/depot/cli/pkg/config"
"github.com/depot/cli/pkg/helpers"
civ1 "github.com/depot/cli/pkg/proto/depot/ci/v1"
"github.com/spf13/cobra"
)

var (
ciGetJobSummary = api.CIGetJobSummary
)

func NewCmdSummary() *cobra.Command {
var (
orgID string
token string
output string
)

cmd := &cobra.Command{
Use: "summary <attempt-id | job-id>",
Short: "Fetch CI step summary markdown",
Long: "Fetch authored CI step summary markdown for one job attempt or the current/latest attempt of one job.",
Example: ` depot ci summary <attempt-id>
depot ci summary <job-id>
depot ci summary <attempt-id> --output json`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateTextOrJSONOutput(output); err != nil {
return err
}
if len(args) == 0 {
if outputIsJSON(output) {
cmd.SilenceUsage = true
return fmt.Errorf("expected exactly one attempt or job ID")
}
return cmd.Help()
}
Comment thread
cursor[bot] marked this conversation as resolved.
if len(args) > 1 {
return fmt.Errorf("expected exactly one attempt or job ID")
}

ctx := cmd.Context()
id := args[0]

if orgID == "" {
orgID = config.GetCurrentOrganization()
}

tokenVal, err := helpers.ResolveOrgAuth(ctx, token)
if err != nil {
return err
}
if tokenVal == "" {
return fmt.Errorf("missing API token, please run `depot login`")
}

resp, jobErr := ciGetJobSummary(ctx, tokenVal, orgID, &civ1.GetJobSummaryRequest{JobId: id})
if jobErr == nil {
if outputIsJSON(output) {
return writeJSON(buildSummaryJSON(resp))
}
if resp.GetAttemptId() != "" {
fmt.Fprintf(cmd.ErrOrStderr(), "Using attempt #%d %s for job %s.\n", resp.GetAttempt(), resp.GetAttemptId(), resp.GetJobId())
}
return printSummaryResponse(cmd.OutOrStdout(), resp)
}
if connect.CodeOf(jobErr) != connect.CodeNotFound {
return fmt.Errorf("failed to get job summary: %w", jobErr)
}

resp, attemptErr := ciGetJobSummary(ctx, tokenVal, orgID, &civ1.GetJobSummaryRequest{AttemptId: id})
if attemptErr != nil {
if connect.CodeOf(attemptErr) == connect.CodeNotFound {
return fmt.Errorf(
"could not resolve %q as an attempt or job ID:\n as attempt: %v\n as job: %v",
id,
attemptErr,
jobErr,
)
}
return fmt.Errorf("failed to get attempt summary: %w", attemptErr)
}

if outputIsJSON(output) {
return writeJSON(buildSummaryJSON(resp))
}
return printSummaryResponse(cmd.OutOrStdout(), resp)
},
}

cmd.Flags().StringVar(&orgID, "org", "", "Organization ID (required when user is a member of multiple organizations)")
cmd.Flags().StringVar(&token, "token", "", "Depot API token")
cmd.Flags().StringVarP(&output, "output", "o", "", "Output format (text, json)")

return cmd
}

type summaryJSONDocument struct {
OrgID string `json:"org_id"`
RunID string `json:"run_id"`
WorkflowID string `json:"workflow_id"`
JobID string `json:"job_id"`
AttemptID string `json:"attempt_id"`
Attempt int32 `json:"attempt"`
JobStatus string `json:"job_status"`
AttemptStatus string `json:"attempt_status"`
HasSummary bool `json:"has_summary"`
EmptyReason string `json:"empty_reason"`
StepCount uint32 `json:"step_count"`
Markdown string `json:"markdown"`
}

func buildSummaryJSON(resp *civ1.GetJobSummaryResponse) summaryJSONDocument {
return summaryJSONDocument{
OrgID: resp.GetOrgId(),
RunID: resp.GetRunId(),
WorkflowID: resp.GetWorkflowId(),
JobID: resp.GetJobId(),
AttemptID: resp.GetAttemptId(),
Attempt: resp.GetAttempt(),
JobStatus: resp.GetJobStatus(),
AttemptStatus: resp.GetAttemptStatus(),
HasSummary: resp.GetHasSummary(),
EmptyReason: resp.GetEmptyReason(),
StepCount: resp.GetStepCount(),
Markdown: resp.GetMarkdown(),
}
}

func printSummaryResponse(w io.Writer, resp *civ1.GetJobSummaryResponse) error {
if resp.GetHasSummary() {
fmt.Fprint(w, resp.GetMarkdown())
if !strings.HasSuffix(resp.GetMarkdown(), "\n") {
fmt.Fprintln(w)
}
return nil
}

fmt.Fprintln(w, emptySummaryMessage(resp))
return nil
}

func emptySummaryMessage(resp *civ1.GetJobSummaryResponse) string {
switch resp.GetEmptyReason() {
case "no_attempt":
return "No CI job attempts exist yet, so no step summary is available."
default:
if resp.GetAttemptId() != "" {
return "No CI step summary was produced for this attempt."
}
return "No CI step summary was produced."
}
}
Loading
Loading