diff --git a/internal/api/assets.go b/internal/api/assets.go new file mode 100644 index 0000000..b74a92c --- /dev/null +++ b/internal/api/assets.go @@ -0,0 +1,188 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// AssetsClient talks to the Jira Service Management Assets (CMDB) REST API. +// +// Assets lives on a different base URL than the Jira site API +// (https://api.atlassian.com/jsm/assets/workspace/{workspaceId}/v1) and the +// granular OAuth scopes the rest of atl uses do not cover CMDB objects, so this +// client authenticates with Basic auth (account email + API token) instead of +// the shared OAuth client. The token is read from the environment so it never +// lands in the on-disk config. +type AssetsClient struct { + httpClient *http.Client + email string + token string + workspaceID string + siteBase string // https://, used only for workspace discovery +} + +const assetsAPIBase = "https://api.atlassian.com/jsm/assets/workspace" + +// NewAssetsClient builds an Assets client. email and token are required; +// workspaceID may be empty, in which case it is discovered from the site. +func NewAssetsClient(siteBase, email, token, workspaceID string) *AssetsClient { + return &AssetsClient{ + httpClient: &http.Client{Timeout: 60 * time.Second}, + email: email, + token: token, + workspaceID: workspaceID, + siteBase: strings.TrimRight(siteBase, "/"), + } +} + +func (c *AssetsClient) do(ctx context.Context, method, fullURL string, body []byte, out interface{}) error { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, fullURL, rdr) + if err != nil { + return err + } + req.SetBasicAuth(c.email, c.token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + dec := json.NewDecoder(resp.Body) + var apiErr struct { + ErrorMessages []string `json:"errorMessages"` + } + _ = dec.Decode(&apiErr) + if len(apiErr.ErrorMessages) > 0 { + return fmt.Errorf("assets API %s: %d: %s", method, resp.StatusCode, strings.Join(apiErr.ErrorMessages, "; ")) + } + return fmt.Errorf("assets API %s: unexpected status %d", method, resp.StatusCode) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// WorkspaceID returns the resolved workspace id, discovering it from the site if +// it was not supplied. +func (c *AssetsClient) WorkspaceID(ctx context.Context) (string, error) { + if c.workspaceID != "" { + return c.workspaceID, nil + } + if c.siteBase == "" { + return "", fmt.Errorf("workspace id not set and no site to discover it from (pass --workspace or set ATLASSIAN_ASSETS_WORKSPACE)") + } + var out struct { + Values []struct { + WorkspaceID string `json:"workspaceId"` + } `json:"values"` + } + if err := c.do(ctx, http.MethodGet, c.siteBase+"/rest/servicedeskapi/assets/workspace", nil, &out); err != nil { + return "", fmt.Errorf("discovering assets workspace: %w", err) + } + if len(out.Values) == 0 { + return "", fmt.Errorf("no assets workspace found for site %s", c.siteBase) + } + c.workspaceID = out.Values[0].WorkspaceID + return c.workspaceID, nil +} + +func (c *AssetsClient) v1(ctx context.Context) (string, error) { + ws, err := c.WorkspaceID(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("%s/%s/v1", assetsAPIBase, ws), nil +} + +// AssetSchema is one object schema with its current object count. +type AssetSchema struct { + ID string `json:"id"` + Name string `json:"name"` + ObjectSchemaKey string `json:"objectSchemaKey"` + ObjectCount int `json:"objectCount"` + ObjectTypeCount int `json:"objectTypeCount"` +} + +// Schemas returns all object schemas in the workspace. +func (c *AssetsClient) Schemas(ctx context.Context) ([]AssetSchema, error) { + base, err := c.v1(ctx) + if err != nil { + return nil, err + } + var out struct { + Values []AssetSchema `json:"values"` + } + if err := c.do(ctx, http.MethodGet, base+"/objectschema/list", nil, &out); err != nil { + return nil, err + } + return out.Values, nil +} + +// AssetObject is a single Assets object (trimmed to the useful fields). +type AssetObject struct { + ID string `json:"id"` + ObjectKey string `json:"objectKey"` + Label string `json:"label"` + Created string `json:"created"` + Updated string `json:"updated"` + ObjectType struct { + Name string `json:"name"` + } `json:"objectType"` +} + +type aqlPage struct { + Values []AssetObject `json:"values"` + IsLast bool `json:"isLast"` +} + +// AQLPage runs an AQL query and returns one page of objects. +func (c *AssetsClient) AQLPage(ctx context.Context, ql string, startAt, maxResults int) ([]AssetObject, bool, error) { + base, err := c.v1(ctx) + if err != nil { + return nil, false, err + } + q := url.Values{} + q.Set("startAt", fmt.Sprint(startAt)) + q.Set("maxResults", fmt.Sprint(maxResults)) + q.Set("includeAttributes", "false") + body, _ := json.Marshal(map[string]string{"qlQuery": ql}) + var page aqlPage + if err := c.do(ctx, http.MethodPost, base+"/object/aql?"+q.Encode(), body, &page); err != nil { + return nil, false, err + } + return page.Values, page.IsLast, nil +} + +// AQLCount returns the exact number of objects matching an AQL query by +// paginating through every page. The object/aql endpoint caps its reported +// `total` at 1000, so it cannot be trusted for counting — this walks instead. +func (c *AssetsClient) AQLCount(ctx context.Context, ql string) (int, error) { + total, start := 0, 0 + for { + vals, isLast, err := c.AQLPage(ctx, ql, start, 500) + if err != nil { + return 0, err + } + total += len(vals) + if isLast || len(vals) == 0 { + return total, nil + } + start += len(vals) + } +} diff --git a/internal/cmd/assets/aql.go b/internal/cmd/assets/aql.go new file mode 100644 index 0000000..a1ece79 --- /dev/null +++ b/internal/cmd/assets/aql.go @@ -0,0 +1,74 @@ +package assets + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +func newCmdAQL(ios *iostreams.IOStreams, common *commonOptions) *cobra.Command { + var ( + countOnly bool + limit int + jsonOut bool + ) + + cmd := &cobra.Command{ + Use: "aql ", + Short: "Run an AQL query against the Assets workspace", + Long: `Run an Assets Query Language (AQL) query. + +With --count the result is the exact number of matching objects (the command +paginates, because the Assets endpoint caps its reported total at 1000). +Otherwise the first matching objects are listed.`, + Args: cobra.ExactArgs(1), + Example: ` # Exact count of every object in the workspace + atl assets aql 'objectId > 0' --count + + # Newest objects of one object type + atl assets aql 'objectTypeId = 36 ORDER BY created DESC' --limit 20`, + RunE: func(cmd *cobra.Command, args []string) error { + ql := args[0] + client, err := common.client() + if err != nil { + return err + } + ctx := cmd.Context() + + if countOnly { + n, err := client.AQLCount(ctx, ql) + if err != nil { + return err + } + if jsonOut { + return output.JSON(ios.Out, map[string]int{"count": n}) + } + fmt.Fprintln(ios.Out, n) + return nil + } + + objs, _, err := client.AQLPage(ctx, ql, 0, limit) + if err != nil { + return err + } + if jsonOut { + return output.JSON(ios.Out, objs) + } + rows := make([][]string, 0, len(objs)) + for _, o := range objs { + rows = append(rows, []string{o.ObjectKey, o.ObjectType.Name, o.Created, o.Updated, strings.TrimSpace(o.Label)}) + } + output.SimpleTable(ios.Out, []string{"KEY", "TYPE", "CREATED", "UPDATED", "LABEL"}, rows) + return nil + }, + } + + cmd.Flags().BoolVar(&countOnly, "count", false, "Print only the exact total match count") + cmd.Flags().IntVar(&limit, "limit", 25, "Max objects to list when not counting") + cmd.Flags().BoolVarP(&jsonOut, "json", "j", false, "Output as JSON") + return cmd +} diff --git a/internal/cmd/assets/assets.go b/internal/cmd/assets/assets.go new file mode 100644 index 0000000..c736c49 --- /dev/null +++ b/internal/cmd/assets/assets.go @@ -0,0 +1,87 @@ +package assets + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/api" + "github.com/enthus-appdev/atl-cli/internal/config" + "github.com/enthus-appdev/atl-cli/internal/iostreams" +) + +// commonOptions holds the auth/target flags shared by every assets subcommand. +type commonOptions struct { + Email string + Workspace string +} + +func (o *commonOptions) addFlags(cmd *cobra.Command) { + cmd.PersistentFlags().StringVar(&o.Email, "email", "", "Atlassian account email (default: $ATLASSIAN_EMAIL or current host user)") + cmd.PersistentFlags().StringVar(&o.Workspace, "workspace", "", "Assets workspace id (default: $ATLASSIAN_ASSETS_WORKSPACE or auto-discovered)") +} + +// client builds a Basic-auth Assets client from flags, environment, and the +// current atl host. The API token is only ever read from the environment. +func (o *commonOptions) client() (*api.AssetsClient, error) { + token := os.Getenv("ATLASSIAN_API_TOKEN") + if token == "" { + return nil, fmt.Errorf("ATLASSIAN_API_TOKEN is not set — Assets uses Basic auth, not atl's OAuth login; create a token at https://id.atlassian.com/manage-profile/security/api-tokens") + } + + email := o.Email + if email == "" { + email = os.Getenv("ATLASSIAN_EMAIL") + } + workspace := o.Workspace + if workspace == "" { + workspace = os.Getenv("ATLASSIAN_ASSETS_WORKSPACE") + } + + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("loading atl config: %w", err) + } + siteBase := "" + if hc := cfg.CurrentHostConfig(); hc != nil { + if email == "" { + email = hc.User + } + proto := hc.Protocol + if proto == "" { + proto = "https" + } + if hc.Hostname != "" { + siteBase = proto + "://" + hc.Hostname + } + } + + if email == "" { + return nil, fmt.Errorf("no account email — pass --email, set $ATLASSIAN_EMAIL, or log in to a host that records a user") + } + + return api.NewAssetsClient(siteBase, email, token, workspace), nil +} + +// NewCmdAssets creates the assets command group. +func NewCmdAssets(ios *iostreams.IOStreams) *cobra.Command { + opts := &commonOptions{} + + cmd := &cobra.Command{ + Use: "assets", + Short: "Work with Jira Assets (CMDB)", + Long: `Query the Jira Service Management Assets (CMDB) workspace. + +Assets has its own API and uses Basic auth rather than atl's OAuth login +(the granular OAuth scopes do not cover CMDB objects). Set ATLASSIAN_API_TOKEN; +the account email and site default to your current atl host, and the workspace +id is auto-discovered if not supplied.`, + } + + opts.addFlags(cmd) + cmd.AddCommand(newCmdCount(ios, opts)) + cmd.AddCommand(newCmdAQL(ios, opts)) + + return cmd +} diff --git a/internal/cmd/assets/count.go b/internal/cmd/assets/count.go new file mode 100644 index 0000000..4be6548 --- /dev/null +++ b/internal/cmd/assets/count.go @@ -0,0 +1,58 @@ +package assets + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +func newCmdCount(ios *iostreams.IOStreams, common *commonOptions) *cobra.Command { + var jsonOut bool + + cmd := &cobra.Command{ + Use: "count", + Short: "Show object counts per schema and the workspace total", + Long: `List every object schema with its current object count, plus the +workspace-wide total — useful for tracking how close the workspace is to the +Assets object limit.`, + Example: ` atl assets count + atl assets count --json`, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := common.client() + if err != nil { + return err + } + + schemas, err := client.Schemas(cmd.Context()) + if err != nil { + return err + } + + total := 0 + for _, s := range schemas { + total += s.ObjectCount + } + + if jsonOut { + return output.JSON(ios.Out, map[string]any{ + "schemas": schemas, + "total": total, + }) + } + + rows := make([][]string, 0, len(schemas)+1) + for _, s := range schemas { + rows = append(rows, []string{s.ObjectSchemaKey, s.Name, fmt.Sprint(s.ObjectCount), fmt.Sprint(s.ObjectTypeCount)}) + } + rows = append(rows, []string{"", "TOTAL", fmt.Sprint(total), ""}) + output.SimpleTable(ios.Out, []string{"KEY", "SCHEMA", "OBJECTS", "TYPES"}, rows) + return nil + }, + } + + cmd.Flags().BoolVarP(&jsonOut, "json", "j", false, "Output as JSON") + return cmd +} diff --git a/internal/cmd/jira/jira.go b/internal/cmd/jira/jira.go index 7dedc18..2d4769d 100644 --- a/internal/cmd/jira/jira.go +++ b/internal/cmd/jira/jira.go @@ -3,6 +3,7 @@ package jira import ( "github.com/spf13/cobra" + assetsCmd "github.com/enthus-appdev/atl-cli/internal/cmd/assets" boardCmd "github.com/enthus-appdev/atl-cli/internal/cmd/board" issueCmd "github.com/enthus-appdev/atl-cli/internal/cmd/issue" smCmd "github.com/enthus-appdev/atl-cli/internal/cmd/sm" @@ -23,6 +24,7 @@ func NewCmdJira(ios *iostreams.IOStreams) *cobra.Command { cmd.AddCommand(boardCmd.NewCmdBoard(ios)) cmd.AddCommand(smCmd.NewCmdSM(ios)) cmd.AddCommand(sprintCmd.NewCmdSprint(ios)) + cmd.AddCommand(assetsCmd.NewCmdAssets(ios)) return cmd }