fj/cmd/issue.go

817 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"
feat: logins list/default, actions run delete, date filters, label update alias - fgj logins {list,default}: complementary UI to 'fgj auth'. 'list' shows all configured hosts (hostname, user, protocol, default flag, match_dirs) with --json. 'default [hostname]' gets or sets which host wins in resolution when no other signal is present. Adds 'Default bool' field to HostConfig; GetHost consults it between match_dirs and the codeberg.org fallback. Multiple defaults tolerated with a stderr warning; alphabetical-first wins. - fgj actions run delete: delete a completed workflow run via raw DELETE (SDK v0.23.2 has no DeleteRepoActionRun). Fetches run first and refuses to delete non-terminal states unless --force; suggests 'actions run cancel' for those. Confirmation prompt unless --yes. - pr list / issue list gain --since and --before date filter flags. Accepts YYYY-MM-DD, RFC 3339, YYYY-MM-DD HH:MM:SS, and relative deltas (7d, 24h, 2w, 1m — months=30 days). Issues use server-side filter via ListIssueOption.Since/Before; PRs fall back to client-side (SDK lacks Since/Before on ListPullRequestsOptions). - fgj label update added as alias for 'fgj label edit' (tea-compat). All changes: cmd/logins.go (new, 140 LOC) cmd/actions_run_delete.go (new, ~85 LOC) cmd/pr.go, cmd/issue.go (+parseDateArg helper, filter wiring) cmd/label.go (1-line alias) internal/config/config.go (Default field + DefaultHost method) CHANGELOG.md Built in parallel by three sub-agents; plus the label alias done serially. go build / go vet / go test -race all clean.
2026-04-19 23:04:33 -06:00
"time"
2025-12-08 09:49:07 +01:00
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fgj-sid/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
fgj issue list
# List closed issues for a specific repo
fgj issue list -s closed -R owner/repo
# Output as JSON
feat: logins list/default, actions run delete, date filters, label update alias - fgj logins {list,default}: complementary UI to 'fgj auth'. 'list' shows all configured hosts (hostname, user, protocol, default flag, match_dirs) with --json. 'default [hostname]' gets or sets which host wins in resolution when no other signal is present. Adds 'Default bool' field to HostConfig; GetHost consults it between match_dirs and the codeberg.org fallback. Multiple defaults tolerated with a stderr warning; alphabetical-first wins. - fgj actions run delete: delete a completed workflow run via raw DELETE (SDK v0.23.2 has no DeleteRepoActionRun). Fetches run first and refuses to delete non-terminal states unless --force; suggests 'actions run cancel' for those. Confirmation prompt unless --yes. - pr list / issue list gain --since and --before date filter flags. Accepts YYYY-MM-DD, RFC 3339, YYYY-MM-DD HH:MM:SS, and relative deltas (7d, 24h, 2w, 1m — months=30 days). Issues use server-side filter via ListIssueOption.Since/Before; PRs fall back to client-side (SDK lacks Since/Before on ListPullRequestsOptions). - fgj label update added as alias for 'fgj label edit' (tea-compat). All changes: cmd/logins.go (new, 140 LOC) cmd/actions_run_delete.go (new, ~85 LOC) cmd/pr.go, cmd/issue.go (+parseDateArg helper, filter wiring) cmd/label.go (1-line alias) internal/config/config.go (Default field + DefaultHost method) CHANGELOG.md Built in parallel by three sub-agents; plus the label alias done serially. go build / go vet / go test -race all clean.
2026-04-19 23:04:33 -06:00
fgj issue list --json
# PRs updated in the last 7 days
fgj pr list --since 7d
# Issues touched between two dates
fgj issue list --since 2026-04-01 --before 2026-04-15`,
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
fgj issue view 42
# View using URL
fgj issue view https://codeberg.org/owner/repo/issues/42
# Open in browser
fgj issue view 42 --web
# View an issue from a specific repo as JSON
fgj 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
fgj issue create -t "Fix login bug"
# Create an issue with title, body, and labels
fgj 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
fgj issue comment 42 -b "This is fixed in the latest release"
# Comment on an issue in a specific repo
fgj 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
fgj issue close 42
# Close with a comment
fgj 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
fgj 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
fgj issue delete 42
# Delete without confirmation
fgj 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
fgj issue edit 42 -t "Updated title"
# Reopen a closed issue
fgj issue edit 42 -s open
# Add and remove labels
fgj issue edit 42 --add-label bug --remove-label wontfix
# Add a dependency
fgj 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")
feat: logins list/default, actions run delete, date filters, label update alias - fgj logins {list,default}: complementary UI to 'fgj auth'. 'list' shows all configured hosts (hostname, user, protocol, default flag, match_dirs) with --json. 'default [hostname]' gets or sets which host wins in resolution when no other signal is present. Adds 'Default bool' field to HostConfig; GetHost consults it between match_dirs and the codeberg.org fallback. Multiple defaults tolerated with a stderr warning; alphabetical-first wins. - fgj actions run delete: delete a completed workflow run via raw DELETE (SDK v0.23.2 has no DeleteRepoActionRun). Fetches run first and refuses to delete non-terminal states unless --force; suggests 'actions run cancel' for those. Confirmation prompt unless --yes. - pr list / issue list gain --since and --before date filter flags. Accepts YYYY-MM-DD, RFC 3339, YYYY-MM-DD HH:MM:SS, and relative deltas (7d, 24h, 2w, 1m — months=30 days). Issues use server-side filter via ListIssueOption.Since/Before; PRs fall back to client-side (SDK lacks Since/Before on ListPullRequestsOptions). - fgj label update added as alias for 'fgj label edit' (tea-compat). All changes: cmd/logins.go (new, 140 LOC) cmd/actions_run_delete.go (new, ~85 LOC) cmd/pr.go, cmd/issue.go (+parseDateArg helper, filter wiring) cmd/label.go (1-line alias) internal/config/config.go (Default field + DefaultHost method) CHANGELOG.md Built in parallel by three sub-agents; plus the label alias done serially. go build / go vet / go test -race all clean.
2026-04-19 23:04:33 -06:00
issueListCmd.Flags().String("since", "", "Only items updated at or after this date (YYYY-MM-DD, RFC 3339, or relative like 7d)")
issueListCmd.Flags().String("before", "", "Only items updated strictly before this date (YYYY-MM-DD, RFC 3339, or relative like 1d)")
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")
feat: logins list/default, actions run delete, date filters, label update alias - fgj logins {list,default}: complementary UI to 'fgj auth'. 'list' shows all configured hosts (hostname, user, protocol, default flag, match_dirs) with --json. 'default [hostname]' gets or sets which host wins in resolution when no other signal is present. Adds 'Default bool' field to HostConfig; GetHost consults it between match_dirs and the codeberg.org fallback. Multiple defaults tolerated with a stderr warning; alphabetical-first wins. - fgj actions run delete: delete a completed workflow run via raw DELETE (SDK v0.23.2 has no DeleteRepoActionRun). Fetches run first and refuses to delete non-terminal states unless --force; suggests 'actions run cancel' for those. Confirmation prompt unless --yes. - pr list / issue list gain --since and --before date filter flags. Accepts YYYY-MM-DD, RFC 3339, YYYY-MM-DD HH:MM:SS, and relative deltas (7d, 24h, 2w, 1m — months=30 days). Issues use server-side filter via ListIssueOption.Since/Before; PRs fall back to client-side (SDK lacks Since/Before on ListPullRequestsOptions). - fgj label update added as alias for 'fgj label edit' (tea-compat). All changes: cmd/logins.go (new, 140 LOC) cmd/actions_run_delete.go (new, ~85 LOC) cmd/pr.go, cmd/issue.go (+parseDateArg helper, filter wiring) cmd/label.go (1-line alias) internal/config/config.go (Default field + DefaultHost method) CHANGELOG.md Built in parallel by three sub-agents; plus the label alias done serially. go build / go vet / go test -race all clean.
2026-04-19 23:04:33 -06:00
sinceStr, _ := cmd.Flags().GetString("since")
beforeStr, _ := cmd.Flags().GetString("before")
var sinceTime, beforeTime time.Time
if sinceStr != "" {
t, err := parseDateArg(sinceStr)
if err != nil {
return fmt.Errorf("invalid --since: %w", err)
}
sinceTime = t
}
if beforeStr != "" {
t, err := parseDateArg(beforeStr)
if err != nil {
return fmt.Errorf("invalid --before: %w", err)
}
beforeTime = t
}
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...")
2025-12-08 09:49:07 +01:00
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
State: stateType,
Labels: labels,
KeyWord: search,
CreatedBy: author,
AssignedBy: assignee,
feat: logins list/default, actions run delete, date filters, label update alias - fgj logins {list,default}: complementary UI to 'fgj auth'. 'list' shows all configured hosts (hostname, user, protocol, default flag, match_dirs) with --json. 'default [hostname]' gets or sets which host wins in resolution when no other signal is present. Adds 'Default bool' field to HostConfig; GetHost consults it between match_dirs and the codeberg.org fallback. Multiple defaults tolerated with a stderr warning; alphabetical-first wins. - fgj actions run delete: delete a completed workflow run via raw DELETE (SDK v0.23.2 has no DeleteRepoActionRun). Fetches run first and refuses to delete non-terminal states unless --force; suggests 'actions run cancel' for those. Confirmation prompt unless --yes. - pr list / issue list gain --since and --before date filter flags. Accepts YYYY-MM-DD, RFC 3339, YYYY-MM-DD HH:MM:SS, and relative deltas (7d, 24h, 2w, 1m — months=30 days). Issues use server-side filter via ListIssueOption.Since/Before; PRs fall back to client-side (SDK lacks Since/Before on ListPullRequestsOptions). - fgj label update added as alias for 'fgj label edit' (tea-compat). All changes: cmd/logins.go (new, 140 LOC) cmd/actions_run_delete.go (new, ~85 LOC) cmd/pr.go, cmd/issue.go (+parseDateArg helper, filter wiring) cmd/label.go (1-line alias) internal/config/config.go (Default field + DefaultHost method) CHANGELOG.md Built in parallel by three sub-agents; plus the label alias done serially. go build / go vet / go test -race all clean.
2026-04-19 23:04:33 -06:00
Since: sinceTime,
Before: beforeTime,
ListOptions: gitea.ListOptions{PageSize: limit},
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)
}
}
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
}