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
132 changes: 132 additions & 0 deletions internal/api/sm.go
Original file line number Diff line number Diff line change
@@ -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
}
189 changes: 189 additions & 0 deletions internal/api/sm_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions internal/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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))

Expand Down
Loading
Loading