diff --git a/internal/api/sm.go b/internal/api/sm.go new file mode 100644 index 0000000..fa4b6ac --- /dev/null +++ b/internal/api/sm.go @@ -0,0 +1,132 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" +) + +// SMService handles Jira Service Management API operations. +type SMService struct { + client *Client +} + +// NewSMService creates a new Service Management service. +func NewSMService(client *Client) *SMService { + return &SMService{client: client} +} + +// SMBaseURL returns the base URL for JSM API requests. +func (c *Client) SMBaseURL() string { + return fmt.Sprintf("%s/ex/jira/%s/rest/servicedeskapi", AtlassianAPIURL, c.cloudID) +} + +// ServiceDesk represents a Jira Service Management service desk. +type ServiceDesk struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + ProjectKey string `json:"projectKey"` +} + +// ServiceDeskPage represents a paginated list of service desks. +type ServiceDeskPage struct { + Size int `json:"size"` + Start int `json:"start"` + Limit int `json:"limit"` + IsLastPage bool `json:"isLastPage"` + Values []*ServiceDesk `json:"values"` +} + +// GetServiceDesks lists all service desks. +func (s *SMService) GetServiceDesks(ctx context.Context) ([]*ServiceDesk, error) { + path := fmt.Sprintf("%s/servicedesk", s.client.SMBaseURL()) + + var result ServiceDeskPage + if err := s.client.Get(ctx, path, &result); err != nil { + return nil, err + } + + return result.Values, nil +} + +// RequestType represents a request type in a service desk. +type RequestType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ServiceDesk *struct { + ID string `json:"id"` + } `json:"serviceDeskId,omitempty"` +} + +// RequestTypePage represents a paginated list of request types. +type RequestTypePage struct { + Size int `json:"size"` + Start int `json:"start"` + Limit int `json:"limit"` + IsLastPage bool `json:"isLastPage"` + Values []*RequestType `json:"values"` +} + +// GetRequestTypes lists request types for a service desk. +func (s *SMService) GetRequestTypes(ctx context.Context, serviceDeskID int) ([]*RequestType, error) { + path := fmt.Sprintf("%s/servicedesk/%d/requesttype", s.client.SMBaseURL(), serviceDeskID) + + var result RequestTypePage + if err := s.client.Get(ctx, path, &result); err != nil { + return nil, err + } + + return result.Values, nil +} + +// RequestTypeField represents a field in a request type. +type RequestTypeField struct { + FieldID string `json:"fieldId"` + Name string `json:"name"` + Description string `json:"description"` + Required bool `json:"required"` + DefaultValue []json.RawMessage `json:"defaultValues"` + ValidValues []*RequestTypeValue `json:"validValues"` + PresetValues []string `json:"presetValues"` + JiraSchema *RequestTypeSchema `json:"jiraSchema,omitempty"` + Visible bool `json:"visible"` +} + +// RequestTypeValue represents a valid value for a request type field. +type RequestTypeValue struct { + Value string `json:"value"` + Label string `json:"label"` + Children []*RequestTypeValue `json:"children"` +} + +// RequestTypeSchema represents the Jira schema of a request type field. +type RequestTypeSchema struct { + Type string `json:"type"` + Items string `json:"items,omitempty"` + System string `json:"system,omitempty"` + Custom string `json:"custom,omitempty"` + CustomID int `json:"customId,omitempty"` + Configuration map[string]interface{} `json:"configuration,omitempty"` +} + +// RequestTypeFieldsResponse represents the response from the request type fields endpoint. +type RequestTypeFieldsResponse struct { + RequestTypeFields []*RequestTypeField `json:"requestTypeFields"` + CanRaiseOnBehalfOf bool `json:"canRaiseOnBehalfOf"` + CanAddRequestParticipants bool `json:"canAddRequestParticipants"` +} + +// GetRequestTypeFields gets the fields for a request type. +func (s *SMService) GetRequestTypeFields(ctx context.Context, serviceDeskID, requestTypeID int) (*RequestTypeFieldsResponse, error) { + path := fmt.Sprintf("%s/servicedesk/%d/requesttype/%d/field", + s.client.SMBaseURL(), serviceDeskID, requestTypeID) + + var result RequestTypeFieldsResponse + if err := s.client.Get(ctx, path, &result); err != nil { + return nil, err + } + + return &result, nil +} diff --git a/internal/api/sm_test.go b/internal/api/sm_test.go new file mode 100644 index 0000000..328e823 --- /dev/null +++ b/internal/api/sm_test.go @@ -0,0 +1,189 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/enthus-appdev/atl-cli/internal/auth" +) + +func newTestSMClient(server *httptest.Server) *Client { + return &Client{ + httpClient: server.Client(), + cloudID: "test-cloud", + tokens: &auth.TokenSet{ + AccessToken: "test-token", + ExpiresAt: time.Now().Add(time.Hour), + }, + } +} + +func TestSMBaseURL(t *testing.T) { + client := &Client{cloudID: "abc-123"} + want := "https://api.atlassian.com/ex/jira/abc-123/rest/servicedeskapi" + got := client.SMBaseURL() + if got != want { + t.Errorf("SMBaseURL() = %q, want %q", got, want) + } +} + +func TestGetServiceDesks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ServiceDeskPage{ + Size: 2, Start: 0, Limit: 50, IsLastPage: true, + Values: []*ServiceDesk{ + {ID: "1", ProjectID: "10001", ProjectName: "IT Support", ProjectKey: "ITS"}, + {ID: "2", ProjectID: "10002", ProjectName: "HR", ProjectKey: "HR"}, + }, + }) + })) + defer server.Close() + + client := newTestSMClient(server) + sm := NewSMService(client) + + // Call directly via the client.Get to use server URL + var result ServiceDeskPage + err := client.Get(context.Background(), server.URL+"/servicedesk", &result) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if len(result.Values) != 2 { + t.Errorf("got %d desks, want 2", len(result.Values)) + } + if result.Values[0].ProjectKey != "ITS" { + t.Errorf("first desk key = %q, want ITS", result.Values[0].ProjectKey) + } + _ = sm // ensure service is usable +} + +func TestGetRequestTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(RequestTypePage{ + Size: 2, Start: 0, Limit: 50, IsLastPage: true, + Values: []*RequestType{ + {ID: "10", Name: "Incident", Description: "Report an incident"}, + {ID: "26", Name: "Service Delivery", Description: "DL ticket creation"}, + }, + }) + })) + defer server.Close() + + client := newTestSMClient(server) + + var result RequestTypePage + err := client.Get(context.Background(), server.URL+"/requesttype", &result) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if len(result.Values) != 2 { + t.Errorf("got %d types, want 2", len(result.Values)) + } + if result.Values[1].Name != "Service Delivery" { + t.Errorf("second type name = %q, want Service Delivery", result.Values[1].Name) + } +} + +func TestGetRequestTypeFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "requestTypeFields": []map[string]any{ + { + "fieldId": "summary", "name": "Title", "required": true, "visible": true, + "jiraSchema": map[string]any{"type": "string", "system": "summary"}, + }, + { + "fieldId": "customfield_10038", "name": "Tempo Team", "required": false, "visible": true, + "jiraSchema": map[string]any{ + "type": "object", "customId": 10038, + "custom": "ari:cloud:ecosystem::extension/tempo-team", + "configuration": map[string]any{"customRenderer": true, "readOnly": false, "environment": "PRODUCTION"}, + }, + }, + }, + "canRaiseOnBehalfOf": true, + "canAddRequestParticipants": true, + }) + })) + defer server.Close() + + client := newTestSMClient(server) + + var result RequestTypeFieldsResponse + err := client.Get(context.Background(), server.URL+"/field", &result) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if len(result.RequestTypeFields) != 2 { + t.Fatalf("got %d fields, want 2", len(result.RequestTypeFields)) + } + + // Verify first field + if result.RequestTypeFields[0].FieldID != "summary" { + t.Errorf("first field ID = %q, want summary", result.RequestTypeFields[0].FieldID) + } + if !result.RequestTypeFields[0].Required { + t.Error("summary should be required") + } + + // Verify second field has schema with mixed-type configuration (the NX-15519 bug scenario) + tempoField := result.RequestTypeFields[1] + if tempoField.FieldID != "customfield_10038" { + t.Errorf("second field ID = %q, want customfield_10038", tempoField.FieldID) + } + if tempoField.JiraSchema == nil { + t.Fatal("Tempo Team field should have jiraSchema") + } + if tempoField.JiraSchema.Type != "object" { + t.Errorf("schema type = %q, want object", tempoField.JiraSchema.Type) + } + + // Key assertion: configuration with booleans deserializes correctly into map[string]interface{} + conf := tempoField.JiraSchema.Configuration + if conf == nil { + t.Fatal("configuration should not be nil") + } + if v, ok := conf["customRenderer"]; !ok || v != true { + t.Errorf("configuration[customRenderer] = %v, want true", v) + } + if v, ok := conf["readOnly"]; !ok || v != false { + t.Errorf("configuration[readOnly] = %v, want false", v) + } + if v, ok := conf["environment"]; !ok || v != "PRODUCTION" { + t.Errorf("configuration[environment] = %v, want PRODUCTION", v) + } +} + +func TestGetRequestTypeFields_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"code":401,"message":"Unauthorized; scope does not match"}`)) + })) + defer server.Close() + + client := newTestSMClient(server) + + var result RequestTypeFieldsResponse + err := client.Get(context.Background(), server.URL+"/field", &result) + if err == nil { + t.Fatal("expected error for 401 response") + } + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("error should be *APIError, got %T", err) + } + if apiErr.StatusCode != 401 { + t.Errorf("status code = %d, want 401", apiErr.StatusCode) + } +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 866a1cf..e99832e 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -71,6 +71,9 @@ func DefaultScopes() []string { // Confluence template scopes (v1 API) "read:template:confluence", "write:template:confluence", + // Jira Service Management scopes - for JSM REST API (read-only) + "read:servicedesk", + "read:servicedesk-request", // Token refresh "offline_access", } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 143d21b..5216c32 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -11,6 +11,7 @@ import ( configCmd "github.com/enthus-appdev/atl-cli/internal/cmd/config" confluenceCmd "github.com/enthus-appdev/atl-cli/internal/cmd/confluence" issueCmd "github.com/enthus-appdev/atl-cli/internal/cmd/issue" + smCmd "github.com/enthus-appdev/atl-cli/internal/cmd/sm" "github.com/enthus-appdev/atl-cli/internal/iostreams" ) @@ -60,6 +61,7 @@ Environment variables: cmd.AddCommand(boardCmd.NewCmdBoard(ios)) cmd.AddCommand(confluenceCmd.NewCmdConfluence(ios)) cmd.AddCommand(configCmd.NewCmdConfig(ios)) + cmd.AddCommand(smCmd.NewCmdSM(ios)) cmd.AddCommand(newVersionCmd(ios, version, commit, date)) cmd.AddCommand(newCompletionCmd(ios)) diff --git a/internal/cmd/sm/requesttype.go b/internal/cmd/sm/requesttype.go new file mode 100644 index 0000000..05b5fce --- /dev/null +++ b/internal/cmd/sm/requesttype.go @@ -0,0 +1,175 @@ +package sm + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/api" + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +// NewCmdRequestType creates the request-type command group. +func NewCmdRequestType(ios *iostreams.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "request-type", + Aliases: []string{"rt"}, + Short: "Work with request types", + } + + cmd.AddCommand(newCmdRequestTypeList(ios)) + cmd.AddCommand(newCmdRequestTypeFields(ios)) + + return cmd +} + +// ── request-type list ────────────────────────────────────────────── + +// RequestTypeListOptions holds options for listing request types. +type RequestTypeListOptions struct { + IO *iostreams.IOStreams + ServiceDeskID int + JSON bool +} + +func newCmdRequestTypeList(ios *iostreams.IOStreams) *cobra.Command { + opts := &RequestTypeListOptions{IO: ios} + + cmd := &cobra.Command{ + Use: "list", + Short: "List request types for a service desk", + Example: ` # List request types for service desk 2 + atl sm request-type list --service-desk-id 2`, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.ServiceDeskID == 0 { + return fmt.Errorf("--service-desk-id is required") + } + return runRequestTypeList(opts) + }, + } + + cmd.Flags().IntVar(&opts.ServiceDeskID, "service-desk-id", 0, "Service desk ID (required)") + cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") + + return cmd +} + +func runRequestTypeList(opts *RequestTypeListOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + sm := api.NewSMService(client) + + types, err := sm.GetRequestTypes(ctx, opts.ServiceDeskID) + if err != nil { + return fmt.Errorf("failed to list request types: %w", err) + } + + if opts.JSON { + return output.JSON(opts.IO.Out, types) + } + + headers := []string{"ID", "Name", "Description"} + rows := make([][]string, 0, len(types)) + for _, t := range types { + desc := t.Description + if len(desc) > 60 { + desc = desc[:57] + "..." + } + rows = append(rows, []string{t.ID, t.Name, desc}) + } + + output.SimpleTable(opts.IO.Out, headers, rows) + return nil +} + +// ── request-type fields ──────────────────────────────────────────── + +// RequestTypeFieldsOptions holds options for listing request type fields. +type RequestTypeFieldsOptions struct { + IO *iostreams.IOStreams + ServiceDeskID int + RequestTypeID int + JSON bool +} + +func newCmdRequestTypeFields(ios *iostreams.IOStreams) *cobra.Command { + opts := &RequestTypeFieldsOptions{IO: ios} + + cmd := &cobra.Command{ + Use: "fields", + Short: "List fields for a request type", + Example: ` # List fields for request type 26 on service desk 2 + atl sm request-type fields --service-desk-id 2 --request-type-id 26`, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.ServiceDeskID == 0 { + return fmt.Errorf("--service-desk-id is required") + } + if opts.RequestTypeID == 0 { + return fmt.Errorf("--request-type-id is required") + } + return runRequestTypeFields(opts) + }, + } + + cmd.Flags().IntVar(&opts.ServiceDeskID, "service-desk-id", 0, "Service desk ID (required)") + cmd.Flags().IntVar(&opts.RequestTypeID, "request-type-id", 0, "Request type ID (required)") + cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") + + return cmd +} + +// RequestTypeFieldOutput is the structured output for a request type field. +type RequestTypeFieldOutput struct { + FieldID string `json:"field_id"` + Name string `json:"name"` + Required bool `json:"required"` + Visible bool `json:"visible"` + Type string `json:"type,omitempty"` + Custom string `json:"custom,omitempty"` + CustomID int `json:"custom_id,omitempty"` + Description string `json:"description,omitempty"` +} + +func runRequestTypeFields(opts *RequestTypeFieldsOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + sm := api.NewSMService(client) + + result, err := sm.GetRequestTypeFields(ctx, opts.ServiceDeskID, opts.RequestTypeID) + if err != nil { + return fmt.Errorf("failed to get request type fields: %w", err) + } + + if opts.JSON { + return output.JSON(opts.IO.Out, result) + } + + headers := []string{"Field ID", "Name", "Required", "Type", "Custom"} + rows := make([][]string, 0, len(result.RequestTypeFields)) + for _, f := range result.RequestTypeFields { + required := "" + if f.Required { + required = "*" + } + fieldType := "" + custom := "" + if f.JiraSchema != nil { + fieldType = f.JiraSchema.Type + custom = f.JiraSchema.Custom + } + rows = append(rows, []string{f.FieldID, f.Name, required, fieldType, custom}) + } + + output.SimpleTable(opts.IO.Out, headers, rows) + return nil +} diff --git a/internal/cmd/sm/servicedesk.go b/internal/cmd/sm/servicedesk.go new file mode 100644 index 0000000..9ca6ef6 --- /dev/null +++ b/internal/cmd/sm/servicedesk.go @@ -0,0 +1,75 @@ +package sm + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/api" + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +// NewCmdServiceDesk creates the service-desk command group. +func NewCmdServiceDesk(ios *iostreams.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "service-desk", + Aliases: []string{"sd"}, + Short: "Work with service desks", + } + + cmd.AddCommand(newCmdServiceDeskList(ios)) + + return cmd +} + +// ServiceDeskListOptions holds options for listing service desks. +type ServiceDeskListOptions struct { + IO *iostreams.IOStreams + JSON bool +} + +func newCmdServiceDeskList(ios *iostreams.IOStreams) *cobra.Command { + opts := &ServiceDeskListOptions{IO: ios} + + cmd := &cobra.Command{ + Use: "list", + Short: "List service desks", + RunE: func(cmd *cobra.Command, args []string) error { + return runServiceDeskList(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") + + return cmd +} + +func runServiceDeskList(opts *ServiceDeskListOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + sm := api.NewSMService(client) + + desks, err := sm.GetServiceDesks(ctx) + if err != nil { + return fmt.Errorf("failed to list service desks: %w", err) + } + + if opts.JSON { + return output.JSON(opts.IO.Out, desks) + } + + headers := []string{"ID", "Project Key", "Project Name"} + rows := make([][]string, 0, len(desks)) + for _, d := range desks { + rows = append(rows, []string{d.ID, d.ProjectKey, d.ProjectName}) + } + + output.SimpleTable(opts.IO.Out, headers, rows) + return nil +} diff --git a/internal/cmd/sm/sm.go b/internal/cmd/sm/sm.go new file mode 100644 index 0000000..2fbe578 --- /dev/null +++ b/internal/cmd/sm/sm.go @@ -0,0 +1,22 @@ +package sm + +import ( + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/iostreams" +) + +// NewCmdSM creates the service management command group. +func NewCmdSM(ios *iostreams.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "sm", + Aliases: []string{"service-management"}, + Short: "Work with Jira Service Management", + Long: `List service desks, request types, and their fields.`, + } + + cmd.AddCommand(NewCmdServiceDesk(ios)) + cmd.AddCommand(NewCmdRequestType(ios)) + + return cmd +}