gitlink-cli/shortcuts/issue/batch.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
}