forked from Gitlink/gitlink-cli
704 lines
19 KiB
Go
704 lines
19 KiB
Go
package issue
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
const (
|
|
closedIssueStatusID = 5
|
|
maxBatchLimit = 100
|
|
)
|
|
|
|
type BatchIssueCandidate struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"`
|
|
Labels []string `json:"labels,omitempty"`
|
|
Author string `json:"author,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
DaysInactive int `json:"days_inactive,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
type BatchIssueResult struct {
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
DryRun bool `json:"dry_run"`
|
|
Action string `json:"action"`
|
|
Total int `json:"total"`
|
|
Success int `json:"success"`
|
|
Failed int `json:"failed"`
|
|
Candidates []BatchIssueCandidate `json:"candidates"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type batchIssueOptions struct {
|
|
Owner string
|
|
Repo string
|
|
State string
|
|
Label string
|
|
AddLabel string
|
|
OlderThanDays int
|
|
Limit int
|
|
Yes bool
|
|
Reason string
|
|
Action string
|
|
}
|
|
|
|
func newBatchListShortcut(tr *i18n.Translator) *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "batch-list",
|
|
Description: tr.T("cmd.issue.batch_list.short"),
|
|
Long: tr.T("cmd.issue.batch_list.long"),
|
|
Flags: []common.Flag{
|
|
{Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"},
|
|
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
|
|
{Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")},
|
|
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_list.limit"), Default: "50"},
|
|
},
|
|
Run: runBatchList,
|
|
}
|
|
}
|
|
|
|
func newBatchCloseShortcut(tr *i18n.Translator) *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "batch-close",
|
|
Description: tr.T("cmd.issue.batch_close.short"),
|
|
Long: tr.T("cmd.issue.batch_close.long"),
|
|
Flags: []common.Flag{
|
|
{Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_close.state"), Default: "open"},
|
|
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
|
|
{Name: "older-than-days", Usage: tr.T("flag.issue.batch_close.older_than_days")},
|
|
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "20"},
|
|
{Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"},
|
|
{Name: "reason", Usage: tr.T("flag.issue.batch.reason")},
|
|
},
|
|
Run: runBatchClose,
|
|
}
|
|
}
|
|
|
|
func newBatchLabelShortcut(tr *i18n.Translator) *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "batch-label",
|
|
Description: tr.T("cmd.issue.batch_label.short"),
|
|
Long: tr.T("cmd.issue.batch_label.long"),
|
|
Flags: []common.Flag{
|
|
{Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_label.state"), Default: "open"},
|
|
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
|
|
{Name: "add-label", Usage: tr.T("flag.issue.add_label")},
|
|
{Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")},
|
|
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "50"},
|
|
{Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"},
|
|
},
|
|
Run: runBatchLabel,
|
|
}
|
|
}
|
|
|
|
func runBatchList(ctx *common.RuntimeContext) error {
|
|
opts, err := batchOptionsFromContext(ctx, "list")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
candidates, err := fetchBatchIssueCandidates(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := BatchIssueResult{
|
|
Owner: ctx.Owner,
|
|
Repo: ctx.Repo,
|
|
DryRun: true,
|
|
Action: "list",
|
|
Total: len(candidates),
|
|
Success: len(candidates),
|
|
Candidates: candidates,
|
|
}
|
|
return renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx))
|
|
}
|
|
|
|
func runBatchClose(ctx *common.RuntimeContext) error {
|
|
opts, err := batchOptionsFromContext(ctx, "close")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := validateBatchCloseOptions(opts); err != nil {
|
|
return err
|
|
}
|
|
candidates, err := fetchBatchIssueCandidates(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := BatchIssueResult{
|
|
Owner: ctx.Owner,
|
|
Repo: ctx.Repo,
|
|
DryRun: !opts.Yes,
|
|
Action: "close",
|
|
Total: len(candidates),
|
|
Candidates: candidates,
|
|
Reason: opts.Reason,
|
|
}
|
|
if !opts.Yes {
|
|
result.Success = len(candidates)
|
|
return renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx))
|
|
}
|
|
for _, candidate := range candidates {
|
|
if err := closeIssue(ctx, strconv.Itoa(candidate.Number)); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, fmt.Sprintf("#%d: %v", candidate.Number, err))
|
|
continue
|
|
}
|
|
result.Success++
|
|
}
|
|
if err := renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx)); err != nil {
|
|
return err
|
|
}
|
|
if result.Failed > 0 {
|
|
return fmt.Errorf("%d of %d issue(s) failed to close", result.Failed, result.Total)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runBatchLabel(ctx *common.RuntimeContext) error {
|
|
opts, err := batchOptionsFromContext(ctx, "label")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := validateBatchLabelOptions(opts); err != nil {
|
|
return err
|
|
}
|
|
candidates, err := fetchBatchIssueCandidates(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := BatchIssueResult{
|
|
Owner: ctx.Owner,
|
|
Repo: ctx.Repo,
|
|
DryRun: !opts.Yes,
|
|
Action: "add-label:" + opts.AddLabel,
|
|
Total: len(candidates),
|
|
Candidates: candidates,
|
|
}
|
|
if !opts.Yes {
|
|
result.Success = len(candidates)
|
|
return renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx))
|
|
}
|
|
result.Failed = len(candidates)
|
|
for _, candidate := range candidates {
|
|
result.Errors = append(result.Errors, fmt.Sprintf("#%d: write endpoint unavailable for add-label", candidate.Number))
|
|
}
|
|
if err := renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx)); err != nil {
|
|
return err
|
|
}
|
|
if result.Failed > 0 {
|
|
return fmt.Errorf("batch-label remote write is not implemented because the add-label endpoint is not defined")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func batchOptionsFromContext(ctx *common.RuntimeContext, action string) (batchIssueOptions, error) {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return batchIssueOptions{}, err
|
|
}
|
|
limit, err := parseBatchInt(ctx.Arg("limit"), defaultBatchLimit(action), "limit")
|
|
if err != nil {
|
|
return batchIssueOptions{}, err
|
|
}
|
|
if limit > maxBatchLimit {
|
|
return batchIssueOptions{}, fmt.Errorf("--limit must be <= %d", maxBatchLimit)
|
|
}
|
|
olderThanDays, err := parseBatchInt(ctx.Arg("older-than-days"), 0, "older-than-days")
|
|
if err != nil {
|
|
return batchIssueOptions{}, err
|
|
}
|
|
return batchIssueOptions{
|
|
Owner: ctx.Owner,
|
|
Repo: ctx.Repo,
|
|
State: defaultString(ctx.Arg("state"), "open"),
|
|
Label: strings.TrimSpace(ctx.Arg("label")),
|
|
AddLabel: strings.TrimSpace(ctx.Arg("add-label")),
|
|
OlderThanDays: olderThanDays,
|
|
Limit: limit,
|
|
Yes: parseBool(ctx.Arg("yes")),
|
|
Reason: strings.TrimSpace(ctx.Arg("reason")),
|
|
Action: action,
|
|
}, nil
|
|
}
|
|
|
|
func defaultBatchLimit(action string) int {
|
|
switch action {
|
|
case "close":
|
|
return 20
|
|
default:
|
|
return 50
|
|
}
|
|
}
|
|
|
|
func validateBatchCloseOptions(opts batchIssueOptions) error {
|
|
if opts.OlderThanDays == 0 {
|
|
return fmt.Errorf("issue +batch-close requires --older-than-days as a safety filter")
|
|
}
|
|
if opts.OlderThanDays < 7 {
|
|
return fmt.Errorf("--older-than-days must be at least 7 for issue +batch-close")
|
|
}
|
|
return validateBatchHasFilter(opts)
|
|
}
|
|
|
|
func validateBatchLabelOptions(opts batchIssueOptions) error {
|
|
if opts.AddLabel == "" {
|
|
return fmt.Errorf("issue +batch-label requires --add-label")
|
|
}
|
|
return validateBatchHasFilter(opts)
|
|
}
|
|
|
|
func validateBatchHasFilter(opts batchIssueOptions) error {
|
|
if opts.Label == "" && opts.OlderThanDays == 0 {
|
|
return fmt.Errorf("refusing batch operation without a label or older-than-days filter")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fetchBatchIssueCandidates(ctx *common.RuntimeContext, opts batchIssueOptions) ([]BatchIssueCandidate, error) {
|
|
query := url.Values{}
|
|
query.Set("page", "1")
|
|
query.Set("limit", strconv.Itoa(opts.Limit))
|
|
if opts.State != "" && opts.State != "all" {
|
|
query.Set("state", opts.State)
|
|
}
|
|
if opts.Label != "" {
|
|
query.Set("label", opts.Label)
|
|
}
|
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
candidates := normalizeBatchIssueCandidates(env, opts)
|
|
if len(candidates) > opts.Limit {
|
|
candidates = candidates[:opts.Limit]
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func normalizeBatchIssueCandidates(env *output.Envelope, opts batchIssueOptions) []BatchIssueCandidate {
|
|
items := extractBatchIssueItems(env)
|
|
candidates := make([]BatchIssueCandidate, 0, len(items))
|
|
for _, item := range items {
|
|
candidate := batchCandidateFromMap(item)
|
|
if candidate.Number == 0 {
|
|
continue
|
|
}
|
|
if !batchCandidateMatches(candidate, opts) {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate)
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func extractBatchIssueItems(env *output.Envelope) []map[string]interface{} {
|
|
if env == nil {
|
|
return nil
|
|
}
|
|
data, ok := env.Data.(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
for _, key := range []string{"issues", "data"} {
|
|
raw, ok := data[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if items := mapsFromInterfaceSlice(raw); len(items) > 0 {
|
|
return items
|
|
}
|
|
}
|
|
if items := mapsFromInterfaceSlice(env.Data); len(items) > 0 {
|
|
return items
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mapsFromInterfaceSlice(value interface{}) []map[string]interface{} {
|
|
switch typed := value.(type) {
|
|
case []interface{}:
|
|
out := make([]map[string]interface{}, 0, len(typed))
|
|
for _, item := range typed {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func batchCandidateFromMap(item map[string]interface{}) BatchIssueCandidate {
|
|
updatedAt := firstBatchString(item, "updated_at", "updatedAt", "last_activity_at", "lastActivityAt")
|
|
return BatchIssueCandidate{
|
|
Number: firstBatchInt(item, "number", "project_issues_index", "iid", "id"),
|
|
Title: firstBatchString(item, "title", "subject", "name"),
|
|
State: firstBatchString(item, "state", "status", "status_name"),
|
|
Labels: batchLabels(item["labels"]),
|
|
Author: batchAuthor(item),
|
|
UpdatedAt: updatedAt,
|
|
DaysInactive: daysInactive(updatedAt),
|
|
URL: firstBatchString(item, "url", "html_url", "web_url"),
|
|
}
|
|
}
|
|
|
|
func batchCandidateMatches(candidate BatchIssueCandidate, opts batchIssueOptions) bool {
|
|
if opts.State != "" && opts.State != "all" && candidate.State != "" && !strings.EqualFold(candidate.State, opts.State) {
|
|
return false
|
|
}
|
|
if opts.Label != "" && !candidateHasLabel(candidate, opts.Label) {
|
|
return false
|
|
}
|
|
if opts.OlderThanDays > 0 {
|
|
if candidate.UpdatedAt == "" || candidate.DaysInactive < opts.OlderThanDays {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func candidateHasLabel(candidate BatchIssueCandidate, label string) bool {
|
|
for _, existing := range candidate.Labels {
|
|
if strings.EqualFold(strings.TrimSpace(existing), strings.TrimSpace(label)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func renderBatchIssueResult(w io.Writer, result BatchIssueResult, format string) error {
|
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
|
case "", "table":
|
|
return renderBatchIssueTable(w, result)
|
|
case "json":
|
|
data, err := json.MarshalIndent(result, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintln(w, string(data))
|
|
return err
|
|
default:
|
|
return fmt.Errorf("unsupported format %q: use table or json", format)
|
|
}
|
|
}
|
|
|
|
func renderBatchIssueTable(w io.Writer, result BatchIssueResult) error {
|
|
mode := "DRY-RUN"
|
|
if !result.DryRun {
|
|
mode = "EXECUTED"
|
|
}
|
|
fmt.Fprintf(w, "%s ACTION=%s TOTAL=%d SUCCESS=%d FAILED=%d\n", mode, result.Action, result.Total, result.Success, result.Failed)
|
|
if result.Reason != "" {
|
|
fmt.Fprintf(w, "REASON: %s\n", result.Reason)
|
|
}
|
|
if len(result.Candidates) > 0 {
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "NUMBER\tSTATE\tDAYS_INACTIVE\tLABELS\tTITLE")
|
|
for _, candidate := range result.Candidates {
|
|
fmt.Fprintf(tw, "%d\t%s\t%d\t%s\t%s\n",
|
|
candidate.Number,
|
|
candidate.State,
|
|
candidate.DaysInactive,
|
|
strings.Join(candidate.Labels, ","),
|
|
candidate.Title,
|
|
)
|
|
}
|
|
if err := tw.Flush(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, errText := range result.Errors {
|
|
fmt.Fprintf(w, "ERROR: %s\n", errText)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func batchOutputFormat(ctx *common.RuntimeContext) string {
|
|
if strings.TrimSpace(cmdutil.Format) == "" {
|
|
return "table"
|
|
}
|
|
return ctx.Format
|
|
}
|
|
|
|
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
|
current, err := fetchExistingIssue(ctx, number)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch issue: %w", err)
|
|
}
|
|
body := map[string]interface{}{
|
|
"subject": current.Subject,
|
|
"description": current.Description,
|
|
"status_id": closedIssueStatusID,
|
|
}
|
|
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
|
return fmt.Errorf("close issue: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseBatchInt(value string, defaultValue int, name string) (int, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return defaultValue, nil
|
|
}
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid --%s %q: must be an integer", name, value)
|
|
}
|
|
if parsed < 0 {
|
|
return 0, fmt.Errorf("invalid --%s %q: must be >= 0", name, value)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func daysInactive(value string) int {
|
|
if strings.TrimSpace(value) == "" {
|
|
return 0
|
|
}
|
|
updated, err := parseBatchTime(value)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
days := int(time.Since(updated).Hours() / 24)
|
|
if days < 0 {
|
|
return 0
|
|
}
|
|
return days
|
|
}
|
|
|
|
func parseBatchTime(value string) (time.Time, error) {
|
|
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
|
if parsed, err := time.Parse(layout, value); err == nil {
|
|
return parsed, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("unsupported time %q", value)
|
|
}
|
|
|
|
func firstBatchString(item map[string]interface{}, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := item[key]; ok {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return strings.TrimSpace(typed)
|
|
case map[string]interface{}:
|
|
if text := firstBatchString(typed, "name", "login", "title"); text != "" {
|
|
return text
|
|
}
|
|
default:
|
|
if value != nil {
|
|
return strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstBatchInt(item map[string]interface{}, keys ...string) int {
|
|
for _, key := range keys {
|
|
value, ok := item[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return typed
|
|
case int64:
|
|
return int(typed)
|
|
case float64:
|
|
return int(typed)
|
|
case json.Number:
|
|
parsed, _ := typed.Int64()
|
|
return int(parsed)
|
|
case string:
|
|
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
|
|
return parsed
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func batchLabels(value interface{}) []string {
|
|
switch typed := value.(type) {
|
|
case []interface{}:
|
|
out := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
switch label := item.(type) {
|
|
case string:
|
|
out = append(out, label)
|
|
case map[string]interface{}:
|
|
if text := firstBatchString(label, "name", "title", "label"); text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
case []string:
|
|
return append([]string(nil), typed...)
|
|
case string:
|
|
if strings.TrimSpace(typed) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(typed, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if text := strings.TrimSpace(part); text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func batchAuthor(item map[string]interface{}) string {
|
|
for _, key := range []string{"author", "user", "creator"} {
|
|
value, ok := item[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
case map[string]interface{}:
|
|
if text := firstBatchString(typed, "login", "name", "username"); text != "" {
|
|
return text
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func defaultString(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
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 issue numbers from 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 issue numbers from 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: issue numbers must be integers", 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
|
|
}
|
|
|
|
func renderBatchIssueResultString(result BatchIssueResult, format string) (string, error) {
|
|
var buf bytes.Buffer
|
|
err := renderBatchIssueResult(&buf, result, format)
|
|
return buf.String(), err
|
|
}
|