fj/cmd/pr.go

1053 lines
29 KiB
Go
Raw Permalink Normal View History

2025-12-08 09:49:07 +01:00
package cmd
import (
"fmt"
"net/http"
"os/exec"
2025-12-08 09:49:07 +01:00
"strings"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
gitpkg "forgejo.zerova.net/public/fj/internal/git"
"forgejo.zerova.net/public/fj/internal/text"
"github.com/spf13/cobra"
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
fj pr list
# List all pull requests for a specific repo
fj pr list -s all -R owner/repo
# Output as JSON
fj pr list --json`,
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
fj pr view 5
# View using URL
fj pr view https://codeberg.org/owner/repo/pulls/5
# View PR for current branch
fj pr view
# Open in browser
fj pr view 5 --web
# View as JSON
fj 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
fj pr create -t "Add login page" -H feature/login
# Create with body and custom base branch
fj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop
# Create and self-assign
fj 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
fj pr merge 5
# Squash merge
fj pr merge 5 --merge-method squash
# Rebase merge
fj pr merge 5 --merge-method rebase
# Merge without confirmation
fj 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
fj pr close 5
# Close with a comment
fj 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
fj 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
fj pr edit 5 -t "Updated title"
# Add assignees and labels
fj pr edit 5 --add-assignee user1 --add-label bug
# Remove a reviewer and set milestone
fj 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
fj 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")
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")
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)
}
needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != ""
ios.StartSpinner("Fetching pull requests...")
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
// When client-side filtering is needed, pull pages until exhausted (no
// limit) so we can apply filters; otherwise paginate up to the user's
// limit. Either way, paginate — `PageSize: limit` capped at 50 silently.
fetchPage := func(page, pageSize int) ([]*gitea.PullRequest, error) {
batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
State: stateType,
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
})
return batch, err
}
var prs []*gitea.PullRequest
if needsClientFilter {
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
prs, err = paginateGitea(0, fetchPage) // pull all, then filter + limit
if err == nil {
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
if limit > 0 && len(prs) > limit {
prs = prs[:limit]
}
}
} else {
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
prs, err = paginateGitea(limit, fetchPage)
2025-12-08 09:49:07 +01:00
}
ios.StopSpinner()
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
if err != nil {
return fmt.Errorf("failed to list pull requests: %w", err)
}
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
}
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
}