fj/cmd/issue.go

802 lines
22 KiB
Go
Raw Normal View History

2025-12-08 09:49:07 +01:00
package cmd
import (
"fmt"
"net/http"
2025-12-08 09:49:07 +01:00
"strings"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
2026-01-03 11:49:34 +01:00
"github.com/spf13/cobra"
2025-12-08 09:49:07 +01:00
)
var issueCmd = &cobra.Command{
Use: "issue",
Short: "Manage issues",
Long: "Create, view, list, and manage issues.",
}
var issueListCmd = &cobra.Command{
Use: "list [flags]",
Short: "List issues",
Long: "List issues in a repository.",
Example: ` # List open issues
fj issue list
# List closed issues for a specific repo
fj issue list -s closed -R owner/repo
# Output as JSON
fj issue list --json`,
RunE: runIssueList,
2025-12-08 09:49:07 +01:00
}
var issueViewCmd = &cobra.Command{
Use: "view <number>",
Short: "View an issue",
Long: "Display detailed information about an issue.",
Example: ` # View issue #42
fj issue view 42
# View using URL
fj issue view https://codeberg.org/owner/repo/issues/42
# Open in browser
fj issue view 42 --web
# View an issue from a specific repo as JSON
fj issue view 42 -R owner/repo --json`,
Args: cobra.ExactArgs(1),
RunE: runIssueView,
2025-12-08 09:49:07 +01:00
}
var issueCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an issue",
Long: "Create a new issue.",
Example: ` # Create an issue with a title
fj issue create -t "Fix login bug"
# Create an issue with title, body, and labels
fj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
RunE: runIssueCreate,
2025-12-08 09:49:07 +01:00
}
var issueCommentCmd = &cobra.Command{
Use: "comment <number>",
Short: "Add a comment to an issue",
Long: "Add a comment to an existing issue.",
Example: ` # Add a comment to issue #42
fj issue comment 42 -b "This is fixed in the latest release"
# Comment on an issue in a specific repo
fj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runIssueComment,
2025-12-08 09:49:07 +01:00
}
var issueCloseCmd = &cobra.Command{
Use: "close <number>",
Short: "Close an issue",
Long: "Close an existing issue.",
Example: ` # Close issue #42
fj issue close 42
# Close with a comment
fj issue close 42 -c "Fixed in commit abc1234"`,
Args: cobra.ExactArgs(1),
RunE: runIssueClose,
}
var issueReopenCmd = &cobra.Command{
Use: "reopen <number>",
Short: "Reopen an issue",
Long: "Reopen a closed issue.",
Example: ` # Reopen issue #42
fj issue reopen 42`,
Args: cobra.ExactArgs(1),
RunE: runIssueReopen,
}
var issueDeleteCmd = &cobra.Command{
Use: "delete <number>",
Short: "Delete an issue",
Long: "Delete an issue permanently.",
Example: ` # Delete issue #42
fj issue delete 42
# Delete without confirmation
fj issue delete 42 -y`,
Args: cobra.ExactArgs(1),
RunE: runIssueDelete,
2025-12-08 09:49:07 +01:00
}
2026-01-03 11:49:34 +01:00
var issueEditCmd = &cobra.Command{
Use: "edit <number>",
Short: "Edit an issue",
Long: "Edit an existing issue's title, body, or state.",
Example: ` # Update the title of issue #42
fj issue edit 42 -t "Updated title"
# Reopen a closed issue
fj issue edit 42 -s open
# Add and remove labels
fj issue edit 42 --add-label bug --remove-label wontfix
# Add a dependency
fj issue edit 42 --add-dependency 10`,
Args: cobra.ExactArgs(1),
RunE: runIssueEdit,
2026-01-03 11:49:34 +01:00
}
2025-12-08 09:49:07 +01:00
func init() {
rootCmd.AddCommand(issueCmd)
issueCmd.AddCommand(issueListCmd)
issueCmd.AddCommand(issueViewCmd)
issueCmd.AddCommand(issueCreateCmd)
issueCmd.AddCommand(issueCommentCmd)
issueCmd.AddCommand(issueCloseCmd)
issueCmd.AddCommand(issueReopenCmd)
issueCmd.AddCommand(issueDeleteCmd)
2026-01-03 11:49:34 +01:00
issueCmd.AddCommand(issueEditCmd)
2025-12-08 09:49:07 +01:00
issueReopenCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
2025-12-08 09:49:07 +01:00
issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results")
issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username")
issueListCmd.Flags().String("author", "", "Filter by author username")
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter")
addJSONFlags(issueListCmd, "Output issues as JSON")
2025-12-08 09:49:07 +01:00
issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(issueViewCmd, "Output issue as JSON")
issueViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
2025-12-08 09:49:07 +01:00
issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
issueCreateCmd.Flags().StringP("body", "b", "", "Body for the issue")
issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Labels to add (can be specified multiple times)")
issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their login. Use \"@me\" to self-assign.")
issueCreateCmd.Flags().StringP("milestone", "m", "", "Milestone name to associate with the issue")
2025-12-08 09:49:07 +01:00
issueCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCommentCmd.Flags().StringP("body", "b", "", "Comment body")
issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing")
2026-01-03 11:49:34 +01:00
issueDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
2026-01-03 11:49:34 +01:00
issueEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue")
issueEditCmd.Flags().StringP("body", "b", "", "New body for the issue")
issueEditCmd.Flags().StringP("state", "s", "", "New state for the issue (open or closed)")
issueEditCmd.Flags().StringSlice("add-label", nil, "Labels to add (can be specified multiple times)")
issueEditCmd.Flags().StringSlice("remove-label", nil, "Labels to remove (can be specified multiple times)")
issueEditCmd.Flags().Int64Slice("add-dependency", nil, "Issue numbers to add as dependencies (can be specified multiple times)")
issueEditCmd.Flags().Int64Slice("remove-dependency", nil, "Issue numbers to remove as dependencies (can be specified multiple times)")
2025-12-08 09:49:07 +01:00
}
func runIssueList(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
state, _ := cmd.Flags().GetString("state")
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
author, _ := cmd.Flags().GetString("author")
labels, _ := cmd.Flags().GetStringSlice("label")
search, _ := cmd.Flags().GetString("search")
2025-12-08 09:49:07 +01:00
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
var stateType gitea.StateType
switch strings.ToLower(state) {
case "open":
stateType = gitea.StateOpen
case "closed":
stateType = gitea.StateClosed
case "all":
stateType = gitea.StateAll
default:
return fmt.Errorf("invalid state: %s", state)
}
ios.StartSpinner("Fetching issues...")
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
// ListRepoIssues returns both issues AND PRs (we filter PRs out below).
// Pull more than `limit` so post-filter we still have `limit` real issues
// — overshoot 2x as a heuristic. paginateGitea(0, ...) would be safer
// but spends extra round-trips; keep it bounded.
fetchLimit := limit * 2
if fetchLimit < 50 {
fetchLimit = 50
}
issues, err := paginateGitea(fetchLimit, func(page, pageSize int) ([]*gitea.Issue, error) {
batch, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
State: stateType,
Labels: labels,
KeyWord: search,
CreatedBy: author,
AssignedBy: assignee,
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
})
return batch, err
2025-12-08 09:49:07 +01:00
})
ios.StopSpinner()
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
nonPRIssues := make([]*gitea.Issue, 0, len(issues))
for _, issue := range issues {
if issue.PullRequest == nil {
nonPRIssues = append(nonPRIssues, issue)
}
}
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
if limit > 0 && len(nonPRIssues) > limit {
nonPRIssues = nonPRIssues[:limit]
}
if wantJSON(cmd) {
return outputJSON(cmd, nonPRIssues)
}
if len(nonPRIssues) == 0 {
fmt.Fprintf(ios.Out, "No %s issues in %s/%s\n", state, owner, name)
2025-12-08 09:49:07 +01:00
return nil
}
tp := ios.NewTablePrinter()
tp.AddHeader("NUMBER", "TITLE", "STATE")
for _, issue := range nonPRIssues {
tp.AddRow(fmt.Sprintf("#%d", issue.Index), issue.Title, string(issue.State))
2025-12-08 09:49:07 +01:00
}
return tp.Render()
2025-12-08 09:49:07 +01:00
}
func runIssueView(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
issueNumber, err := parseIssueArg(args[0])
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
ios.StartSpinner("Fetching issue...")
2025-12-08 09:49:07 +01:00
issue, _, err := client.GetIssue(owner, name, issueNumber)
if err != nil {
ios.StopSpinner()
2025-12-08 09:49:07 +01:00
return fmt.Errorf("failed to get issue: %w", err)
}
var comments []*gitea.Comment
comments, _, err = client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{})
if err != nil {
comments = nil
}
ios.StopSpinner()
if web, _ := cmd.Flags().GetBool("web"); web {
return ios.OpenInBrowser(issue.HTMLURL)
}
if wantJSON(cmd) {
payload := struct {
Issue *gitea.Issue `json:"issue"`
Comments []*gitea.Comment `json:"comments,omitempty"`
}{
Issue: issue,
Comments: comments,
}
return outputJSON(cmd, payload)
}
if err := ios.StartPager(); err != nil {
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
}
defer ios.StopPager()
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "Issue #%d\n", issue.Index)
fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(issue.Title))
fmt.Fprintf(ios.Out, "State: %s\n", issue.State)
fmt.Fprintf(ios.Out, "Author: %s\n", issue.Poster.UserName)
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(issue.Created, isTTY))
fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(issue.Updated, isTTY))
2025-12-08 09:49:07 +01:00
if issue.Body != "" {
fmt.Fprintf(ios.Out, "\n%s\n", issue.Body)
2025-12-08 09:49:07 +01:00
}
if len(comments) > 0 {
fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments))
2025-12-08 09:49:07 +01:00
for _, comment := range comments {
fmt.Fprintf(ios.Out, "\n---\n%s (@%s) - %s\n%s\n",
2025-12-08 09:49:07 +01:00
comment.Poster.FullName,
comment.Poster.UserName,
text.FormatDate(comment.Created, isTTY),
2025-12-08 09:49:07 +01:00
comment.Body)
}
}
return nil
}
func runIssueCreate(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
title, _ := cmd.Flags().GetString("title")
body, _ := cmd.Flags().GetString("body")
labelNames, _ := cmd.Flags().GetStringSlice("label")
assignees, _ := cmd.Flags().GetStringSlice("assignee")
milestoneName, _ := cmd.Flags().GetString("milestone")
2025-12-08 09:49:07 +01:00
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
// Interactive mode: prompt for missing fields when TTY
if title == "" && ios.IsStdinTTY() {
title, err = promptLine("Title: ")
if err != nil {
return err
}
if title == "" {
return fmt.Errorf("title is required")
}
if body == "" {
body, _ = promptLine("Body (optional): ")
}
} else if title == "" {
return fmt.Errorf("title is required (use -t flag)")
2025-12-08 09:49:07 +01:00
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
var labelIDs []int64
if len(labelNames) > 0 {
labelIDs, err = resolveLabelIDs(client, owner, name, labelNames)
if err != nil {
return err
}
}
// Resolve @me in assignees
resolvedAssignees := make([]string, 0, len(assignees))
for _, assignee := range assignees {
if assignee == "@me" {
user, _, userErr := client.GetMyUserInfo()
if userErr != nil {
return fmt.Errorf("failed to get current user info: %w", userErr)
}
resolvedAssignees = append(resolvedAssignees, user.UserName)
} else {
resolvedAssignees = append(resolvedAssignees, assignee)
}
}
// Resolve milestone name to ID
var milestoneID int64
if milestoneName != "" {
milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{})
if msErr != nil {
return fmt.Errorf("failed to list milestones: %w", msErr)
}
found := false
for _, ms := range milestones {
if ms.Title == milestoneName {
milestoneID = ms.ID
found = true
break
}
}
if !found {
return fmt.Errorf("milestone not found: %s", milestoneName)
}
}
ios.StartSpinner("Creating issue...")
2025-12-08 09:49:07 +01:00
issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{
Title: title,
Body: body,
Labels: labelIDs,
Assignees: resolvedAssignees,
Milestone: milestoneID,
2025-12-08 09:49:07 +01:00
})
ios.StopSpinner()
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue created: #%d\n", cs.SuccessIcon(), issue.Index)
fmt.Fprintf(ios.Out, "View at: %s\n", issue.HTMLURL)
2025-12-08 09:49:07 +01:00
return nil
}
func runIssueComment(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
body, _ := cmd.Flags().GetString("body")
issueNumber, err := parseIssueArg(args[0])
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
if body == "" {
return fmt.Errorf("comment body is required")
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
ios.StartSpinner("Adding comment...")
2025-12-08 09:49:07 +01:00
comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: body,
})
ios.StopSpinner()
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Comment added to issue #%d\n", cs.SuccessIcon(), issueNumber)
fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL)
2025-12-08 09:49:07 +01:00
return nil
}
func runIssueClose(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
commentBody, _ := cmd.Flags().GetString("comment")
issueNumber, err := parseIssueArg(args[0])
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
if commentBody != "" {
ios.StartSpinner("Adding comment...")
_, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: commentBody,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
}
ios.StartSpinner("Closing issue...")
2025-12-08 09:49:07 +01:00
stateClosed := gitea.StateClosed
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
State: &stateClosed,
})
ios.StopSpinner()
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d closed\n", cs.SuccessIcon(), issueNumber)
2025-12-08 09:49:07 +01:00
return nil
}
2026-01-03 11:49:34 +01:00
func runIssueEdit(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
title, _ := cmd.Flags().GetString("title")
body, _ := cmd.Flags().GetString("body")
stateStr, _ := cmd.Flags().GetString("state")
addLabelNames, _ := cmd.Flags().GetStringSlice("add-label")
removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-label")
addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency")
removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency")
2026-01-03 11:49:34 +01:00
issueNumber, err := parseIssueArg(args[0])
2026-01-03 11:49:34 +01:00
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 && len(addDeps) == 0 && len(removeDeps) == 0 {
return fmt.Errorf("at least one of --title, --body, --state, --add-label, --remove-label, --add-dependency, or --remove-dependency must be provided")
2026-01-03 11:49:34 +01:00
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
2026-01-03 11:49:34 +01:00
if err != nil {
return err
}
editOpt := gitea.EditIssueOption{}
if title != "" {
editOpt.Title = title
}
if body != "" {
editOpt.Body = &body
}
if stateStr != "" {
switch strings.ToLower(stateStr) {
case "open":
stateOpen := gitea.StateOpen
editOpt.State = &stateOpen
case "closed":
stateClosed := gitea.StateClosed
editOpt.State = &stateClosed
default:
return fmt.Errorf("invalid state: %s (must be 'open' or 'closed')", stateStr)
}
}
ios.StartSpinner("Updating issue...")
if title != "" || body != "" || stateStr != "" {
_, _, err = client.EditIssue(owner, name, issueNumber, editOpt)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to edit issue: %w", err)
}
}
if len(addLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames)
if err != nil {
ios.StopSpinner()
return err
}
_, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{
Labels: labelIDs,
})
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to add labels: %w", err)
}
}
if len(removeLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames)
if err != nil {
ios.StopSpinner()
return err
}
for _, labelID := range labelIDs {
_, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to remove label %d: %w", labelID, err)
}
}
2026-01-03 11:49:34 +01:00
}
ios.StopSpinner()
for _, depNumber := range addDeps {
depIssue, _, err := client.GetIssue(owner, name, depNumber)
if err != nil {
return fmt.Errorf("failed to get issue #%d: %w", depNumber, err)
}
depBody := map[string]int64{"id": depIssue.ID}
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/dependencies", owner, name, issueNumber)
_, err = client.DoJSON(http.MethodPost, path, depBody, nil)
if err != nil {
return fmt.Errorf("failed to add dependency #%d: %w", depNumber, err)
}
fmt.Fprintf(ios.Out, "Added dependency: #%d depends on #%d\n", issueNumber, depNumber)
}
for _, depNumber := range removeDeps {
depIssue, _, err := client.GetIssue(owner, name, depNumber)
if err != nil {
return fmt.Errorf("failed to get issue #%d: %w", depNumber, err)
}
depBody := map[string]int64{"id": depIssue.ID}
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/dependencies", owner, name, issueNumber)
_, err = client.DoJSON(http.MethodDelete, path, depBody, nil)
if err != nil {
return fmt.Errorf("failed to remove dependency #%d: %w", depNumber, err)
}
fmt.Fprintf(ios.Out, "Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d updated\n", cs.SuccessIcon(), issueNumber)
return nil
}
func runIssueDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return err
}
if !yes {
confirmed, confirmErr := ios.ConfirmAction(fmt.Sprintf("Permanently delete issue #%d from %s/%s?", issueNumber, owner, name))
if confirmErr != nil {
return confirmErr
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Deleting issue...")
_, err = client.DeleteIssue(owner, name, issueNumber)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to delete issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d deleted from %s/%s\n", cs.SuccessIcon(), issueNumber, owner, name)
return nil
}
func runIssueReopen(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return err
}
ios.StartSpinner("Reopening issue...")
stateOpen := gitea.StateOpen
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
State: &stateOpen,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to reopen issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d reopened\n", cs.SuccessIcon(), issueNumber)
2026-01-03 11:49:34 +01:00
return nil
}
func resolveLabelIDs(client *api.Client, owner, name string, labelNames []string) ([]int64, error) {
if len(labelNames) == 0 {
return nil, nil
}
labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list labels: %w", err)
}
labelNameToID := make(map[string]int64)
for _, label := range labels {
labelNameToID[label.Name] = label.ID
}
var labelIDs []int64
var missingLabels []string
for _, labelName := range labelNames {
labelID, exists := labelNameToID[labelName]
if !exists {
missingLabels = append(missingLabels, labelName)
continue
}
labelIDs = append(labelIDs, labelID)
}
if len(missingLabels) > 0 {
return nil, fmt.Errorf("labels not found: %s", strings.Join(missingLabels, ", "))
}
return labelIDs, nil
}