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
1 change: 1 addition & 0 deletions cmd/kata/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func newRootCmd() *cobra.Command {
newInitCmd(),
newCreateCmd(),
newShowCmd(),
newStatusCmd(),
newListCmd(),
newEditCmd(),
newScheduleCmd(),
Expand Down
1 change: 1 addition & 0 deletions cmd/kata/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ type showResponseForCLI struct {
Author string `json:"author"`
Owner *string `json:"owner"`
Priority *int64 `json:"priority"`
Revision int64 `json:"revision"`
Metadata map[string]json.RawMessage `json:"metadata"`
} `json:"issue"`
Comments []struct {
Expand Down
240 changes: 240 additions & 0 deletions cmd/kata/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/spf13/cobra"
"go.kenn.io/kata/internal/textsafe"
)

type issueStatusProjection struct {
Issue string `json:"issue"`
Project string `json:"project"`
IssueStatus string `json:"issue_status"`
Revision int64 `json:"revision"`
Actor string `json:"actor"`
ActorSource string `json:"actor_source"`
Auth string `json:"auth"`
Instance string `json:"instance"`
Owner *string `json:"owner,omitempty"`
Hold string `json:"hold"`
Holder string `json:"holder,omitempty"`
HolderInstance string `json:"holder_instance,omitempty"`
LeaseKind string `json:"lease_kind,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
PendingLeaseCount int `json:"pending_lease_count,omitempty"`
}

type instanceStatusForCLI struct {
InstanceUID string `json:"instance_uid"`
Auth struct {
Kind string `json:"kind"`
Actor string `json:"actor"`
} `json:"auth"`
}

func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status <issue-ref>",
Short: "show compact issue identity and hold status",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runIssueStatus(cmd, args[0])
},
}
}

func runIssueStatus(cmd *cobra.Command, issueRef string) error {
ctx, baseURL, pid, ref, err := resolveIssueRefForCommand(cmd, issueRef)
if err != nil {
return err
}
client, err := httpClientFor(ctx, baseURL)
if err != nil {
return err
}

var show showResponseForCLI
if err := getStatusPayload(ctx, client,
fmt.Sprintf("%s/api/v1/projects/%d/issues/%s", baseURL, pid, url.PathEscape(ref.RefForAPI)),
&show); err != nil {
return err
}
var instance instanceStatusForCLI
if err := getStatusPayload(ctx, client, baseURL+"/api/v1/instance", &instance); err != nil {
return err
}

actor, source := resolveActor(ctx, flags.As, nil)
if instance.Auth.Actor != "" {
actor = instance.Auth.Actor
source = "daemon"
}
authKind := instance.Auth.Kind
if authKind == "" {
authKind = "unknown"
}
now := time.Now().UTC()
if show.LeaseHubNow != nil && !show.LeaseHubNow.IsZero() {
now = show.LeaseHubNow.UTC()
}
projection := issueStatusProjection{
Issue: show.Issue.ShortID,
Project: ref.ProjectName,
IssueStatus: show.Issue.Status,
Revision: show.Issue.Revision,
Actor: actor,
ActorSource: source,
Auth: authKind,
Instance: instance.InstanceUID,
Owner: show.Issue.Owner,
Hold: projectedHoldState(show.Issue.Status, show.Issue.Owner, show.Lease, show.PendingLeases, now),
PendingLeaseCount: len(show.PendingLeases),
}
if show.Lease != nil {
projection.Holder = show.Lease.Holder
projection.HolderInstance = show.Lease.HolderInstanceUID
projection.LeaseKind = show.Lease.ClaimKind
projection.ExpiresAt = show.Lease.ExpiresAt
}
return printIssueStatus(cmd, projection)
}

func getStatusPayload(ctx context.Context, client *http.Client, target string, out any) error {
status, body, err := httpDoJSON(ctx, client, http.MethodGet, target, nil)
if err != nil {
return err
}
if status >= http.StatusBadRequest {
return apiErrFromBody(status, body)
}
return json.Unmarshal(body, out)
}

func projectedHoldState(
issueStatus string,
owner *string,
lease *claimForShowCLI,
pending []pendingClaimForCLI,
now time.Time,
) string {
if issueStatus == "closed" {
return "closed"
}
if lease != nil {
if lease.ClaimKind == "timed" && lease.ExpiresAt != nil && !lease.ExpiresAt.After(now) {
return "expired"
}
return "active"
}
if len(pending) > 0 {
return "pending"
}
if owner != nil && *owner != "" {
return "assigned"
}
return "unassigned"
}

func printIssueStatus(cmd *cobra.Command, status issueStatusProjection) error {
switch currentOutputMode() {
case outputJSON:
var out bytes.Buffer
if err := emitJSON(&out, status); err != nil {
return err
}
_, err := fmt.Fprint(cmd.OutOrStdout(), out.String())
return err
case outputAgent:
return printIssueStatusAgent(cmd.OutOrStdout(), status)
default:
return printIssueStatusHuman(cmd.OutOrStdout(), status)
}
}

func printIssueStatusAgent(out io.Writer, status issueStatusProjection) error {
fields := []agentField{
agentRowField("issue", status.Issue),
agentRowField("project", status.Project),
agentRowField("issue_status", status.IssueStatus),
agentRowField("revision", fmt.Sprint(status.Revision)),
agentRowField("actor", status.Actor),
agentRowField("actor_source", status.ActorSource),
agentRowField("auth", status.Auth),
agentRowField("instance", status.Instance),
agentOptionalRowField("owner", status.Owner),
agentRowField("hold", status.Hold),
agentOptionalRowField("holder", optionalStatusString(status.Holder)),
agentOptionalRowField("holder_instance", optionalStatusString(status.HolderInstance)),
agentOptionalRowField("lease_kind", optionalStatusString(status.LeaseKind)),
}
if status.ExpiresAt != nil {
expires := status.ExpiresAt.UTC().Format(time.RFC3339Nano)
fields = append(fields, agentRowField("expires_at", expires))
}
if status.PendingLeaseCount > 0 {
fields = append(fields, agentRowField("pending_leases", fmt.Sprint(status.PendingLeaseCount)))
}
if _, err := fmt.Fprint(out, "OK status"); err != nil {
return err
}
for _, field := range fields {
if field.value == nil {
continue
}
if _, err := fmt.Fprintf(out, " %s=%s", field.name, agentValue(*field.value)); err != nil {
return err
}
}
_, err := fmt.Fprintln(out)
return err
}

func optionalStatusString(value string) *string {
if value == "" {
return nil
}
return &value
}

func printIssueStatusHuman(out io.Writer, status issueStatusProjection) error {
if _, err := fmt.Fprintf(out, "%s [%s] hold=%s\n",
textsafe.Line(status.Issue), textsafe.Line(status.IssueStatus), status.Hold); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "actor: %s (%s; auth=%s)\ninstance: %s\n",
textsafe.Line(status.Actor), textsafe.Line(status.ActorSource),
textsafe.Line(status.Auth), textsafe.Line(status.Instance)); err != nil {
return err
}
if status.Owner != nil && *status.Owner != "" {
if _, err := fmt.Fprintln(out, "owner:", textsafe.Line(*status.Owner)); err != nil {
return err
}
}
if status.Holder != "" {
if _, err := fmt.Fprintf(out, "lease: %s from instance %s (%s)\n",
textsafe.Line(status.Holder), textsafe.Line(status.HolderInstance),
textsafe.Line(status.LeaseKind)); err != nil {
return err
}
}
if status.ExpiresAt != nil {
if _, err := fmt.Fprintln(out, "expires:", status.ExpiresAt.UTC().Format(time.RFC3339)); err != nil {
return err
}
}
if status.PendingLeaseCount > 0 {
if _, err := fmt.Fprintln(out, "pending leases:", status.PendingLeaseCount); err != nil {
return err
}
}
return nil
}
127 changes: 127 additions & 0 deletions cmd/kata/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package main

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

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/kata/internal/db"
"go.kenn.io/kata/internal/testenv"
)

func TestProjectedHoldStateDistinguishesLeaseAndAssignment(t *testing.T) {
now := time.Date(2026, time.September, 2, 12, 0, 0, 0, time.UTC)
before := now.Add(-time.Minute)
after := now.Add(time.Minute)
owner := "alice"

for _, tc := range []struct {
name string
status string
owner *string
lease *claimForShowCLI
pending []pendingClaimForCLI
want string
}{
{name: "active lease", status: "open", owner: &owner, lease: &claimForShowCLI{ClaimKind: "timed", ExpiresAt: &after}, want: "active"},
{name: "expired lease", status: "open", owner: &owner, lease: &claimForShowCLI{ClaimKind: "timed", ExpiresAt: &before}, want: "expired"},
{name: "pending lease", status: "open", owner: &owner, pending: []pendingClaimForCLI{{Holder: "alice"}}, want: "pending"},
{name: "assignment only", status: "open", owner: &owner, want: "assigned"},
{name: "unassigned", status: "open", want: "unassigned"},
{name: "closed", status: "closed", owner: &owner, want: "closed"},
} {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, projectedHoldState(tc.status, tc.owner, tc.lease, tc.pending, now))
})
}
}

func TestStatusAgentReportsAuthenticatedIdentityAndAssignment(t *testing.T) {
env, dir, pid := setupCLIWorkspaceOptions(t,
testenv.WithAuthToken("bootstrap-token"),
testenv.WithRequireTokenIdentity(),
)
const operatorToken = "operator-bearer"
_, _, err := env.DB.CreateAPIToken(context.Background(), db.CreateAPITokenParams{ //nolint:gosec // test-only bearer credential
PlaintextToken: operatorToken,
Actor: "operator",
AdminActor: db.BootstrapActor,
})
require.NoError(t, err)
owner := "operator"
issue, _, err := env.DB.CreateIssue(context.Background(), db.CreateIssueParams{
ProjectID: pid,
Title: "report assignment",
Author: "operator",
Owner: &owner,
})
require.NoError(t, err)
t.Setenv("KATA_AUTH_TOKEN", operatorToken)

out := runCLI(t, env, dir, "--agent", "status", issue.ShortID)

assert.Contains(t, out, "OK status issue="+issue.ShortID+" project=kata issue_status=open revision=1")
assert.Contains(t, out, "actor=operator actor_source=daemon auth=db_token")
assert.Contains(t, out, "instance="+env.DB.InstanceUID())
assert.Contains(t, out, "owner=operator hold=assigned")
assert.NotContains(t, out, operatorToken)
}

func TestStatusAgentReportsActiveTimedLease(t *testing.T) {
env, dir, _, ref := setupFederatedHubIssue(t, "report active lease")
runCLIAs(t, env, dir, "alice", "federation", "lease", "acquire", ref, "--ttl", "30m")

out := runCLIAs(t, env, dir, "alice", "--agent", "status", ref)

assert.Contains(t, out, "hold=active")
assert.Contains(t, out, "holder=alice")
assert.Contains(t, out, "lease_kind=timed")
assert.True(t, strings.Contains(out, "expires_at="), out)
}

func TestStatusJSONReportsDaemonActorAndHold(t *testing.T) {
env, dir, pid := setupCLIWorkspaceOptions(t,
testenv.WithAuthToken("bootstrap-token"),
testenv.WithRequireTokenIdentity(),
)
const operatorToken = "operator-bearer"
_, _, err := env.DB.CreateAPIToken(context.Background(), db.CreateAPITokenParams{ //nolint:gosec // test-only bearer credential
PlaintextToken: operatorToken,
Actor: "operator",
AdminActor: db.BootstrapActor,
})
require.NoError(t, err)
issue, _, err := env.DB.CreateIssue(context.Background(), db.CreateIssueParams{
ProjectID: pid,
Title: "report json status",
Author: "operator",
})
require.NoError(t, err)
t.Setenv("KATA_AUTH_TOKEN", operatorToken)

out := runCLI(t, env, dir, "--json", "status", issue.ShortID)

var got map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &got))
assert.Equal(t, "operator", got["actor"])
assert.Equal(t, "daemon", got["actor_source"])
assert.Equal(t, "db_token", got["auth"])
assert.Equal(t, "unassigned", got["hold"])
assert.NotContains(t, got, "claim")
assert.NotContains(t, out, operatorToken)
}

func TestStatusHumanShowsLeaseExpiry(t *testing.T) {
env, dir, _, ref := setupFederatedHubIssue(t, "report lease expiry")
runCLIAs(t, env, dir, "alice", "federation", "lease", "acquire", ref, "--ttl", "30m")

out := runCLIAs(t, env, dir, "alice", "status", ref)

assert.Contains(t, out, "hold=active")
assert.Contains(t, out, "lease: alice from instance "+env.DB.InstanceUID()+" (timed)")
assert.Regexp(t, `(?m)^expires: \d{4}-\d{2}-\d{2}T`, out)
}
Loading
Loading