package cmd import ( "fmt" "net/http" "os/exec" "strings" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" gitpkg "forgejo.zerova.net/sid/fgj-sid/internal/git" "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) 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 fgj pr list --json`, RunE: runPRList, } var prViewCmd = &cobra.Command{ Use: "view []", 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, } 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, } var prMergeCmd = &cobra.Command{ Use: "merge ", 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 ", 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 ", 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 ", 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 ", 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, } 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") 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") prListCmd.Flags().BoolP("web", "w", false, "Open list in web browser") addJSONFlags(prListCmd, "Output pull requests as JSON") 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") 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") 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)") } 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") 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()) 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)) } 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) } needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != "" 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) 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) } } ios.StopSpinner() if wantJSON(cmd) { return outputJSON(cmd, prs) } if len(prs) == 0 { fmt.Fprintf(ios.Out, "No %s pull requests in %s/%s\n", state, owner, name) return nil } tp := ios.NewTablePrinter() tp.AddHeader("NUMBER", "TITLE", "BRANCH", "STATE") for _, pr := range prs { tp.AddRow(fmt.Sprintf("#%d", pr.Index), pr.Title, pr.Head.Ref, string(pr.State)) } return tp.Render() } 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 } 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()) 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...") pr, _, err := client.GetPullRequest(owner, name, prNumber) ios.StopSpinner() 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)) } if pr.Body != "" { fmt.Fprintf(ios.Out, "\n%s\n", pr.Body) } 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") 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)") } 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 } } } if head == "" { return fmt.Errorf("head branch is required (use -H flag)") } cfg, err := config.Load() if err != nil { return err } client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) 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...") pr, _, err := client.CreatePullRequest(owner, name, gitea.CreatePullRequestOption{ Title: title, Body: body, Head: head, Base: base, Assignees: resolvedAssignees, Reviewers: reviewers, Labels: labelIDs, Milestone: milestoneID, }) ios.StopSpinner() 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) 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]) 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()) 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...") _, _, err = client.MergePullRequest(owner, name, prNumber, gitea.MergePullRequestOption{ Style: method, DeleteBranchAfterMerge: deleteBranch, MergeWhenChecksSucceed: autoMerge, }) ios.StopSpinner() 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()) 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()) 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()) 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()) 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) return nil }