fj/cmd/pr.go

1164 lines
32 KiB
Go
Raw Permalink Normal View History

2025-12-08 09:49:07 +01:00
package cmd
import (
"fmt"
"net/http"
"os/exec"
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
"strconv"
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"
gitpkg "forgejo.zerova.net/public/fgj-sid/internal/git"
"forgejo.zerova.net/public/fgj-sid/internal/text"
"github.com/spf13/cobra"
2025-12-08 09:49:07 +01:00
)
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
// parseDateArg parses a --since / --before argument.
// Accepted forms: "2006-01-02", RFC 3339, "2006-01-02 15:04:05" (local),
// or a relative delta like "7d", "24h", "2w", "1m" (months treated as 30 days).
func parseDateArg(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("empty date")
}
// Relative delta: <N><unit>
if last := s[len(s)-1]; last == 'h' || last == 'd' || last == 'w' || last == 'm' {
numPart := s[:len(s)-1]
if n, err := strconv.Atoi(numPart); err == nil && n >= 0 {
var d time.Duration
switch last {
case 'h':
d = time.Duration(n) * time.Hour
case 'd':
d = time.Duration(n) * 24 * time.Hour
case 'w':
d = time.Duration(n) * 7 * 24 * time.Hour
case 'm':
// Months treated as 30 days (crude but documented).
d = time.Duration(n) * 30 * 24 * time.Hour
}
return time.Now().Add(-d), nil
}
}
layouts := []string{
"2006-01-02",
time.RFC3339,
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if layout == "2006-01-02" || layout == "2006-01-02 15:04:05" {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t, nil
}
} else {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
}
return time.Time{}, fmt.Errorf("unrecognized date format: %q (expected YYYY-MM-DD, RFC 3339, 'YYYY-MM-DD HH:MM:SS', or a relative delta like 7d/24h/2w/1m)", s)
}
2025-12-08 09:49:07 +01:00
var prCmd = &cobra.Command{
Use: "pr",
Aliases: []string{"pull-request"},
Short: "Manage pull requests",
Long: "Create, view, list, and manage pull requests.",
}
var prListCmd = &cobra.Command{
Use: "list [flags]",
Short: "List pull requests",
Long: "List pull requests in a repository.",
Example: ` # List open pull requests
fgj pr list
# List all pull requests for a specific repo
fgj pr list -s all -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 pr 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: runPRList,
2025-12-08 09:49:07 +01:00
}
var prViewCmd = &cobra.Command{
Use: "view [<number>]",
2025-12-08 09:49:07 +01:00
Short: "View a pull request",
Long: "Display detailed information about a pull request.",
Example: ` # View pull request #5
fgj pr view 5
# View using URL
fgj pr view https://codeberg.org/owner/repo/pulls/5
# View PR for current branch
fgj pr view
# Open in browser
fgj pr view 5 --web
# View as JSON
fgj pr view 5 --json`,
Args: cobra.MaximumNArgs(1),
RunE: runPRView,
2025-12-08 09:49:07 +01:00
}
var prCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a pull request",
Long: "Create a new pull request.",
Example: ` # Create a pull request from feature branch to main
fgj pr create -t "Add login page" -H feature/login
# Create with body and custom base branch
fgj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop
# Create and self-assign
fgj pr create -t "Update docs" -H docs/update -a @me`,
RunE: runPRCreate,
2025-12-08 09:49:07 +01:00
}
var prMergeCmd = &cobra.Command{
Use: "merge <number>",
Short: "Merge a pull request",
Long: "Merge a pull request.",
Example: ` # Merge pull request #5
fgj pr merge 5
# Squash merge
fgj pr merge 5 --merge-method squash
# Rebase merge
fgj pr merge 5 --merge-method rebase
# Merge without confirmation
fgj pr merge 5 -y`,
Args: cobra.ExactArgs(1),
RunE: runPRMerge,
}
var prCloseCmd = &cobra.Command{
Use: "close <number>",
Short: "Close a pull request",
Long: "Close a pull request without merging.",
Example: ` # Close PR #5
fgj pr close 5
# Close with a comment
fgj pr close 5 -c "Won't merge, superseded by #10"`,
Args: cobra.ExactArgs(1),
RunE: runPRClose,
}
var prReopenCmd = &cobra.Command{
Use: "reopen <number>",
Short: "Reopen a pull request",
Long: "Reopen a closed pull request.",
Example: ` # Reopen PR #5
fgj pr reopen 5`,
Args: cobra.ExactArgs(1),
RunE: runPRReopen,
}
var prEditCmd = &cobra.Command{
Use: "edit <number>",
Short: "Edit a pull request",
Long: "Edit a pull request's title, body, or metadata.",
Example: ` # Update the title of PR #5
fgj pr edit 5 -t "Updated title"
# Add assignees and labels
fgj pr edit 5 --add-assignee user1 --add-label bug
# Remove a reviewer and set milestone
fgj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`,
Args: cobra.ExactArgs(1),
RunE: runPREdit,
}
var prCheckoutCmd = &cobra.Command{
Use: "checkout <number>",
Short: "Check out a pull request locally",
Long: "Check out the head branch of a pull request.",
Example: ` # Check out PR #5
fgj pr checkout 5`,
Args: cobra.ExactArgs(1),
RunE: runPRCheckout,
2025-12-08 09:49:07 +01:00
}
func init() {
rootCmd.AddCommand(prCmd)
prCmd.AddCommand(prListCmd)
prCmd.AddCommand(prViewCmd)
prCmd.AddCommand(prCreateCmd)
prCmd.AddCommand(prMergeCmd)
prCmd.AddCommand(prCloseCmd)
prCmd.AddCommand(prReopenCmd)
prCmd.AddCommand(prEditCmd)
prCmd.AddCommand(prCheckoutCmd)
prCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing")
prReopenCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
2025-12-08 09:49:07 +01:00
prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
prListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results")
prListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username")
prListCmd.Flags().String("author", "", "Filter by author username")
prListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
prListCmd.Flags().StringP("search", "S", "", "Search keyword filter")
prListCmd.Flags().Bool("draft", false, "Filter by draft status")
prListCmd.Flags().String("head", "", "Filter by head branch")
prListCmd.Flags().String("base", "", "Filter by base branch")
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
prListCmd.Flags().String("since", "", "Only items updated at or after this date (YYYY-MM-DD, RFC 3339, or relative like 7d)")
prListCmd.Flags().String("before", "", "Only items updated strictly before this date (YYYY-MM-DD, RFC 3339, or relative like 1d)")
prListCmd.Flags().BoolP("web", "w", false, "Open list in web browser")
addJSONFlags(prListCmd, "Output pull requests as JSON")
2025-12-08 09:49:07 +01:00
prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(prViewCmd, "Output pull request as JSON")
prViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
2025-12-08 09:49:07 +01:00
prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request")
prCreateCmd.Flags().StringP("body", "b", "", "Body for the pull request")
prCreateCmd.Flags().StringP("head", "H", "", "Head branch")
prCreateCmd.Flags().StringP("base", "B", "", "Base branch (default: main)")
prCreateCmd.Flags().StringSliceP("assignee", "a", []string{}, "Assign people by their login. Use \"@me\" to self-assign.")
prCreateCmd.Flags().BoolP("draft", "d", false, "Create as draft pull request")
prCreateCmd.Flags().StringSliceP("reviewer", "r", nil, "Request reviewers by username")
prCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by name")
prCreateCmd.Flags().StringP("milestone", "m", "", "Set milestone by name")
2025-12-08 09:49:07 +01:00
prMergeCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prMergeCmd.Flags().String("merge-method", "merge", "Merge method: merge, rebase, squash")
prMergeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
prMergeCmd.Flags().BoolP("delete-branch", "d", false, "Delete the branch after merge")
prMergeCmd.Flags().Bool("auto", false, "Merge automatically when checks succeed")
prCheckoutCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prEditCmd.Flags().StringP("title", "t", "", "New title for the pull request")
prEditCmd.Flags().StringP("body", "b", "", "New body for the pull request")
prEditCmd.Flags().StringP("base", "B", "", "New base branch for the pull request")
prEditCmd.Flags().StringSlice("add-assignee", nil, "Assignees to add (login names)")
prEditCmd.Flags().StringSlice("remove-assignee", nil, "Assignees to remove (login names)")
prEditCmd.Flags().StringSlice("add-label", nil, "Labels to add (by name)")
prEditCmd.Flags().StringSlice("remove-label", nil, "Labels to remove (by name)")
prEditCmd.Flags().StringSlice("add-reviewer", nil, "Reviewers to add (login names)")
prEditCmd.Flags().StringSlice("remove-reviewer", nil, "Reviewers to remove (login names)")
prEditCmd.Flags().String("milestone", "", "Milestone name to set (empty string to clear)")
2025-12-08 09:49:07 +01:00
}
func runPRList(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")
draft, _ := cmd.Flags().GetBool("draft")
head, _ := cmd.Flags().GetString("head")
base, _ := cmd.Flags().GetString("base")
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
var hasSince, hasBefore bool
if sinceStr != "" {
t, err := parseDateArg(sinceStr)
if err != nil {
return fmt.Errorf("invalid --since: %w", err)
}
sinceTime = t
hasSince = true
}
if beforeStr != "" {
t, err := parseDateArg(beforeStr)
if err != nil {
return fmt.Errorf("invalid --before: %w", err)
}
beforeTime = t
hasBefore = true
}
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
}
if web, _ := cmd.Flags().GetBool("web"); web {
return ios.OpenInBrowser(fmt.Sprintf("https://%s/%s/%s/pulls", client.Hostname(), owner, name))
}
2025-12-08 09:49:07 +01:00
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)
}
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
// server-side since/before unsupported for pulls; filtering client-side
needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != "" || hasSince || hasBefore
ios.StartSpinner("Fetching pull requests...")
var prs []*gitea.PullRequest
if needsClientFilter {
page := 1
for {
batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
State: stateType,
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list pull requests: %w", err)
}
prs = append(prs, batch...)
if len(batch) < 50 {
break
}
page++
}
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
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
if hasSince || hasBefore {
prs = filterPRsByDate(prs, sinceTime, hasSince, beforeTime, hasBefore)
}
if len(prs) > limit {
prs = prs[:limit]
}
} else {
prs, _, err = client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
State: stateType,
ListOptions: gitea.ListOptions{PageSize: limit},
})
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list pull requests: %w", err)
}
2025-12-08 09:49:07 +01:00
}
ios.StopSpinner()
2025-12-08 09:49:07 +01:00
if wantJSON(cmd) {
return outputJSON(cmd, prs)
}
2025-12-08 09:49:07 +01:00
if len(prs) == 0 {
fmt.Fprintf(ios.Out, "No %s pull requests in %s/%s\n", state, owner, name)
2025-12-08 09:49:07 +01:00
return nil
}
tp := ios.NewTablePrinter()
tp.AddHeader("NUMBER", "TITLE", "BRANCH", "STATE")
2025-12-08 09:49:07 +01:00
for _, pr := range prs {
tp.AddRow(fmt.Sprintf("#%d", pr.Index), pr.Title, pr.Head.Ref, string(pr.State))
2025-12-08 09:49:07 +01:00
}
return tp.Render()
}
2025-12-08 09:49:07 +01:00
func filterPRs(prs []*gitea.PullRequest, author, assignee string, labels []string, search string, draft bool, head, base string) []*gitea.PullRequest {
var result []*gitea.PullRequest
for _, pr := range prs {
if author != "" && !strings.EqualFold(pr.Poster.UserName, author) {
continue
}
if assignee != "" {
found := false
for _, a := range pr.Assignees {
if strings.EqualFold(a.UserName, assignee) {
found = true
break
}
}
if !found {
continue
}
}
if len(labels) > 0 {
prLabelNames := make(map[string]bool)
for _, l := range pr.Labels {
prLabelNames[strings.ToLower(l.Name)] = true
}
allFound := true
for _, label := range labels {
if !prLabelNames[strings.ToLower(label)] {
allFound = false
break
}
}
if !allFound {
continue
}
}
if search != "" && !strings.Contains(strings.ToLower(pr.Title), strings.ToLower(search)) {
continue
}
if draft && !pr.Draft {
continue
}
if head != "" && !strings.EqualFold(pr.Head.Ref, head) {
continue
}
if base != "" && !strings.EqualFold(pr.Base.Ref, base) {
continue
}
result = append(result, pr)
}
return result
2025-12-08 09:49:07 +01:00
}
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
// filterPRsByDate applies the --since / --before range against pr.Updated.
func filterPRsByDate(prs []*gitea.PullRequest, since time.Time, hasSince bool, before time.Time, hasBefore bool) []*gitea.PullRequest {
if !hasSince && !hasBefore {
return prs
}
result := make([]*gitea.PullRequest, 0, len(prs))
for _, pr := range prs {
if pr.Updated == nil {
continue
}
if hasSince && pr.Updated.Before(since) {
continue
}
if hasBefore && !pr.Updated.Before(before) {
continue
}
result = append(result, pr)
}
return result
}
2025-12-08 09:49:07 +01:00
func runPRView(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
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 prNumber int64
if len(args) == 0 {
// Try to find PR for current branch
branch, branchErr := gitpkg.GetCurrentBranch()
if branchErr != nil {
return fmt.Errorf("no pull request number specified and could not detect current branch: %w", branchErr)
}
ios.StartSpinner("Finding pull request for branch...")
prs, _, listErr := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
State: gitea.StateOpen,
})
ios.StopSpinner()
if listErr != nil {
return fmt.Errorf("failed to list pull requests: %w", listErr)
}
var found bool
for _, pr := range prs {
if pr.Head.Ref == branch {
prNumber = pr.Index
found = true
break
}
}
if !found {
return fmt.Errorf("no open pull request found for branch %q", branch)
}
} else {
prNumber, err = parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
}
ios.StartSpinner("Fetching pull request...")
2025-12-08 09:49:07 +01:00
pr, _, err := client.GetPullRequest(owner, name, prNumber)
ios.StopSpinner()
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("failed to get pull request: %w", err)
}
if web, _ := cmd.Flags().GetBool("web"); web {
return ios.OpenInBrowser(pr.HTMLURL)
}
if wantJSON(cmd) {
return outputJSON(cmd, pr)
}
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, "%s Pull Request #%d\n", cs.Bold(""), pr.Index)
fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(pr.Title))
fmt.Fprintf(ios.Out, "State: %s\n", pr.State)
fmt.Fprintf(ios.Out, "Author: %s\n", pr.Poster.UserName)
fmt.Fprintf(ios.Out, "Branch: %s -> %s\n", pr.Head.Ref, pr.Base.Ref)
if pr.Created != nil {
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(*pr.Created, isTTY))
}
if pr.Updated != nil {
fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*pr.Updated, isTTY))
}
2025-12-08 09:49:07 +01:00
if pr.Body != "" {
fmt.Fprintf(ios.Out, "\n%s\n", pr.Body)
2025-12-08 09:49:07 +01:00
}
return nil
}
func runPRCreate(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
title, _ := cmd.Flags().GetString("title")
body, _ := cmd.Flags().GetString("body")
head, _ := cmd.Flags().GetString("head")
base, _ := cmd.Flags().GetString("base")
assignees, _ := cmd.Flags().GetStringSlice("assignee")
draft, _ := cmd.Flags().GetBool("draft")
reviewers, _ := cmd.Flags().GetStringSlice("reviewer")
labelNames, _ := cmd.Flags().GetStringSlice("label")
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")
}
} else if title == "" {
return fmt.Errorf("title is required (use -t flag)")
2025-12-08 09:49:07 +01:00
}
if head == "" && ios.IsStdinTTY() {
// Default to current branch
branch, branchErr := gitpkg.GetCurrentBranch()
if branchErr == nil {
head = branch
fmt.Fprintf(ios.ErrOut, "Using current branch %q as head\n", head)
} else {
head, err = promptLine("Head branch: ")
if err != nil {
return err
}
}
}
2025-12-08 09:49:07 +01:00
if head == "" {
return fmt.Errorf("head branch is required (use -H 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
}
if base == "" {
ios.StartSpinner("Fetching repository info...")
repoInfo, _, err := client.GetRepo(owner, name)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to get repository info: %w", err)
}
base = repoInfo.DefaultBranch
}
// Resolve @me in assignees
resolvedAssignees := make([]string, 0, len(assignees))
for _, assignee := range assignees {
if assignee == "@me" {
user, _, err := client.GetMyUserInfo()
if err != nil {
return fmt.Errorf("failed to get current user info: %w", err)
}
resolvedAssignees = append(resolvedAssignees, user.UserName)
} else {
resolvedAssignees = append(resolvedAssignees, assignee)
}
}
// Resolve label names to IDs
var labelIDs []int64
if len(labelNames) > 0 {
labelIDs, err = resolveLabelIDs(client, owner, name, labelNames)
if err != nil {
return err
}
}
// 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 pull request...")
2025-12-08 09:49:07 +01:00
pr, _, err := client.CreatePullRequest(owner, name, gitea.CreatePullRequestOption{
Title: title,
Body: body,
Head: head,
Base: base,
Assignees: resolvedAssignees,
Reviewers: reviewers,
Labels: labelIDs,
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 pull request: %w", err)
}
// Set draft status via raw API if needed
if draft {
_, draftErr := client.DoJSON("PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, name, pr.Index), map[string]any{"draft": true}, nil)
if draftErr != nil {
return fmt.Errorf("failed to set pull request as draft: %w", draftErr)
}
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Pull request created: #%d\n", cs.SuccessIcon(), pr.Index)
fmt.Fprintf(ios.Out, "View at: %s\n", pr.HTMLURL)
2025-12-08 09:49:07 +01:00
return nil
}
func runPRMerge(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
mergeMethod, _ := cmd.Flags().GetString("merge-method")
yes, _ := cmd.Flags().GetBool("yes")
deleteBranch, _ := cmd.Flags().GetBool("delete-branch")
autoMerge, _ := cmd.Flags().GetBool("auto")
prNumber, err := parseIssueArg(args[0])
2025-12-08 09:49:07 +01:00
if err != nil {
return fmt.Errorf("invalid pull request 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
}
var method gitea.MergeStyle
switch strings.ToLower(mergeMethod) {
case "merge":
method = gitea.MergeStyleMerge
case "rebase":
method = gitea.MergeStyleRebase
case "squash":
method = gitea.MergeStyleSquash
default:
return fmt.Errorf("invalid merge method: %s", mergeMethod)
}
if !yes {
confirmed, err := ios.ConfirmAction(fmt.Sprintf("Merge pull request #%d via %s?", prNumber, mergeMethod))
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Merging pull request...")
2025-12-08 09:49:07 +01:00
_, _, err = client.MergePullRequest(owner, name, prNumber, gitea.MergePullRequestOption{
Style: method,
DeleteBranchAfterMerge: deleteBranch,
MergeWhenChecksSucceed: autoMerge,
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 merge pull request: %w", err)
}
cs := ios.ColorScheme()
if autoMerge {
fmt.Fprintf(ios.Out, "%s Auto-merge enabled for PR #%d\n", cs.SuccessIcon(), prNumber)
} else {
fmt.Fprintf(ios.Out, "%s Pull request #%d merged successfully\n", cs.SuccessIcon(), prNumber)
}
return nil
}
func runPRClose(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
commentBody, _ := cmd.Flags().GetString("comment")
prNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid pull request 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 commentBody != "" {
ios.StartSpinner("Adding comment...")
_, _, err = client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{
Body: commentBody,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
}
ios.StartSpinner("Closing pull request...")
stateClosed := gitea.StateClosed
_, _, err = client.EditPullRequest(owner, name, prNumber, gitea.EditPullRequestOption{
State: &stateClosed,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to close pull request: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Pull request #%d closed\n", cs.SuccessIcon(), prNumber)
return nil
}
func runPRReopen(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
prNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid pull request 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 pull request...")
stateOpen := gitea.StateOpen
_, _, err = client.EditPullRequest(owner, name, prNumber, gitea.EditPullRequestOption{
State: &stateOpen,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to reopen pull request: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Pull request #%d reopened\n", cs.SuccessIcon(), prNumber)
return nil
}
func runPRCheckout(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
prNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid pull request 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("Fetching pull request...")
pr, _, err := client.GetPullRequest(owner, name, prNumber)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to get pull request: %w", err)
}
headBranch := pr.Head.Ref
headRepo := pr.Head.Repository
// Determine if same-repo or cross-repo PR
isSameRepo := headRepo == nil || headRepo.FullName == fmt.Sprintf("%s/%s", owner, name)
if isSameRepo {
// Same repo: fetch and checkout
ios.StartSpinner("Checking out branch...")
gitFetch := exec.Command("git", "fetch", "origin", headBranch)
gitFetch.Stdout = ios.Out
gitFetch.Stderr = ios.ErrOut
if err := gitFetch.Run(); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to fetch branch: %w", err)
}
// Try to checkout existing branch first
gitCheckout := exec.Command("git", "checkout", headBranch)
gitCheckout.Stdout = ios.Out
gitCheckout.Stderr = ios.ErrOut
if err := gitCheckout.Run(); err != nil {
// Branch doesn't exist locally, create it tracking remote
gitCheckout = exec.Command("git", "checkout", "-b", headBranch, "origin/"+headBranch)
gitCheckout.Stdout = ios.Out
gitCheckout.Stderr = ios.ErrOut
if err := gitCheckout.Run(); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to checkout branch: %w", err)
}
} else {
// Branch existed, pull latest
gitPull := exec.Command("git", "pull")
gitPull.Stdout = ios.Out
gitPull.Stderr = ios.ErrOut
_ = gitPull.Run()
}
ios.StopSpinner()
} else {
// Cross-repo (fork): add remote and checkout
forkOwner := headRepo.Owner.UserName
forkCloneURL := headRepo.CloneURL
ios.StartSpinner("Checking out branch from fork...")
// Add fork as remote (ignore error if already exists)
gitRemoteAdd := exec.Command("git", "remote", "add", forkOwner, forkCloneURL)
gitRemoteAdd.Stdout = ios.Out
gitRemoteAdd.Stderr = ios.ErrOut
_ = gitRemoteAdd.Run() // ignore error if remote already exists
// Fetch from fork
gitFetch := exec.Command("git", "fetch", forkOwner)
gitFetch.Stdout = ios.Out
gitFetch.Stderr = ios.ErrOut
if err := gitFetch.Run(); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to fetch from fork: %w", err)
}
// Checkout the branch
gitCheckout := exec.Command("git", "checkout", "-b", headBranch, forkOwner+"/"+headBranch)
gitCheckout.Stdout = ios.Out
gitCheckout.Stderr = ios.ErrOut
if err := gitCheckout.Run(); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to checkout branch: %w", err)
}
ios.StopSpinner()
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Checked out PR #%d on branch %q\n", cs.SuccessIcon(), prNumber, headBranch)
return nil
}
func runPREdit(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
prNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
// Check that at least one flag was provided
anyChanged := false
for _, flag := range []string{"title", "body", "base", "add-assignee", "remove-assignee",
"add-label", "remove-label", "add-reviewer", "remove-reviewer", "milestone"} {
if cmd.Flags().Changed(flag) {
anyChanged = true
break
}
}
if !anyChanged {
return fmt.Errorf("at least one of --title, --body, --base, --add-assignee, --remove-assignee, " +
"--add-label, --remove-label, --add-reviewer, --remove-reviewer, or --milestone must be provided")
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return err
}
// Build EditPullRequestOption from changed flags
editOpt := gitea.EditPullRequestOption{}
needsEditCall := false
if cmd.Flags().Changed("title") {
title, _ := cmd.Flags().GetString("title")
editOpt.Title = title
needsEditCall = true
}
if cmd.Flags().Changed("body") {
body, _ := cmd.Flags().GetString("body")
editOpt.Body = &body
needsEditCall = true
}
if cmd.Flags().Changed("base") {
base, _ := cmd.Flags().GetString("base")
editOpt.Base = base
needsEditCall = true
}
// Handle milestone
if cmd.Flags().Changed("milestone") {
milestoneName, _ := cmd.Flags().GetString("milestone")
if milestoneName == "" {
// Clear milestone by setting to 0
editOpt.Milestone = 0
} else {
milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{})
if msErr != nil {
return fmt.Errorf("failed to list milestones: %w", msErr)
}
var milestoneID int64
for _, ms := range milestones {
if ms.Title == milestoneName {
milestoneID = ms.ID
break
}
}
if milestoneID == 0 {
return fmt.Errorf("milestone not found: %s", milestoneName)
}
editOpt.Milestone = milestoneID
}
needsEditCall = true
}
// Handle assignees (add/remove requires fetching current PR)
addAssignees, _ := cmd.Flags().GetStringSlice("add-assignee")
removeAssignees, _ := cmd.Flags().GetStringSlice("remove-assignee")
if len(addAssignees) > 0 || len(removeAssignees) > 0 {
ios.StartSpinner("Fetching pull request...")
pr, _, prErr := client.GetPullRequest(owner, name, prNumber)
ios.StopSpinner()
if prErr != nil {
return fmt.Errorf("failed to get pull request: %w", prErr)
}
// Build current assignee set
assigneeSet := make(map[string]bool)
for _, a := range pr.Assignees {
assigneeSet[a.UserName] = true
}
// Add new assignees
for _, a := range addAssignees {
assigneeSet[a] = true
}
// Remove assignees
for _, a := range removeAssignees {
delete(assigneeSet, a)
}
// Convert back to slice
newAssignees := make([]string, 0, len(assigneeSet))
for a := range assigneeSet {
newAssignees = append(newAssignees, a)
}
editOpt.Assignees = newAssignees
needsEditCall = true
}
ios.StartSpinner("Updating pull request...")
// Perform the edit API call if needed
if needsEditCall {
_, _, err = client.EditPullRequest(owner, name, prNumber, editOpt)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to edit pull request: %w", err)
}
}
// Handle labels
addLabelNames, _ := cmd.Flags().GetStringSlice("add-label")
removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-label")
if len(addLabelNames) > 0 {
labelIDs, labelErr := resolveLabelIDs(client, owner, name, addLabelNames)
if labelErr != nil {
ios.StopSpinner()
return labelErr
}
_, _, err = client.AddIssueLabels(owner, name, prNumber, gitea.IssueLabelsOption{
Labels: labelIDs,
})
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to add labels: %w", err)
}
}
if len(removeLabelNames) > 0 {
labelIDs, labelErr := resolveLabelIDs(client, owner, name, removeLabelNames)
if labelErr != nil {
ios.StopSpinner()
return labelErr
}
for _, labelID := range labelIDs {
_, err = client.DeleteIssueLabel(owner, name, prNumber, labelID)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to remove label %d: %w", labelID, err)
}
}
}
// Handle reviewers
addReviewers, _ := cmd.Flags().GetStringSlice("add-reviewer")
removeReviewers, _ := cmd.Flags().GetStringSlice("remove-reviewer")
if len(addReviewers) > 0 {
reviewerReq := map[string][]string{
"reviewers": addReviewers,
}
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", owner, name, prNumber)
_, err := client.DoJSON(http.MethodPost, endpoint, reviewerReq, nil)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to add reviewers: %w", err)
}
}
if len(removeReviewers) > 0 {
reviewerReq := map[string][]string{
"reviewers": removeReviewers,
}
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", owner, name, prNumber)
_, err := client.DoJSON(http.MethodDelete, endpoint, reviewerReq, nil)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to remove reviewers: %w", err)
}
}
ios.StopSpinner()
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Pull request #%d updated\n", cs.SuccessIcon(), prNumber)
2025-12-08 09:49:07 +01:00
return nil
}