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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ a CI job could read: this machine's CLI session, a platform token or an API
key. A platform token needs the `automations:read` scope to list and inspect
automations and `automations:run` to start one.

The text views show workspace and automation names, looked up once per
command; detail views print `name (id)` so the id stays copyable. A token
without `workspaces:read` (or `automations:read`, for automation names in
`nan approvals ls` and `nan runs show`) gets the ids instead, never an error.
`--json` prints the API's objects unchanged, with ids only.

### Exit codes

`nan run`, `nan runs logs -f` and `nan automations run` exit with the run's outcome, so a script can
Expand Down
6 changes: 5 additions & 1 deletion cmd/approvals.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ CLI session or tokens (an agent in a workspace, a CI job) can approve the
actions an agent proposed.

Authentication is the same as nan run (see nan run --help); a platform token
needs the runs scope.
needs the runs scope. Automation names are looked up with automations:read;
without it the automation's id is shown instead.

Docs: https://nan.builders/docs/runs`,
Annotations: sysexits,
Expand Down Expand Up @@ -126,6 +127,9 @@ func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error {
}
return nil
}
for i := range list.Data {
env.nameAutomation(ctx, list.Data[i].Automation)
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "RUN\tSTATE\tAUTOMATION\tWAITING FOR APPROVAL\tEXPIRES")
for _, a := range list.Data {
Expand Down
20 changes: 16 additions & 4 deletions cmd/automations.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ an API key.

Authentication is the same as nan run (see nan run --help). A platform token
needs the automations:read scope to list and inspect, and automations:run to
start a run.
start a run. Workspace names are looked up with workspaces:read; without it
the workspace's id is shown instead.

Docs: https://nan.builders/docs/runs`,
Annotations: sysexits,
Expand Down Expand Up @@ -246,13 +247,16 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error {
fmt.Fprintf(env.stderr, "No automations yet. Create one in the portal: %s\n", runs.NewAutomationURL)
return nil
}
for i := range list.Data {
env.nameWorkspace(ctx, list.Data[i].Workspace)
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tWORKSPACE\tAGENT\tSTATUS\tAPPROVAL")
for _, a := range list.Data {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
runs.SanitizeLine(a.ID),
runs.Truncate(runs.SanitizeLine(a.Name), 40),
runs.SanitizeLine(a.Workspace.Label()),
runs.Truncate(runs.SanitizeLine(a.Workspace.Label()), 40),
runs.SanitizeLine(a.Agent),
automationStatus(a),
runs.Truncate(approvalSummary(a.ApprovalKinds), 40))
Expand Down Expand Up @@ -295,6 +299,7 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error {
if autoShowJSON {
return printJSON(env.stdout, a, true)
}
env.nameWorkspace(ctx, a.Workspace)
triggers, trigErr := env.client.ListTriggers(ctx, id)

tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
Expand All @@ -310,7 +315,9 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error {
if a.Template != "" && a.Template != "custom" {
row("template", runs.SanitizeLine(a.Template))
}
row("workspace", runs.SanitizeLine(a.Workspace.Label()))
if a.Workspace != nil {
row("workspace", runs.NameAndID(a.Workspace.Name, a.Workspace.ID))
}
agent := runs.SanitizeLine(a.Agent)
if a.Model != nil && *a.Model != "" {
agent += " · model " + runs.SanitizeLine(*a.Model)
Expand Down Expand Up @@ -486,7 +493,12 @@ func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json.
return runExit(run)
}
if !replayed {
name := runs.SanitizeLine(run.Automation.Label())
// The platform names the automation by id; what the member typed
// (a name, usually) reads better than a UUID they did not.
name := ""
if run.Automation != nil {
name = runs.SanitizeLine(run.Automation.Name)
}
if name == "" {
name = runs.Truncate(runs.SanitizeLine(ref), 64)
}
Expand Down
5 changes: 3 additions & 2 deletions cmd/automations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ func (f *fakePhase2) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.runIdem = r.Header.Get("Idempotency-Key")
_ = json.NewDecoder(r.Body).Decode(&f.runBody)
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, f.runs.run(api.StateQueued))
// The platform names the automation by id only.
_, _ = io.WriteString(w, strings.Replace(f.runs.run(api.StateQueued), `"object":"run",`, `"object":"run","automation_id":"`+testAutoID+`",`, 1))
case r.Method == http.MethodGet && p == "/v1/approvals":
f.approvalsQ = r.URL.RawQuery
_, _ = io.WriteString(w, `{"object":"list","data":[{"run_id":"`+testRunID+`","state":"pending","approval_from":1,
Expand Down Expand Up @@ -262,7 +263,7 @@ func TestAutomationsRunExitsTenOnAwaitingApproval(t *testing.T) {
t.Errorf("request: idem %q body %v", fake.runIdem, fake.runBody)
}
out := h.stdout.String() + h.stderr.String()
for _, want := range []string{"approval required for: git_push", "awaiting approval", "nan runs proposal " + testRunID, "nan runs reject " + testRunID, "browser sign-in"} {
for _, want := range []string{"of PR review queued on develop", "approval required for: git_push", "awaiting approval", "nan runs proposal " + testRunID, "nan runs reject " + testRunID, "browser sign-in"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
Expand Down
55 changes: 52 additions & 3 deletions cmd/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"

"github.com/nxssie/nan-cli/internal/api"
Expand All @@ -16,6 +17,15 @@ import (
// goldenEnv serves cloud-api's own golden responses (copied into
// internal/api/testdata/phase2) on the paths that return them.
func goldenEnv(t *testing.T) (*runsEnv, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
env, out, errOut, _ := goldenEnvWith(t, "")
return env, out, errOut
}

// goldenEnvWith is goldenEnv that also answers GET /api/workspaces with
// workspaces (a JSON body; "" leaves it a 404, as for a platform token
// without workspaces:read), and records every request it serves.
func goldenEnvWith(t *testing.T, workspaces string) (*runsEnv, *bytes.Buffer, *bytes.Buffer, func() []string) {
t.Helper()
const run = "6f1c2a9b-0000-4000-8000-000000000001"
const auto = "a0000000-0000-4000-8000-000000000001"
Expand All @@ -27,7 +37,17 @@ func goldenEnv(t *testing.T) (*runsEnv, *bytes.Buffer, *bytes.Buffer) {
"/v1/automations/" + auto + "/triggers": "automation_triggers.json",
"/v1/automations/" + auto + "/deliveries": "automation_deliveries.json",
}
var mu sync.Mutex
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = append(seen, r.Method+" "+r.URL.Path)
mu.Unlock()
if r.URL.Path == "/api/workspaces" && r.Method == http.MethodGet && workspaces != "" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(workspaces))
return
}
name, ok := routes[r.URL.Path]
if !ok || r.Method != http.MethodGet {
http.NotFound(w, r)
Expand All @@ -43,7 +63,11 @@ func goldenEnv(t *testing.T) (*runsEnv, *bytes.Buffer, *bytes.Buffer) {
t.Cleanup(srv.Close)
var out, errOut bytes.Buffer
env := &runsEnv{client: api.NewRunsClient("s", "").WithBaseURL(srv.URL), stdout: &out, stderr: &errOut}
return env, &out, &errOut
return env, &out, &errOut, func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), seen...)
}
}

func wantAll(t *testing.T, what, out string, wants ...string) {
Expand All @@ -66,7 +90,8 @@ func TestGoldenRender(t *testing.T) {
if err := doRunsShow(ctx, env, run); err != nil {
t.Fatal(err)
}
wantAll(t, "runs show", out.String(), "awaiting_approval", "propose", "a0000000-0000-4000-8000-000000000001",
wantAll(t, "runs show", out.String(), "awaiting_approval", "propose", "PR review api (a0000000-0000-4000-8000-000000000001)",
"develop ("+ws+")",
"acme/api", "GITHUB_TOKEN (the agent)", "pending", "3 actions", "d0000000-0000-4000-8000-000000000004",
"github_pr_review ok", "nan runs reject "+run, "/workspaces/"+ws+"/runs/"+run)

Expand All @@ -88,7 +113,7 @@ func TestGoldenRender(t *testing.T) {
if err := doApprovalsLs(ctx, env, api.ApprovalPending); err != nil {
t.Fatal(err)
}
wantAll(t, "approvals ls", out.String(), run, "pending", "a0000000-0000-4000-8000-000000000001", "github_pr_review")
wantAll(t, "approvals ls", out.String(), run, "pending", "PR review api", "github_pr_review")
if strings.Contains(out.String(), "github_pr_comment") {
t.Errorf("approvals ls lists an action that applied automatically:\n%s", out.String())
}
Expand All @@ -99,6 +124,30 @@ func TestGoldenRender(t *testing.T) {
}
wantAll(t, "automations ls", out.String(), "PR review api", ws, "enabled", "github_pr_review")

// With workspaces:read the workspace is named, from one lookup.
const workspaces = `[{"workspace_uuid":"` + ws + `","name":"develop","status":"running"}]`
env, out, _, seen := goldenEnvWith(t, workspaces)
if err := doAutomationsLs(ctx, env); err != nil {
t.Fatal(err)
}
if lines := strings.Split(out.String(), "\n"); len(lines) < 2 || !strings.Contains(lines[1], "develop") || strings.Contains(lines[1], ws) {
t.Errorf("automations ls does not name the workspace:\n%s", out.String())
}
env, out, _, seen = goldenEnvWith(t, workspaces)
if err := doAutomationsShow(ctx, env, "PR review api"); err != nil {
t.Fatal(err)
}
wantAll(t, "automations show", out.String(), "develop ("+ws+")")
n := 0
for _, r := range seen() {
if r == "GET /api/workspaces" {
n++
}
}
if n != 1 {
t.Errorf("automations show: GET /api/workspaces %d times, want 1: %v", n, seen())
}

env, out, _ = goldenEnv(t)
if err := doAutomationsShow(ctx, env, "PR review api"); err != nil {
t.Fatal(err)
Expand Down
108 changes: 108 additions & 0 deletions cmd/names.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package cmd

import (
"context"
"strings"
"time"

"github.com/nxssie/nan-cli/internal/api"
)

// The platform names a run's or an approval's workspace and automation by id
// only. People look for names, so the text views look the names up here,
// once per command, and show the id when that is not possible: a platform
// token without workspaces:read, a network error, an id that is not in the
// list. A name is a nicety; it never fails a command, and --json output
// never asks for one (it stays the API's own shape).

// nameLookupTimeout bounds each lookup, so a slow answer to a question the
// command does not need answered cannot hold up the one it does.
const nameLookupTimeout = 3 * time.Second

// nameCache holds the names a command looked up. The zero value is ready:
// each list is fetched the first time a name from it is asked for, and
// never again, whatever the answer was.
type nameCache struct {
workspaces, automations map[string]string
workspacesTried, automationsTried bool
}

func lookupKey(id string) string { return strings.ToLower(strings.TrimSpace(id)) }

// workspaceName is the name of the member's workspace with this id, or ""
// when it cannot be known.
func (env *runsEnv) workspaceName(ctx context.Context, id string) string {
if lookupKey(id) == "" {
return ""
}
if !env.names.workspacesTried {
env.names.workspacesTried = true
lctx, cancel := context.WithTimeout(ctx, nameLookupTimeout)
defer cancel()
if list, err := env.client.ListWorkspaces(lctx); err == nil {
env.names.workspaces = make(map[string]string, len(list))
for _, w := range list {
env.names.workspaces[lookupKey(w.ID)] = w.Name
}
}
}
return env.names.workspaces[lookupKey(id)]
}

// automationName is the name of the member's automation with this id, or ""
// when it cannot be known.
func (env *runsEnv) automationName(ctx context.Context, id string) string {
if lookupKey(id) == "" {
return ""
}
if !env.names.automationsTried {
env.names.automationsTried = true
lctx, cancel := context.WithTimeout(ctx, nameLookupTimeout)
defer cancel()
if list, err := env.client.ListAutomations(lctx); err == nil {
env.names.automations = make(map[string]string, len(list.Data))
for _, a := range list.Data {
env.names.automations[lookupKey(a.ID)] = a.Name
}
}
}
return env.names.automations[lookupKey(id)]
}

// nameWorkspace fills in a workspace reference's name when the platform sent
// only its id.
func (env *runsEnv) nameWorkspace(ctx context.Context, r *api.Ref) {
if r != nil && r.Name == "" {
r.Name = env.workspaceName(ctx, r.ID)
}
}

// nameAutomation fills in an automation reference's name when the platform
// sent only its id.
func (env *runsEnv) nameAutomation(ctx context.Context, r *api.Ref) {
if r != nil && r.Name == "" {
r.Name = env.automationName(ctx, r.ID)
}
}

// nameRunWorkspace fills in a run's workspace name when it has none (the
// platform sends it, except for a workspace deleted since).
func (env *runsEnv) nameRunWorkspace(ctx context.Context, run *api.Run) {
if run.Workspace.Name == "" {
run.Workspace.Name = env.workspaceName(ctx, run.Workspace.ID)
}
}

// nameRun fills in the names a run's text views show.
func (env *runsEnv) nameRun(ctx context.Context, run *api.Run) {
env.nameRunWorkspace(ctx, run)
env.nameAutomation(ctx, run.Automation)
}

// refLabel is a name for a table: the name when it is known, else the id.
func refLabel(name, id string) string {
if name != "" {
return name
}
return id
}
Loading
Loading