Add PR checks command, iostreams/text packages for colored table output, top-level run/workflow aliases matching gh CLI structure. Enhance actions, issues, PRs, releases, repos, labels, milestones, and wiki commands with improved flags, JSON output, and error handling.
1059 lines
29 KiB
Go
1059 lines
29 KiB
Go
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 [<number>]",
|
|
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 <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,
|
|
}
|
|
|
|
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
|
|
}
|