forked from Gitlink/gitlink-cli
692 lines
18 KiB
Go
692 lines
18 KiB
Go
package issue
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
// Priority constants
|
|
const (
|
|
priorityLow = 1
|
|
priorityNormal = 2
|
|
priorityHigh = 3
|
|
priorityUrgent = 4
|
|
)
|
|
|
|
// Status constants
|
|
const (
|
|
statusNew = 1
|
|
statusInProgress = 2
|
|
statusResolved = 3
|
|
statusClosed = 5
|
|
statusRejected = 6
|
|
)
|
|
|
|
// Tracker constants
|
|
const (
|
|
trackerBug = 1
|
|
trackerFeature = 2
|
|
trackerSupport = 3
|
|
trackerDoc = 4
|
|
trackerTest = 5
|
|
trackerDuplicate = 6
|
|
trackerQuestion = 7
|
|
)
|
|
|
|
var priorityNames = map[int]string{
|
|
priorityLow: "low",
|
|
priorityNormal: "normal",
|
|
priorityHigh: "high",
|
|
priorityUrgent: "urgent",
|
|
}
|
|
|
|
var statusNames = map[int]string{
|
|
statusNew: "new",
|
|
statusInProgress: "in-progress",
|
|
statusResolved: "resolved",
|
|
statusClosed: "closed",
|
|
statusRejected: "rejected",
|
|
}
|
|
|
|
var trackerNames = map[int]string{
|
|
trackerBug: "bug",
|
|
trackerFeature: "feature",
|
|
trackerSupport: "support",
|
|
trackerDoc: "doc",
|
|
trackerTest: "test",
|
|
trackerDuplicate: "duplicate",
|
|
trackerQuestion: "question",
|
|
}
|
|
|
|
// Tag name → GitLink tag ID mapping
|
|
// Collect IDs from web UI DevTools: change tag → capture PATCH payload → get issue_tag_ids value
|
|
var tagIDs = map[string]int{
|
|
"缺陷": 315526,
|
|
"功能": 315527,
|
|
"文档": 315533,
|
|
"重复": 315525,
|
|
"疑问": 315528,
|
|
"支持": 315529,
|
|
"任务": 315530,
|
|
"测试": 315534,
|
|
"协助": 315531,
|
|
"搁置": 315532,
|
|
}
|
|
|
|
// labelNames returns all known tag names from the given mapping.
|
|
func labelNames(tags map[string]int) string {
|
|
var names []string
|
|
for name := range tags {
|
|
names = append(names, name)
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// BatchResult is a single item result in a batch operation.
|
|
type BatchResult struct {
|
|
Number string `json:"number" yaml:"number"`
|
|
Action string `json:"action" yaml:"action"`
|
|
Status string `json:"status" yaml:"status"`
|
|
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
|
}
|
|
|
|
// BatchSummary is the aggregate result of a batch operation.
|
|
type BatchSummary struct {
|
|
Repository string `json:"repository" yaml:"repository"`
|
|
Action string `json:"action" yaml:"action"`
|
|
Value string `json:"value,omitempty" yaml:"value,omitempty"`
|
|
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
|
Total int `json:"total" yaml:"total"`
|
|
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
|
Failed int `json:"failed" yaml:"failed"`
|
|
Results []BatchResult `json:"results" yaml:"results"`
|
|
}
|
|
|
|
// ---- batch-close ----
|
|
|
|
func newBatchCloseShortcut() *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "batch-close",
|
|
Description: "Close multiple issues by issue numbers or a CSV file",
|
|
Flags: []common.Flag{
|
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
|
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
|
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
|
},
|
|
Run: runBatchClose,
|
|
}
|
|
}
|
|
|
|
func runBatchClose(ctx *common.RuntimeContext) error {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return err
|
|
}
|
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(numbers) == 0 {
|
|
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
|
}
|
|
|
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
|
summary := BatchSummary{
|
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
|
Action: "close",
|
|
DryRun: dryRun,
|
|
Total: len(numbers),
|
|
Results: make([]BatchResult, 0, len(numbers)),
|
|
}
|
|
|
|
for _, number := range numbers {
|
|
result := BatchResult{Number: number, Action: "close"}
|
|
if dryRun {
|
|
result.Status = "planned"
|
|
summary.Succeeded++
|
|
summary.Results = append(summary.Results, result)
|
|
continue
|
|
}
|
|
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
|
|
result.Status = "failed"
|
|
result.Error = err.Error()
|
|
summary.Failed++
|
|
} else {
|
|
result.Status = "closed"
|
|
summary.Succeeded++
|
|
}
|
|
summary.Results = append(summary.Results, result)
|
|
}
|
|
|
|
if err := ctx.OutputData(summary); err != nil {
|
|
return err
|
|
}
|
|
if summary.Failed > 0 {
|
|
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- batch-status ----
|
|
|
|
func newBatchStatusShortcut() *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "batch-status",
|
|
Description: "Change status for multiple issues",
|
|
Flags: []common.Flag{
|
|
{Name: "state", Short: "s", Usage: "Target state: new, in-progress, resolved, closed, rejected", Required: true},
|
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
|
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
|
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
|
},
|
|
Run: runBatchStatus,
|
|
}
|
|
}
|
|
|
|
func runBatchStatus(ctx *common.RuntimeContext) error {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return err
|
|
}
|
|
state := ctx.Arg("state")
|
|
statusID, err := parseStatus(state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(numbers) == 0 {
|
|
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
|
}
|
|
|
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
|
summary := BatchSummary{
|
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
|
Action: "set-status",
|
|
Value: state,
|
|
DryRun: dryRun,
|
|
Total: len(numbers),
|
|
Results: make([]BatchResult, 0, len(numbers)),
|
|
}
|
|
|
|
for _, number := range numbers {
|
|
result := BatchResult{Number: number, Action: "set-status"}
|
|
if dryRun {
|
|
result.Status = "planned"
|
|
summary.Succeeded++
|
|
summary.Results = append(summary.Results, result)
|
|
continue
|
|
}
|
|
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID}); err != nil {
|
|
result.Status = "failed"
|
|
result.Error = err.Error()
|
|
summary.Failed++
|
|
} else {
|
|
result.Status = state
|
|
summary.Succeeded++
|
|
}
|
|
summary.Results = append(summary.Results, result)
|
|
}
|
|
|
|
if err := ctx.OutputData(summary); err != nil {
|
|
return err
|
|
}
|
|
if summary.Failed > 0 {
|
|
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- batch-priority ----
|
|
|
|
func newBatchPriorityShortcut() *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "batch-priority",
|
|
Description: "Change priority for multiple issues",
|
|
Flags: []common.Flag{
|
|
{Name: "priority", Short: "p", Usage: "Target priority: low, normal, high, urgent", Required: true},
|
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
|
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
|
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
|
},
|
|
Run: runBatchPriority,
|
|
}
|
|
}
|
|
|
|
func runBatchPriority(ctx *common.RuntimeContext) error {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return err
|
|
}
|
|
priority := ctx.Arg("priority")
|
|
priorityID, err := parsePriority(priority)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(numbers) == 0 {
|
|
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
|
}
|
|
|
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
|
summary := BatchSummary{
|
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
|
Action: "set-priority",
|
|
Value: priority,
|
|
DryRun: dryRun,
|
|
Total: len(numbers),
|
|
Results: make([]BatchResult, 0, len(numbers)),
|
|
}
|
|
|
|
for _, number := range numbers {
|
|
result := BatchResult{Number: number, Action: "set-priority"}
|
|
if dryRun {
|
|
result.Status = "planned"
|
|
summary.Succeeded++
|
|
summary.Results = append(summary.Results, result)
|
|
continue
|
|
}
|
|
if err := updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID}); err != nil {
|
|
result.Status = "failed"
|
|
result.Error = err.Error()
|
|
summary.Failed++
|
|
} else {
|
|
result.Status = priority
|
|
summary.Succeeded++
|
|
}
|
|
summary.Results = append(summary.Results, result)
|
|
}
|
|
|
|
if err := ctx.OutputData(summary); err != nil {
|
|
return err
|
|
}
|
|
if summary.Failed > 0 {
|
|
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- batch-assign ----
|
|
|
|
func newBatchAssignShortcut() *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "batch-assign",
|
|
Description: "Change assignee for multiple issues",
|
|
Flags: []common.Flag{
|
|
{Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true},
|
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
|
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
|
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
|
},
|
|
Run: runBatchAssign,
|
|
}
|
|
}
|
|
|
|
func runBatchAssign(ctx *common.RuntimeContext) error {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return err
|
|
}
|
|
assignee := ctx.Arg("assignee")
|
|
|
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(numbers) == 0 {
|
|
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
|
}
|
|
|
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
|
summary := BatchSummary{
|
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
|
Action: "set-assignee",
|
|
Value: assignee,
|
|
DryRun: dryRun,
|
|
Total: len(numbers),
|
|
Results: make([]BatchResult, 0, len(numbers)),
|
|
}
|
|
|
|
var assigneeID interface{}
|
|
if !dryRun {
|
|
id, err := resolveUserID(ctx, assignee)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err)
|
|
}
|
|
assigneeID = id
|
|
}
|
|
|
|
for _, number := range numbers {
|
|
result := BatchResult{Number: number, Action: "set-assignee"}
|
|
if dryRun {
|
|
result.Status = "planned"
|
|
summary.Succeeded++
|
|
summary.Results = append(summary.Results, result)
|
|
continue
|
|
}
|
|
if err := updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID}); err != nil {
|
|
result.Status = "failed"
|
|
result.Error = err.Error()
|
|
summary.Failed++
|
|
} else {
|
|
result.Status = "assigned"
|
|
summary.Succeeded++
|
|
}
|
|
summary.Results = append(summary.Results, result)
|
|
}
|
|
|
|
if err := ctx.OutputData(summary); err != nil {
|
|
return err
|
|
}
|
|
if summary.Failed > 0 {
|
|
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- batch-label ----
|
|
|
|
func newBatchLabelShortcut() *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "batch-label",
|
|
Description: "Change tracker label for multiple issues",
|
|
Flags: []common.Flag{
|
|
{Name: "label", Short: "l", Usage: "Target label: bug, feature, support, doc, test, duplicate, question", Required: true},
|
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
|
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
|
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
|
},
|
|
Run: runBatchLabel,
|
|
}
|
|
}
|
|
|
|
func runBatchLabel(ctx *common.RuntimeContext) error {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return err
|
|
}
|
|
label := ctx.Arg("label")
|
|
trackerID, err := parseTracker(label)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(numbers) == 0 {
|
|
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
|
}
|
|
|
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
|
summary := BatchSummary{
|
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
|
Action: "set-label",
|
|
Value: label,
|
|
DryRun: dryRun,
|
|
Total: len(numbers),
|
|
Results: make([]BatchResult, 0, len(numbers)),
|
|
}
|
|
|
|
for _, number := range numbers {
|
|
result := BatchResult{Number: number, Action: "set-label"}
|
|
if dryRun {
|
|
result.Status = "planned"
|
|
summary.Succeeded++
|
|
summary.Results = append(summary.Results, result)
|
|
continue
|
|
}
|
|
if err := updateIssueField(ctx, number, map[string]interface{}{"tracker_id": trackerID}); err != nil {
|
|
result.Status = "failed"
|
|
result.Error = err.Error()
|
|
summary.Failed++
|
|
} else {
|
|
result.Status = label
|
|
summary.Succeeded++
|
|
}
|
|
summary.Results = append(summary.Results, result)
|
|
}
|
|
|
|
if err := ctx.OutputData(summary); err != nil {
|
|
return err
|
|
}
|
|
if summary.Failed > 0 {
|
|
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- shared helpers ----
|
|
|
|
// updateIssueField fetches the current issue to preserve subject/description,
|
|
// then PATCHes with the given fields merged in.
|
|
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
|
|
current, err := fetchExistingIssue(ctx, number)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch issue #%s: %w", number, err)
|
|
}
|
|
|
|
body := map[string]interface{}{
|
|
"subject": current.Subject,
|
|
"description": current.Description,
|
|
}
|
|
for k, v := range fields {
|
|
body[k] = v
|
|
}
|
|
|
|
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
|
return fmt.Errorf("update issue #%s: %w", number, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveUserID converts a login name to a numeric user ID via the users API.
|
|
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
|
|
if id, err := strconv.Atoi(login); err == nil {
|
|
return id, nil
|
|
}
|
|
|
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup user %q: %w", login, err)
|
|
}
|
|
data, ok := env.Data.(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("unexpected response for user %q", login)
|
|
}
|
|
idFloat, ok := data["id"].(float64)
|
|
if ok {
|
|
return int(idFloat), nil
|
|
}
|
|
userIDFloat, ok := data["user_id"].(float64)
|
|
if ok {
|
|
return int(userIDFloat), nil
|
|
}
|
|
return nil, fmt.Errorf("cannot determine user ID for %q", login)
|
|
}
|
|
|
|
func parseStatus(state string) (int, error) {
|
|
switch strings.ToLower(strings.TrimSpace(state)) {
|
|
case "new":
|
|
return statusNew, nil
|
|
case "in-progress", "in_progress", "inprogress":
|
|
return statusInProgress, nil
|
|
case "resolved":
|
|
return statusResolved, nil
|
|
case "closed":
|
|
return statusClosed, nil
|
|
case "rejected":
|
|
return statusRejected, nil
|
|
default:
|
|
if id, err := strconv.Atoi(state); err == nil {
|
|
return id, nil
|
|
}
|
|
return 0, fmt.Errorf("invalid state %q: use new, in-progress, resolved, closed, or rejected", state)
|
|
}
|
|
}
|
|
|
|
func parsePriority(p string) (int, error) {
|
|
switch strings.ToLower(strings.TrimSpace(p)) {
|
|
case "low":
|
|
return priorityLow, nil
|
|
case "normal":
|
|
return priorityNormal, nil
|
|
case "high":
|
|
return priorityHigh, nil
|
|
case "urgent":
|
|
return priorityUrgent, nil
|
|
default:
|
|
if id, err := strconv.Atoi(p); err == nil {
|
|
return id, nil
|
|
}
|
|
return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p)
|
|
}
|
|
}
|
|
|
|
func parseTracker(label string) (int, error) {
|
|
switch strings.ToLower(strings.TrimSpace(label)) {
|
|
case "bug":
|
|
return trackerBug, nil
|
|
case "feature":
|
|
return trackerFeature, nil
|
|
case "support":
|
|
return trackerSupport, nil
|
|
case "doc":
|
|
return trackerDoc, nil
|
|
case "test":
|
|
return trackerTest, nil
|
|
case "duplicate":
|
|
return trackerDuplicate, nil
|
|
case "question":
|
|
return trackerQuestion, nil
|
|
default:
|
|
if id, err := strconv.Atoi(label); err == nil {
|
|
return id, nil
|
|
}
|
|
return 0, fmt.Errorf("invalid label %q: use bug, feature, support, doc, test, duplicate, or question", label)
|
|
}
|
|
}
|
|
|
|
// parseLabel converts a label name to its GitLink tag ID.
|
|
// tags is the project's name→id mapping from resolveIssueTags.
|
|
func parseLabel(name string, tags map[string]int) (int, error) {
|
|
if id, ok := tags[name]; ok && id != 0 {
|
|
return id, nil
|
|
}
|
|
if id, err := strconv.Atoi(name); err == nil {
|
|
return id, nil
|
|
}
|
|
return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name)
|
|
}
|
|
|
|
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
|
numbers, err := parseIssueNumbers(numbersValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if csvPath == "" {
|
|
return numbers, nil
|
|
}
|
|
|
|
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return mergeIssueNumbers(numbers, csvNumbers), nil
|
|
}
|
|
|
|
func parseIssueNumbers(value string) ([]string, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil, nil
|
|
}
|
|
return normalizeIssueNumbers(strings.Split(value, ","))
|
|
}
|
|
|
|
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read CSV: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
reader := csv.NewReader(file)
|
|
reader.TrimLeadingSpace = true
|
|
records, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse CSV: %w", err)
|
|
}
|
|
if len(records) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
numberColumn := -1
|
|
startRow := 0
|
|
for i, cell := range records[0] {
|
|
switch strings.ToLower(strings.TrimSpace(cell)) {
|
|
case "number", "issue_number", "project_issues_index":
|
|
numberColumn = i
|
|
startRow = 1
|
|
}
|
|
}
|
|
if numberColumn == -1 {
|
|
numberColumn = 0
|
|
}
|
|
|
|
values := make([]string, 0, len(records)-startRow)
|
|
for _, record := range records[startRow:] {
|
|
if numberColumn >= len(record) {
|
|
continue
|
|
}
|
|
values = append(values, record[numberColumn])
|
|
}
|
|
return normalizeIssueNumbers(values)
|
|
}
|
|
|
|
func normalizeIssueNumbers(values []string) ([]string, error) {
|
|
numbers := make([]string, 0, len(values))
|
|
seen := map[string]bool{}
|
|
for _, value := range values {
|
|
number := strings.TrimSpace(value)
|
|
if number == "" {
|
|
continue
|
|
}
|
|
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
|
return nil, fmt.Errorf("invalid issue number %q: must be an integer", number)
|
|
}
|
|
if seen[number] {
|
|
continue
|
|
}
|
|
seen[number] = true
|
|
numbers = append(numbers, number)
|
|
}
|
|
return numbers, nil
|
|
}
|
|
|
|
func mergeIssueNumbers(values ...[]string) []string {
|
|
merged := []string{}
|
|
seen := map[string]bool{}
|
|
for _, numbers := range values {
|
|
for _, number := range numbers {
|
|
if seen[number] {
|
|
continue
|
|
}
|
|
seen[number] = true
|
|
merged = append(merged, number)
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func parseBool(value string) bool {
|
|
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
|
return err == nil && parsed
|
|
}
|