feat: v0 — glpi-go client, mglpi CLI, mglpi-mcp, fake-GLPI test suite [#767]
https://projects.knownelement.com/issues/767#note-4191
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.knownelement.com/ukrrs/mopac-glpi-go/glpi"
|
||||
)
|
||||
|
||||
func marshalIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }
|
||||
|
||||
// --- whoami -----------------------------------------------------------------
|
||||
|
||||
// cmdWhoami lists the session's profiles. It NEVER switches the active
|
||||
// profile (agent mode's MGLPI_PROFILE_ID is deliberately not applied).
|
||||
func cmdWhoami(args []string, stdout, stderr io.Writer) int {
|
||||
fs, cfgPath, out := newFlags("whoami", stderr)
|
||||
pos, err := parseArgs(fs, args)
|
||||
if err != nil || len(pos) != 0 {
|
||||
return usageErr(stderr, "whoami: usage: mglpi whoami")
|
||||
}
|
||||
c, _, err := client(*cfgPath, 0)
|
||||
if err != nil {
|
||||
return usageErr(stderr, "%v", err)
|
||||
}
|
||||
profs, err := c.GetMyProfiles(context.Background())
|
||||
if err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
if *out == "json" {
|
||||
emitJSON(stdout, map[string]any{"profiles": profs})
|
||||
return 0
|
||||
}
|
||||
for _, p := range profs {
|
||||
line := fmt.Sprintf("%-6d %s", p.ID, p.Name)
|
||||
if p.IsActive {
|
||||
line += " [active]"
|
||||
}
|
||||
fmt.Fprintln(stdout, line)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// --- change -------------------------------------------------------------------
|
||||
|
||||
func cmdChange(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
return usageErr(stderr, "change: expected list|show|create|transition|followup (see mglpi help)")
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
case "list":
|
||||
return cmdChangeList(rest, stdout, stderr)
|
||||
case "show":
|
||||
return cmdChangeShow(rest, stdout, stderr)
|
||||
case "create":
|
||||
return cmdChangeCreate(rest, stdout, stderr)
|
||||
case "transition":
|
||||
return cmdChangeTransition(rest, stdout, stderr)
|
||||
case "followup":
|
||||
return cmdChangeFollowup(rest, stdout, stderr)
|
||||
default:
|
||||
return usageErr(stderr, "change: unknown subcommand %q", sub)
|
||||
}
|
||||
}
|
||||
|
||||
// withClient is the common preamble: resolve config + profile, switch
|
||||
// profile when one is set (an API op -> exit 2 on failure), and hand
|
||||
// the client over.
|
||||
func withClient(stderr io.Writer, cfgPath string, profileFlag int, fn func(c *glpi.Client, ctx context.Context) int) int {
|
||||
c, profile, err := client(cfgPath, profileFlag)
|
||||
if err != nil {
|
||||
return usageErr(stderr, "%v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if profile > 0 {
|
||||
if err := c.ChangeActiveProfile(ctx, profile); err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
}
|
||||
return fn(c, ctx)
|
||||
}
|
||||
|
||||
func cmdChangeList(args []string, stdout, stderr io.Writer) int {
|
||||
fs, cfgPath, out := newFlags("change list", stderr)
|
||||
status := fs.String("status", "", "filter by status name or numeric id (e.g. new, solved, 1)")
|
||||
pos, err := parseArgs(fs, args)
|
||||
if err != nil || len(pos) != 0 {
|
||||
return usageErr(stderr, "change list: usage: mglpi change list [--status N|NAME]")
|
||||
}
|
||||
want := 0
|
||||
if *status != "" {
|
||||
if id, ok := glpi.StatusID(*status); ok {
|
||||
want = id
|
||||
} else {
|
||||
n, err := strconv.Atoi(*status)
|
||||
if err != nil || n <= 0 {
|
||||
return usageErr(stderr, "change list: --status must be a status name or positive number, got %q", *status)
|
||||
}
|
||||
want = n
|
||||
}
|
||||
}
|
||||
return withClient(stderr, *cfgPath, 0, func(c *glpi.Client, ctx context.Context) int {
|
||||
rows, err := c.ListChanges(ctx, want)
|
||||
if err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
if *out == "json" {
|
||||
emitJSON(stdout, map[string]any{"changes": rows})
|
||||
return 0
|
||||
}
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(stdout, "%-6d %-12s %s\n", r.ID, glpi.StatusName(r.Status), r.Name)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
func cmdChangeShow(args []string, stdout, stderr io.Writer) int {
|
||||
fs, cfgPath, out := newFlags("change show", stderr)
|
||||
pos, err := parseArgs(fs, args)
|
||||
if err != nil || len(pos) != 1 {
|
||||
return usageErr(stderr, "change show: usage: mglpi change show ID")
|
||||
}
|
||||
id, err := strconv.Atoi(pos[0])
|
||||
if err != nil {
|
||||
return usageErr(stderr, "change show: ID must be numeric, got %q", pos[0])
|
||||
}
|
||||
return withClient(stderr, *cfgPath, 0, func(c *glpi.Client, ctx context.Context) int {
|
||||
ch, err := c.GetChange(ctx, id)
|
||||
if err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
if *out == "json" {
|
||||
emitJSON(stdout, map[string]any{"change": ch})
|
||||
return 0
|
||||
}
|
||||
printChange(stdout, ch)
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
func printChange(w io.Writer, ch *glpi.Change) {
|
||||
fmt.Fprintf(w, "#%d %s\n", ch.ID, ch.Name)
|
||||
fmt.Fprintf(w, "Status: %-12s Urgency: %d Impact: %d\n", glpi.StatusName(ch.Status), ch.Urgency, ch.Impact)
|
||||
fmt.Fprintf(w, "Created: %s Updated: %s\n", ch.Date, ch.DateMod)
|
||||
if ch.Content != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, ch.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdChangeCreate(args []string, stdout, stderr io.Writer) int {
|
||||
fs, cfgPath, out := newFlags("change create", stderr)
|
||||
title := fs.String("title", "", "change title (required)")
|
||||
content := fs.String("content", "-", "content body from FILE, or - for stdin")
|
||||
urgency := fs.Int("urgency", 3, "urgency 1..5 (3 = medium)")
|
||||
impact := fs.Int("impact", 3, "impact 1..5 (3 = medium)")
|
||||
profile := fs.Int("profile", 0, "agent profile id to switch to after InitSession (e.g. 5 = Hotliner)")
|
||||
pos, err := parseArgs(fs, args)
|
||||
if err != nil || len(pos) != 0 || *title == "" {
|
||||
return usageErr(stderr, "change create: usage: mglpi change create --title T [--urgency N] [--impact N] [--profile N] [--content FILE|-]")
|
||||
}
|
||||
if *urgency < 1 || *urgency > 5 || *impact < 1 || *impact > 5 {
|
||||
return usageErr(stderr, "change create: --urgency/--impact must be 1..5")
|
||||
}
|
||||
body := ""
|
||||
if *content != "" {
|
||||
text, err := readBody(*content)
|
||||
if err != nil {
|
||||
return usageErr(stderr, "%v", err)
|
||||
}
|
||||
body = text
|
||||
}
|
||||
return withClient(stderr, *cfgPath, *profile, func(c *glpi.Client, ctx context.Context) int {
|
||||
id, err := c.CreateChange(ctx, *title, body, *urgency, *impact)
|
||||
if err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
if *out == "json" {
|
||||
emitJSON(stdout, map[string]any{"change": map[string]any{"id": id}})
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stdout, "created change #%d\n", id)
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
func cmdChangeTransition(args []string, stdout, stderr io.Writer) int {
|
||||
fs, cfgPath, out := newFlags("change transition", stderr)
|
||||
pos, err := parseArgs(fs, args)
|
||||
if err != nil || len(pos) != 2 {
|
||||
return usageErr(stderr, "change transition: usage: mglpi change transition ID STATUS")
|
||||
}
|
||||
id, err := strconv.Atoi(pos[0])
|
||||
if err != nil {
|
||||
return usageErr(stderr, "change transition: ID must be numeric, got %q", pos[0])
|
||||
}
|
||||
status, ok := glpi.StatusID(pos[1])
|
||||
if !ok {
|
||||
status, err = strconv.Atoi(pos[1])
|
||||
if err != nil || status <= 0 {
|
||||
return usageErr(stderr, "change transition: STATUS must be a status name or positive number, got %q", pos[1])
|
||||
}
|
||||
}
|
||||
return withClient(stderr, *cfgPath, 0, func(c *glpi.Client, ctx context.Context) int {
|
||||
if err := c.TransitionChange(ctx, id, status); err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
if *out == "json" {
|
||||
ch, err := c.GetChange(ctx, id)
|
||||
if err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
emitJSON(stdout, map[string]any{"change": ch})
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stdout, "updated change #%d (status %s)\n", id, glpi.StatusName(status))
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
func cmdChangeFollowup(args []string, stdout, stderr io.Writer) int {
|
||||
fs, cfgPath, out := newFlags("change followup", stderr)
|
||||
content := fs.String("content", "-", "followup body from FILE, or - for stdin")
|
||||
profile := fs.Int("profile", 0, "agent profile id to switch to after InitSession")
|
||||
pos, err := parseArgs(fs, args)
|
||||
if err != nil || len(pos) != 1 {
|
||||
return usageErr(stderr, "change followup: usage: mglpi change followup ID [--content FILE|-]")
|
||||
}
|
||||
id, err := strconv.Atoi(pos[0])
|
||||
if err != nil {
|
||||
return usageErr(stderr, "change followup: ID must be numeric, got %q", pos[0])
|
||||
}
|
||||
body := ""
|
||||
if *content != "" {
|
||||
text, err := readBody(*content)
|
||||
if err != nil {
|
||||
return usageErr(stderr, "%v", err)
|
||||
}
|
||||
body = text
|
||||
}
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return usageErr(stderr, "change followup: content is required (stdin or --content FILE)")
|
||||
}
|
||||
return withClient(stderr, *cfgPath, *profile, func(c *glpi.Client, ctx context.Context) int {
|
||||
if err := c.AddFollowup(ctx, id, body); err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
if *out == "json" {
|
||||
emitJSON(stdout, map[string]any{"followup": map[string]any{"items_id": id}})
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stdout, "added followup to change #%d\n", id)
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// readBody loads a body from a file, or from stdin when path is "-".
|
||||
func readBody(path string) (string, error) {
|
||||
if path == "-" {
|
||||
b, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("content: cannot read stdin: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("content: cannot read %s", path)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// --- ci ----------------------------------------------------------------------
|
||||
|
||||
func cmdCI(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
return usageErr(stderr, "ci: expected search|show (see mglpi help)")
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
case "search":
|
||||
return cmdCISearch(rest, stdout, stderr)
|
||||
case "show":
|
||||
return cmdCIShow(rest, stdout, stderr)
|
||||
default:
|
||||
return usageErr(stderr, "ci: unknown subcommand %q", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCISearch(args []string, stdout, stderr io.Writer) int {
|
||||
fs, cfgPath, out := newFlags("ci search", stderr)
|
||||
pos, err := parseArgs(fs, args)
|
||||
if err != nil || len(pos) != 2 {
|
||||
return usageErr(stderr, "ci search: usage: mglpi ci search TYPE TERM")
|
||||
}
|
||||
return withClient(stderr, *cfgPath, 0, func(c *glpi.Client, ctx context.Context) int {
|
||||
rows, err := c.SearchCI(ctx, pos[0], pos[1])
|
||||
if err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
type result struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
results := make([]result, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
results = append(results, result{ID: r.ID, Name: r.Name})
|
||||
}
|
||||
if *out == "json" {
|
||||
emitJSON(stdout, map[string]any{"results": results})
|
||||
return 0
|
||||
}
|
||||
for _, r := range results {
|
||||
fmt.Fprintf(stdout, "%-6d %s\n", r.ID, r.Name)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
func cmdCIShow(args []string, stdout, stderr io.Writer) int {
|
||||
fs, cfgPath, out := newFlags("ci show", stderr)
|
||||
pos, err := parseArgs(fs, args)
|
||||
if err != nil || len(pos) != 2 {
|
||||
return usageErr(stderr, "ci show: usage: mglpi ci show TYPE ID")
|
||||
}
|
||||
id, err := strconv.Atoi(pos[1])
|
||||
if err != nil {
|
||||
return usageErr(stderr, "ci show: ID must be numeric, got %q", pos[1])
|
||||
}
|
||||
return withClient(stderr, *cfgPath, 0, func(c *glpi.Client, ctx context.Context) int {
|
||||
obj, err := c.GetItem(ctx, pos[0], id)
|
||||
if err != nil {
|
||||
return apiErr(stderr, err)
|
||||
}
|
||||
if *out == "json" {
|
||||
emitJSON(stdout, obj)
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s #%d %s\n", pos[0], id, obj["name"])
|
||||
for _, k := range sortedKeys(obj) {
|
||||
if k == "id" || k == "name" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stdout, " %s: %v\n", k, obj[k])
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// sortedKeys orders an object's keys for stable text output.
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
Reference in New Issue
Block a user