package glpi import ( "context" "encoding/json" "fmt" "net/http" "net/url" ) // Change status constants (GLPI change lifecycle). const ( StatusNew = 1 StatusEvaluation = 2 StatusApproval = 3 StatusTest = 4 StatusQualification = 5 StatusWaiting = 6 StatusAccepted = 7 StatusAssigned = 8 StatusPlanned = 9 StatusPending = 10 StatusSolved = 11 StatusClosed = 12 ) // statusNames maps status ids to human names (CLI display + name-based // transitions; GLPI has no enumeration endpoint for these). var statusNames = map[int]string{ StatusNew: "new", StatusEvaluation: "evaluation", StatusApproval: "approval", StatusTest: "test", StatusQualification: "qualification", StatusWaiting: "waiting", StatusAccepted: "accepted", StatusAssigned: "assigned", StatusPlanned: "planned", StatusPending: "pending", StatusSolved: "solved", StatusClosed: "closed", } // StatusName renders a status id ("new", "solved", ...) or "status N". func StatusName(id int) string { if n, ok := statusNames[id]; ok { return n } return fmt.Sprintf("status %d", id) } // StatusID resolves a status name case-insensitively ("Solved" -> 11). func StatusID(name string) (int, bool) { for id, n := range statusNames { if eqfold(n, name) { return id, true } } return 0, false } // Change is GLPI's read shape for one change (GET /change/). type Change struct { ID int `json:"id"` Name string `json:"name"` Content string `json:"content"` Status int `json:"status"` Urgency int `json:"urgency"` Impact int `json:"impact"` Date string `json:"date"` DateMod string `json:"date_mod"` } // ChangeRow is one row of a change search (fields 1 name, 2 id, // 12 status). type ChangeRow struct { ID int `json:"id"` Name string `json:"name"` Status int `json:"status"` } // CreateChange creates a change (POST /change/) and returns its id. // // GLPI contract modeled faithfully: the payload is {"input":{...}} — an // OBJECT (the ITILFollowup endpoint is the array-flavored one) — and // the reply is an ARRAY: [{"id":N,"message":"..."}]. urgency and impact // are GLPI's 1..5 scales (3 = medium). func (c *Client) CreateChange(ctx context.Context, name, content string, urgency, impact int) (int, error) { if name == "" { return 0, fmt.Errorf("%w: change name required", ErrValidation) } if urgency < 1 || urgency > 5 || impact < 1 || impact > 5 { return 0, fmt.Errorf("%w: urgency/impact must be 1..5", ErrValidation) } input := map[string]any{ "name": name, "content": content, } if urgency != 0 { input["urgency"] = urgency } if impact != 0 { input["impact"] = impact } ids, err := c.postCreate(ctx, "/change", map[string]any{"input": input}) if err != nil { return 0, err } if len(ids) == 0 || ids[0] == 0 { return 0, fmt.Errorf("%w: create returned no id", ErrMalformedResponse) } return ids[0], nil } // postCreate sends a create payload and parses GLPI's ARRAY reply // [{"id":N,"message":"..."}] into ids. func (c *Client) postCreate(ctx context.Context, path string, payload any) ([]int, error) { var arr []struct { ID int `json:"id"` Message string `json:"message"` } if err := c.call(ctx, http.MethodPost, path, nil, payload, &arr); err != nil { return nil, err } ids := make([]int, 0, len(arr)) for _, a := range arr { ids = append(ids, a.ID) } return ids, nil } // GetChange fetches one change (GET /change/). func (c *Client) GetChange(ctx context.Context, id int) (*Change, error) { var ch Change if err := c.call(ctx, http.MethodGet, "/change/"+itoa(id), nil, nil, &ch); err != nil { return nil, err } return &ch, nil } // ListChanges searches changes (GET /search/Change/) with forcedisplay // fields 1 (name), 2 (id), 12 (status). GLPI returns rows as OBJECTS // KEYED BY FIELD-ID STRING — parsed here into ChangeRow. status 0 lists // all; any other value filters field 12 with an equals criterion. func (c *Client) ListChanges(ctx context.Context, status int) ([]ChangeRow, error) { q := url.Values{} forced := []string{"1", "2", "12"} for _, f := range forced { q.Add("forcedisplay[]", f) } if status > 0 { crit, err := json.Marshal([]map[string]any{{ "field": 12, "searchtype": "equals", "value": status, }}) if err != nil { return nil, fmt.Errorf("%w: cannot encode criteria", ErrMalformedResponse) } q.Set("criteria", string(crit)) } var body struct { Total int `json:"totalcount"` Data []map[string]any `json:"data"` } if err := c.call(ctx, http.MethodGet, "/search/Change", q, nil, &body); err != nil { return nil, err } rows := make([]ChangeRow, 0, len(body.Data)) for _, d := range body.Data { rows = append(rows, ChangeRow{ Name: asString(d["1"]), ID: asInt(d["2"]), Status: asInt(d["12"]), }) } return rows, nil } // TransitionChange moves a change to a status (PUT /change/ with // {"input":{"status":N}}). GLPI answers 200 with an array whose members // carry a boolean result — a false anywhere is a failed update. func (c *Client) TransitionChange(ctx context.Context, id, status int) error { if status < StatusNew || status > StatusClosed { return fmt.Errorf("%w: unknown change status %d", ErrValidation, status) } payload := map[string]any{"input": map[string]any{"status": status}} var arr []map[string]any if err := c.call(ctx, http.MethodPut, "/change/"+itoa(id), nil, payload, &arr); err != nil { return err } for _, m := range arr { for _, v := range m { if b, ok := v.(bool); ok && !b { return fmt.Errorf("%w: transition rejected", ErrValidation) } } } return nil } // AddFollowup appends a followup note to a change (POST /ITILFollowup). // // GLPI contract modeled faithfully: this endpoint REQUIRES the input to // be an ARRAY of objects — {"input":[{...}]} — where the Change-create // endpoint takes the object form. func (c *Client) AddFollowup(ctx context.Context, changeID int, content string) error { if content == "" { return fmt.Errorf("%w: followup content required", ErrValidation) } payload := map[string]any{ "input": []map[string]any{{ "itemtype": "Change", "items_id": changeID, "content": content, }}, } _, err := c.postCreate(ctx, "/ITILFollowup", payload) return err } // asString coerces a search-row cell (string or number) to string. func asString(v any) string { switch s := v.(type) { case string: return s case float64: return trimFloat(s) case nil: return "" default: return fmt.Sprint(v) } } // asInt coerces a search-row cell (number or numeric string) to int. // GLPI search cells arrive as JSON numbers or strings depending on the // field; both must parse. func asInt(v any) int { switch n := v.(type) { case float64: return int(n) case int: return n case string: i, _ := parseLeadingInt(n) return i case nil: return 0 default: return 0 } }