gitlink-cli/shortcuts/label/label.go

476 lines
13 KiB
Go

package label
import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// defaultLabelColor is used when the caller does not provide a color.
const defaultLabelColor = "#1E90FF"
// hexColorPattern matches #RGB and #RRGGBB hex color values.
var hexColorPattern = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// Shortcuts returns issue label (项目标记) management shortcuts.
//
// Issue labels back the issue triage and PR gatekeeping workflows: until now
// they could only be managed through the raw API (issue_tags), so these
// shortcuts close that gap with first-class create/list/update/delete commands.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List issue labels",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Filter labels by keyword"},
{Name: "only-name", Usage: "Return only label id and name: true or false"},
{Name: "sort-by", Usage: "Sort field: updated_on, created_on, issues_count"},
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
setQueryIfPresent(q, "keyword", ctx.Arg("keyword"))
setQueryIfPresent(q, "only_name", ctx.Arg("only-name"))
setQueryIfPresent(q, "order_by", ctx.Arg("sort-by"))
setQueryIfPresent(q, "order_direction", ctx.Arg("sort-direction"))
env, err := ctx.CallAPIWithQuery("GET", labelPath(ctx), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create an issue label",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Label name", Required: true},
{Name: "description", Short: "d", Usage: "Label description"},
{Name: "color", Short: "c", Usage: "Label color in hex, for example: #1E90FF", Default: defaultLabelColor},
},
Run: runCreate,
},
{
Name: "update",
Description: "Update an issue label while preserving unspecified fields",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
{Name: "name", Short: "n", Usage: "Label name"},
{Name: "description", Short: "d", Usage: "Label description"},
{Name: "color", Short: "c", Usage: "Label color in hex, for example: #1E90FF"},
},
Run: runUpdate,
},
{
Name: "delete",
Description: "Delete an issue label",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
{Name: "dry-run", Usage: "Preview the delete request without removing the label", Bool: true, Default: "false"},
{Name: "yes", Usage: "Confirm label deletion", Bool: true, Default: "false"},
},
Run: runDelete,
},
{
Name: "batch-create",
Description: "Batch create issue labels from semicolon-separated specs",
Flags: []common.Flag{
{Name: "labels", Usage: "Semicolon-separated label specs: name:color:description", Required: true},
{Name: "dry-run", Usage: "Preview labels without creating them", Bool: true, Default: "false"},
{Name: "yes", Usage: "Confirm real batch label creation", Bool: true, Default: "false"},
},
Run: runBatchCreate,
},
{
Name: "batch-delete",
Description: "Batch delete issue labels by IDs",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated label IDs", Required: true},
{Name: "dry-run", Usage: "Preview labels without deleting them", Bool: true, Default: "false"},
{Name: "yes", Usage: "Confirm real batch label deletion", Bool: true, Default: "false"},
},
Run: runBatchDelete,
},
}
}
type labelSpec struct {
Name string `json:"name" yaml:"name"`
Description string `json:"description" yaml:"description"`
Color string `json:"color" yaml:"color"`
}
type labelBatchRequest struct {
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
Body map[string]interface{} `json:"body,omitempty" yaml:"body,omitempty"`
}
type labelBatchPreview struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Action string `json:"action" yaml:"action"`
Requests []labelBatchRequest `json:"requests" yaml:"requests"`
}
type labelBatchResult struct {
Request labelBatchRequest `json:"request" yaml:"request"`
OK bool `json:"ok" yaml:"ok"`
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
func runCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
color := firstNonEmpty(ctx.Arg("color"), defaultLabelColor)
if err := validateColor(color); err != nil {
return err
}
payload := map[string]interface{}{
"name": name,
"description": ctx.Arg("description"),
"color": color,
}
env, err := ctx.CallAPI("POST", labelPath(ctx), payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
if ctx.Arg("name") == "" && ctx.Arg("description") == "" && ctx.Arg("color") == "" {
return fmt.Errorf("at least one of --name, --description, or --color is required")
}
// The update endpoint requires name, description and color together, so we
// merge the requested changes onto the label's current values to avoid
// clobbering fields the caller did not pass.
current, err := fetchLabel(ctx, id)
if err != nil {
return err
}
name := firstNonEmpty(ctx.Arg("name"), stringFromMap(current, "name"))
if name == "" {
return fmt.Errorf("could not resolve label name for id %s; pass --name explicitly", id)
}
color := firstNonEmpty(ctx.Arg("color"), stringFromMap(current, "color"), defaultLabelColor)
if err := validateColor(color); err != nil {
return err
}
description := ctx.Arg("description")
if description == "" {
description = stringFromMap(current, "description")
}
payload := map[string]interface{}{
"name": name,
"description": description,
"color": color,
}
env, err := ctx.CallAPI("PATCH", labelItemPath(ctx, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
path := labelItemPath(ctx, id)
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(labelBatchPreview{
Repository: repositoryName(ctx),
DryRun: true,
Action: "delete_label",
Requests: []labelBatchRequest{{
Method: "DELETE",
Path: path,
}},
})
}
if ctx.Arg("yes") != "true" {
return fmt.Errorf("label delete is destructive; run with --dry-run first, then pass --yes to confirm")
}
env, err := ctx.CallAPI("DELETE", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
}
func runBatchCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
specs, err := parseLabelSpecs(ctx.Arg("labels"))
if err != nil {
return err
}
requests := make([]labelBatchRequest, 0, len(specs))
for _, spec := range specs {
requests = append(requests, labelBatchRequest{
Method: "POST",
Path: labelPath(ctx),
Body: map[string]interface{}{
"name": spec.Name,
"description": spec.Description,
"color": spec.Color,
},
})
}
if ctx.Arg("dry-run") == "true" || ctx.Arg("yes") != "true" {
return ctx.OutputData(labelBatchPreview{
Repository: repositoryName(ctx),
DryRun: true,
Action: "batch_create_labels",
Requests: requests,
})
}
return runLabelBatchRequests(ctx, "batch_create_labels", requests)
}
func runBatchDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
ids, err := parseLabelIDList(ctx.Arg("ids"))
if err != nil {
return err
}
requests := make([]labelBatchRequest, 0, len(ids))
for _, id := range ids {
requests = append(requests, labelBatchRequest{
Method: "DELETE",
Path: labelItemPath(ctx, id),
})
}
if ctx.Arg("dry-run") == "true" || ctx.Arg("yes") != "true" {
return ctx.OutputData(labelBatchPreview{
Repository: repositoryName(ctx),
DryRun: true,
Action: "batch_delete_labels",
Requests: requests,
})
}
return runLabelBatchRequests(ctx, "batch_delete_labels", requests)
}
func runLabelBatchRequests(ctx *common.RuntimeContext, action string, requests []labelBatchRequest) error {
results := make([]labelBatchResult, 0, len(requests))
succeeded := 0
failed := 0
for _, request := range requests {
env, err := ctx.CallAPI(request.Method, request.Path, request.Body)
result := labelBatchResult{Request: request}
if err != nil {
result.OK = false
result.Error = err.Error()
failed++
} else {
result.OK = env.OK
result.Data = env.Data
if env.OK {
succeeded++
} else {
failed++
}
}
results = append(results, result)
}
if err := ctx.OutputData(map[string]interface{}{
"repository": repositoryName(ctx),
"action": action,
"count": len(requests),
"succeeded": succeeded,
"failed": failed,
"results": results,
}); err != nil {
return err
}
if failed > 0 {
return fmt.Errorf("%d of %d label request(s) failed", failed, len(requests))
}
return nil
}
// fetchLabel looks up a single label by id from the list endpoint. GitLink does
// not expose a single-label GET, so we page through the list and match by id.
// A nil result (label not found) is not an error: the caller falls back to the
// flags it was given.
func fetchLabel(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", labelPath(ctx), nil)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, nil
}
rawTags, ok := data["issue_tags"].([]interface{})
if !ok {
return nil, nil
}
for _, raw := range rawTags {
tag, ok := raw.(map[string]interface{})
if !ok {
continue
}
if labelIDString(tag["id"]) == id {
return tag, nil
}
}
return nil, nil
}
func labelPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
}
func labelItemPath(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("%s/%s", labelPath(ctx), url.PathEscape(id))
}
func parseLabelSpecs(raw string) ([]labelSpec, error) {
if strings.TrimSpace(raw) == "" {
return nil, fmt.Errorf("--labels is required")
}
parts := strings.Split(raw, ";")
specs := make([]labelSpec, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.SplitN(part, ":", 3)
name := strings.TrimSpace(fields[0])
if name == "" {
return nil, fmt.Errorf("invalid label spec %q: name is required", part)
}
color := defaultLabelColor
if len(fields) > 1 && strings.TrimSpace(fields[1]) != "" {
color = strings.TrimSpace(fields[1])
}
if err := validateColor(color); err != nil {
return nil, err
}
description := ""
if len(fields) > 2 {
description = strings.TrimSpace(fields[2])
}
specs = append(specs, labelSpec{
Name: name,
Description: description,
Color: color,
})
}
if len(specs) == 0 {
return nil, fmt.Errorf("--labels must contain at least one label spec")
}
return specs, nil
}
func parseLabelIDList(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, fmt.Errorf("--ids is required")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
continue
}
n, err := strconv.Atoi(id)
if err != nil || n <= 0 {
return nil, fmt.Errorf("invalid label id %q: use positive integer IDs", id)
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, fmt.Errorf("--ids must contain at least one label ID")
}
return ids, nil
}
func repositoryName(ctx *common.RuntimeContext) string {
return fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
}
func validateColor(color string) error {
if !hexColorPattern.MatchString(color) {
return fmt.Errorf("invalid --color value %q: use a hex color like #1E90FF or #abc", color)
}
return nil
}
func labelIDString(v interface{}) string {
switch id := v.(type) {
case string:
return id
case float64:
return strconv.FormatInt(int64(id), 10)
case json.Number:
return id.String()
default:
return ""
}
}
func setQueryIfPresent(q url.Values, name, value string) {
if value != "" {
q.Set(name, value)
}
}
func stringFromMap(values map[string]interface{}, key string) string {
if values == nil {
return ""
}
value, _ := values[key].(string)
return value
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}