feat(branch): branch from tags and commits, protect with explicit rules

branch create --source v1.0.0 (or a commit) failed with "The old branch
does not exist" because only old_branch_name was sent; the request now
sends old_ref_name, through a raw call until the SDK has the field.

branch protect nope exited 0 and created a rule for a missing branch,
and protecting twice failed with "permission denied". Branches must now
exist (--pattern protects a glob instead), an existing rule warns
"! main is already protected", and unprotecting an unprotected branch
warns instead of failing. The rule is explained in help and set with
--required-approvals, --allow-push, --status-check,
--block-on-rejected-reviews and --dismiss-stale-approvals.

branch list shows NAME (marking the default branch), COMMIT, PROTECTED,
UPDATED and MESSAGE, branch view is a full view with --web, branch
delete takes several branches, and every change reports a ✓ line.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:52:02 +02:00
1 parent 682c9f66a2
commit 02cf01093f
8 files changed
+550 -126

No files matched your search

-8
View File
@@ -419,13 +419,6 @@ const (
pr review cancel-request pr review cancel-request
` `
examplesToWrite = ` examplesToWrite = `
branch create
branch delete
branch list
branch protect
branch rename
branch unprotect
branch view
issue attachment delete issue attachment delete
issue attachment download issue attachment download
issue attachment edit issue attachment edit
@@ -520,7 +513,6 @@ const (
wiki view wiki view
` `
longsToWrite = ` longsToWrite = `
branch
issue issue
issue attachment issue attachment
issue comment issue comment
+353 -87
View File
@@ -6,6 +6,7 @@ package app
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/url"
"strconv" "strconv"
"strings" "strings"
@@ -33,110 +34,375 @@ func validateBranch(name string) error {
return nil return nil
} }
func (a *application) branchCommand() *cobra.Command { // validateBranches validates every branch name.
group := &cobra.Command{Use: "branch", Short: "Manage remote branches and protection rules"} func validateBranches(names []string) error {
var limit, page int for _, name := range names {
list := &cobra.Command{Use: "list", Short: "List remote branches", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Branches{Client: target.client}).List(cmd.Context(), target.Repo, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, branchRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"view", "create", "rename", "delete", "protect", "unprotect"} {
var source string
count := 1
use := verb + " <name>"
if verb == "rename" {
count = 2
use = "rename <old-name> <new-name>"
}
arguments := argsExact(count)
if verb == "protect" || verb == "unprotect" {
use = verb + " <name>..."
arguments = usageArgs(cobra.MinimumNArgs(1))
}
command := &cobra.Command{Use: use, Short: titleCase(verb) + " a remote branch", Args: arguments, RunE: func(cmd *cobra.Command, args []string) error {
for _, name := range args {
if err := validateBranch(name); err != nil { if err := validateBranch(name); err != nil {
return err return err
} }
} }
if source != "" { return nil
if err := validateBranch(source); err != nil { }
func (a *application) branchCommand() *cobra.Command {
group := &cobra.Command{
Use: "branch",
Short: "Manage remote branches and protection rules",
Long: `Work with the branches of a repository on the server: list and view them, create, rename and
delete them, and protect them from direct pushes. To work with local branches, use git.`,
}
group.AddCommand(a.branchListCommand(), a.branchViewCommand(), a.branchCreateCommand(), a.branchRenameCommand(), a.branchDeleteCommand())
group.AddCommand(a.branchProtectCommand(), a.branchUnprotectCommand())
return group
}
// branchURL links a branch's files on the web.
func branchURL(repository services.Repository, branch string) string {
if repository.URL == "" {
return ""
}
return repository.URL + "/src/branch/" + strings.ReplaceAll(url.PathEscape(branch), "%2F", "/")
}
func (a *application) branchListCommand() *cobra.Command {
var limit, page int
command := &cobra.Command{
Use: "list",
Short: "List branches",
Example: `$ tea branch list
$ tea branch list -R alice/project --limit 100 --json name,sha`,
Args: argsExact(0),
RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
repository := services.Repository{}
if a.options.json == "" {
if repository, err = (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo); err != nil {
return err return err
} }
} }
if verb == "rename" && args[0] == args[1] { result, err := (services.Branches{Client: target.client}).ListPage(cmd.Context(), target.Repo, limit, page)
if err != nil {
return err
}
spec := listSpec[services.Branch]{
Columns: []terminal.Column{{Header: "Name"}, {Header: "Commit"}, {Header: "Protected"}, {Header: "Updated"}, {Header: "Message", Flex: true}},
Row: func(item services.Branch) []terminal.Cell {
name := terminal.Cell{Text: item.Name, Link: branchURL(repository, item.Name)}
if item.Name == repository.DefaultBranch {
name.Text += " (default)"
name.Bold = true
}
protected := ""
if item.Protected {
protected = "protected"
}
message, _, _ := strings.Cut(item.Message, "\n")
return []terminal.Cell{name, {Text: shortSHA(item.SHA), Muted: true}, {Text: protected, Color: "yellow"}, {Time: terminal.ParseTime(item.UpdatedAt)}, {Text: message}}
},
Context: "branches in " + target.Repo, Empty: "no branches in " + target.Repo,
}
return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted})
},
}
pageFlags(command, &limit, &page)
return command
}
func (a *application) branchViewCommand() *cobra.Command {
var web bool
command := &cobra.Command{
Use: "view <branch>",
Short: "View a branch",
Example: "$ tea branch view main\n$ tea branch view feature/login --web",
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateBranch(args[0]); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
repository, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, branchURL(repository, args[0]))
}
item, err := (services.Branches{Client: target.client}).View(cmd.Context(), target.Repo, args[0])
if err != nil {
return err
}
return writeView(a, cmd, item, func(item services.Branch) terminal.View {
var badges []terminal.Cell
if item.Name == repository.DefaultBranch {
badges = append(badges, terminal.Cell{Text: "default", Color: "green"})
}
protection := ""
if item.Protected {
badges = append(badges, terminal.Cell{Text: "protected", Color: "yellow"})
protection = countNoun(int(item.RequiredApprovals), "required approval")
}
meta := ""
if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() {
meta = "updated " + terminal.RelativeTime(a.deps.Now(), updated)
}
return terminal.View{
Title: item.Name, Badges: badges, Meta: meta, Body: item.Message, Kind: "branch", URL: branchURL(repository, item.Name),
Fields: []terminal.Field{{Label: "Commit", Value: item.SHA}, {Label: "Protection", Value: protection}, {Label: "You can push", Value: yesNo(item.CanPush)}},
}
})
},
}
command.Flags().BoolVarP(&web, "web", "w", false, "Open the branch in the browser")
command.ValidArgsFunction = a.completeBranches()
return command
}
func (a *application) branchCreateCommand() *cobra.Command {
var source string
command := &cobra.Command{
Use: "create <branch>",
Short: "Create a branch",
Long: "Create a branch on the server from another branch, a tag or a commit; the default branch unless --source names one.",
Example: `# Branch off the default branch
$ tea branch create feature/login
# Branch off a tag or a commit
$ tea branch create hotfix/1.0.1 --source v1.0.0
$ tea branch create bisect --source 3f2a9c1`,
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateBranch(args[0]); err != nil {
return err
}
if cmd.Flags().Changed("source") && strings.TrimSpace(source) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--source cannot be empty"))
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Branches{Client: target.client}).Create(cmd.Context(), target.Repo, args[0], source)
if err != nil {
return err
}
if source == "" {
a.done(cmd, "Created branch %s in %s at %s", item.Name, target.Repo, shortSHA(item.SHA))
} else {
a.done(cmd, "Created branch %s from %s in %s at %s", item.Name, source, target.Repo, shortSHA(item.SHA))
}
return a.writeMutationJSON(cmd, item)
},
}
command.Flags().StringVarP(&source, "source", "s", "", "Branch, tag or commit to start from (default: the default branch)")
_ = command.RegisterFlagCompletionFunc("source", mergeCompletions(a.completeBranches(), a.completeTags()))
return command
}
func (a *application) branchRenameCommand() *cobra.Command {
command := &cobra.Command{
Use: "rename <branch> <new-name>",
Short: "Rename a branch",
Example: "$ tea branch rename feature/logn feature/login",
Annotations: textOutput("branch rename does not return a resource"),
Args: argsExact(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateBranches(args); err != nil {
return err
}
if args[0] == args[1] {
return clierr.Wrap(clierr.Usage, errors.New("new branch name must differ from the current name")) return clierr.Wrap(clierr.Usage, errors.New("new branch name must differ from the current name"))
} }
target, err := a.resolve(cmd.Context(), true) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
} }
service := services.Branches{Client: target.client} if err := (services.Branches{Client: target.client}).Rename(cmd.Context(), target.Repo, args[0], args[1]); err != nil {
if verb == "delete" || verb == "unprotect" {
if err := a.confirm(cmd, verb+" branches "+strings.Join(args, ", ")+" in "+target.Repo); err != nil {
return err return err
} }
} a.done(cmd, "Renamed branch %s to %s in %s", args[0], args[1], target.Repo)
switch verb {
case "view":
item, err := service.View(cmd.Context(), target.Repo, args[0])
if err != nil {
return err
}
return writeItem(a, cmd, item, branchRow)
case "create":
item, err := service.Create(cmd.Context(), target.Repo, args[0], source)
if err != nil {
return err
}
return writeItem(a, cmd, item, branchRow)
case "rename":
return service.Rename(cmd.Context(), target.Repo, args[0], args[1])
case "delete":
return service.Delete(cmd.Context(), target.Repo, args[0])
case "protect", "unprotect":
var failures []error
for _, name := range args {
var err error
if verb == "protect" {
err = service.Protect(cmd.Context(), target.Repo, name)
} else {
err = service.Unprotect(cmd.Context(), target.Repo, name)
}
if err != nil {
failures = append(failures, fmt.Errorf("%s branch %s: %w", verb, name, err))
continue
}
if len(args) > 1 {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s branch %s\n", verb, name); err != nil {
return err
}
}
}
return errors.Join(failures...)
}
return nil return nil
}} },
if verb == "create" {
command.Flags().StringVar(&source, "source", "", "Source branch (default: repository default branch)")
} }
if verb != "view" && verb != "create" { command.ValidArgsFunction = a.completeBranches()
command.Annotations = textOutput("branch " + verb + " does not return a resource") return command
}
group.AddCommand(command)
}
return group
} }
func branchRow(item services.Branch) []terminal.Cell { func (a *application) branchDeleteCommand() *cobra.Command {
return terminal.Row(item.Name, item.SHA, strconv.FormatBool(item.Protected)) command := &cobra.Command{
Use: "delete <branch>...",
Short: "Delete branches",
Example: `$ tea branch delete feature/login
$ tea branch delete old-a old-b --yes`,
Annotations: map[string]string{"tea.confirm": "true", "tea.text": "branch deletion does not return a resource"},
Args: usageArgs(cobra.MinimumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateBranches(args); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
subject := branchSubject(args)
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + " from " + target.Repo + "?", NonTTY: "delete " + subject + " from " + target.Repo}); err != nil {
return err
}
service := services.Branches{Client: target.client}
return batchDone(a, cmd, args, func(name string) string { return "delete branch " + name }, func(name string) (string, error) {
return name, service.Delete(cmd.Context(), target.Repo, name)
}, func(name string) string { return "Deleted branch " + name + " from " + target.Repo })
},
}
command.ValidArgsFunction = a.completeBranches()
return command
}
// branchSubject names one or more branches in a prompt: "branch main" or "2 branches (a, b)".
func branchSubject(names []string) string {
if len(names) == 1 {
return "branch " + names[0]
}
return fmt.Sprintf("%d branches (%s)", len(names), strings.Join(names, ", "))
}
func (a *application) branchProtectCommand() *cobra.Command {
var rule services.Protection
var pattern string
var pushers []string
command := &cobra.Command{
Use: "protect <branch>...",
Short: "Protect branches",
Long: `Add a protection rule for each branch. By default nobody can push to a protected branch directly
and pull requests need one approving review to merge; --allow-push lets users or teams push, and
--required-approvals changes the review count.
The branches must exist. --pattern protects every branch that matches a glob instead, such as
release/*, including branches created later.`,
Example: `# Require two approvals and passing CI on main
$ tea branch protect main --required-approvals 2 --status-check ci/build
# Let the release team push to every release branch
$ tea branch protect --pattern 'release/*' --allow-push acme/release`,
Annotations: textOutput("branch protect does not return a resource"),
Args: func(cmd *cobra.Command, args []string) error {
if pattern != "" {
return usageArgs(cobra.NoArgs)(cmd, args)
}
return usageArgs(cobra.MinimumNArgs(1))(cmd, args)
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateBranches(args); err != nil {
return err
}
if rule.RequiredApprovals < 0 {
return clierr.Wrap(clierr.Usage, errors.New("--required-approvals cannot be negative"))
}
if err := requireNames("push", "", pushers, rule.StatusChecks); err != nil {
return err
}
for _, pusher := range pushers {
if _, team, ok := strings.Cut(pusher, "/"); ok {
rule.PushTeams = append(rule.PushTeams, team)
} else {
rule.PushUsers = append(rule.PushUsers, pusher)
}
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Branches{Client: target.client}
rules := args
if pattern != "" {
rules = []string{pattern}
}
_, failures := applyBatch(cmd, rules, func(name string) string { return "protect " + name }, func(name string) (string, error) {
if pattern == "" {
if _, err := service.View(cmd.Context(), target.Repo, name); err != nil {
return "", services.Classify(err, "branch", strconv.Quote(name), target.Repo)
}
}
protection := rule
protection.Rule = name
created, err := service.Protect(cmd.Context(), target.Repo, protection)
if err == nil && !created {
a.warn(cmd, "%s is already protected in %s; change its rule on the web", name, target.Repo)
return "", nil
}
return name, err
}, func(name string) {
if name != "" {
a.done(cmd, "Protected %s in %s (%s)", name, target.Repo, protectionSummary(rule))
}
})
return errors.Join(failures...)
},
}
flags := command.Flags()
flags.StringVar(&pattern, "pattern", "", "Protect the branches matching this glob, such as release/*")
flags.Int64Var(&rule.RequiredApprovals, "required-approvals", 1, "Approving reviews that pull requests need")
flags.StringSliceVar(&pushers, "allow-push", nil, "Users, or teams as ORG/TEAM, that may push directly")
flags.StringSliceVar(&rule.StatusChecks, "status-check", nil, "Status checks that must pass (repeatable)")
flags.BoolVar(&rule.BlockOnRejected, "block-on-rejected-reviews", false, "Block merging while changes are requested")
flags.BoolVar(&rule.DismissStaleApprovals, "dismiss-stale-approvals", false, "Dismiss approvals when new commits are pushed")
_ = command.RegisterFlagCompletionFunc("allow-push", a.completeUsers())
command.ValidArgsFunction = a.completeBranches()
return command
}
// protectionSummary describes a protection rule, as in "1 approval required, no direct pushes".
func protectionSummary(rule services.Protection) string {
parts := []string{countNoun(int(rule.RequiredApprovals), "approval") + " required"}
if pushers := len(rule.PushUsers) + len(rule.PushTeams); pushers > 0 {
parts = append(parts, "pushes by "+strings.Join(append(append([]string{}, rule.PushUsers...), rule.PushTeams...), ", "))
} else {
parts = append(parts, "no direct pushes")
}
if len(rule.StatusChecks) > 0 {
parts = append(parts, "checks "+strings.Join(rule.StatusChecks, ", "))
}
return strings.Join(parts, ", ")
}
func (a *application) branchUnprotectCommand() *cobra.Command {
command := &cobra.Command{
Use: "unprotect <branch>...",
Short: "Remove branch protection",
Long: "Remove the protection rules of branches, or of glob patterns such as release/*.",
Example: "$ tea branch unprotect main\n$ tea branch unprotect 'release/*' --yes",
Annotations: map[string]string{"tea.confirm": "true", "tea.text": "branch unprotect does not return a resource"},
Args: usageArgs(cobra.MinimumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
subject := "the protection of " + strings.Join(args, ", ")
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Remove " + subject + " in " + target.Repo + "?", NonTTY: "remove " + subject + " in " + target.Repo}); err != nil {
return err
}
service := services.Branches{Client: target.client}
_, failures := applyBatch(cmd, args, func(name string) string { return "unprotect " + name }, func(name string) (string, error) {
removed, err := service.Unprotect(cmd.Context(), target.Repo, name)
if err == nil && !removed {
a.warn(cmd, "%s is not protected in %s", name, target.Repo)
return "", nil
}
return name, err
}, func(name string) {
if name != "" {
a.done(cmd, "Unprotected %s in %s", name, target.Repo)
}
})
return errors.Join(failures...)
},
}
command.ValidArgsFunction = a.completeBranches()
return command
} }
+72 -3
View File
@@ -5,6 +5,7 @@ package app
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -33,13 +34,81 @@ func TestBulkBranchProtectionContinuesAfterFailure(t *testing.T) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
})) }))
defer server.Close() defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL) deps, out, diagnostics := repoTTYDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"branch", "unprotect", "failed", "succeeded", "-R", "owner/repo", "--no-input"}) err := Execute(context.Background(), deps, []string{"branch", "unprotect", "failed", "succeeded", "-R", "owner/repo", "--no-input"})
require.Error(t, err) require.Error(t, err)
require.Empty(t, names) require.Empty(t, names)
err = Execute(context.Background(), deps, []string{"branch", "unprotect", "failed", "succeeded", "-R", "owner/repo", "--yes"}) err = Execute(context.Background(), deps, []string{"branch", "unprotect", "failed", "succeeded", "-R", "owner/repo", "--yes"})
require.ErrorContains(t, err, "failed") require.ErrorContains(t, err, "unprotect failed")
require.Equal(t, []string{"failed", "succeeded"}, names) require.Equal(t, []string{"failed", "succeeded"}, names)
require.Empty(t, out.String()) require.Empty(t, out.String())
require.Contains(t, diagnostics.String(), "succeeded") require.Equal(t, "✓ Unprotected succeeded in owner/repo\n", diagnostics.String())
}
func TestBranchProtectChecksTheBranchAndIsIdempotent(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "GET /api/v1/repos/alice/x/branches/main", "GET /api/v1/repos/alice/x/branches/dev":
_, _ = w.Write([]byte(`{"name":"main"}`))
case "POST /api/v1/repos/alice/x/branch_protections":
var rule struct {
Name string `json:"rule_name"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&rule))
if rule.Name == "main" {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"Branch protection already exist"}`))
return
}
_, _ = w.Write([]byte(`{"rule_name":"dev"}`))
case "DELETE /api/v1/repos/alice/x/branch_protections/dev":
w.WriteHeader(http.StatusNotFound)
case "GET /api/v1/repos/alice/x":
_, _ = w.Write([]byte(`{"full_name":"alice/x"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, _, diagnostics := repoTTYDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"branch", "protect", "nope", "-R", "alice/x"})
require.EqualError(t, err, `protect nope: branch "nope" not found in alice/x`)
require.Equal(t, []string{"GET /api/v1/repos/alice/x/branches/nope"}, requests)
require.NoError(t, Execute(context.Background(), deps, []string{"branch", "protect", "main", "dev", "-R", "alice/x"}))
require.Equal(t, "! main is already protected in alice/x; change its rule on the web\n"+
"✓ Protected dev in alice/x (1 approval required, no direct pushes)\n", diagnostics.String())
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"branch", "unprotect", "dev", "-R", "alice/x", "--yes"}))
require.Equal(t, "! dev is not protected in alice/x\n", diagnostics.String())
}
func TestBranchCreateFromATag(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requirePayload(t, `{"new_branch_name":"hotfix","old_ref_name":"v1.0.0"}`, r.Body)
_, _ = w.Write([]byte(`{"name":"hotfix","commit":{"id":"abcdef123"}}`))
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"branch", "create", "hotfix", "--source", "v1.0.0", "-R", "alice/x"}))
require.Empty(t, out.String())
require.Equal(t, "✓ Created branch hotfix from v1.0.0 in alice/x at abcdef1\n", diagnostics.String())
}
func TestBranchDeleteTakesSeveralBranches(t *testing.T) {
var deleted []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodDelete, r.Method)
deleted = append(deleted, strings.TrimPrefix(r.URL.Path, "/api/v1/repos/alice/x/branches/"))
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
deps, _, diagnostics := repoTTYDeps(t, server.URL)
prompt := &scriptedPrompter{accept: true}
deps.Prompter, deps.Streams.InTTY, deps.Streams.ErrTTY = prompt, true, true
require.NoError(t, Execute(context.Background(), deps, []string{"branch", "rm", "old-a", "old-b", "-R", "alice/x"}))
require.Equal(t, []string{"Delete 2 branches (old-a, old-b) from alice/x?"}, prompt.confirmations)
require.Equal(t, []string{"old-a", "old-b"}, deleted)
require.Equal(t, "✓ Deleted branch old-a from alice/x\n✓ Deleted branch old-b from alice/x\n", diagnostics.String())
} }
+8
View File
@@ -340,3 +340,11 @@ func (a *application) morePagesHint(cmd *cobra.Command, more bool, page int) err
_, err := fmt.Fprintf(cmd.ErrOrStderr(), "more results available; use --page %d\n", page+1) _, err := fmt.Fprintf(cmd.ErrOrStderr(), "more results available; use --page %d\n", page+1)
return err return err
} }
// yesNo renders a boolean cell.
func yesNo(value bool) string {
if value {
return "yes"
}
return "no"
}
+3 -1
View File
@@ -157,6 +157,8 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
_, _ = w.Write([]byte("[" + issue(1) + "," + issue(2) + "]")) _, _ = w.Write([]byte("[" + issue(1) + "," + issue(2) + "]"))
case "/api/v1/repos/alice/project/issues/1", "/api/v1/repos/alice/project/issues/2": case "/api/v1/repos/alice/project/issues/1", "/api/v1/repos/alice/project/issues/2":
_, _ = w.Write([]byte(issue(int(r.URL.Path[len(r.URL.Path)-1] - '0')))) _, _ = w.Write([]byte(issue(int(r.URL.Path[len(r.URL.Path)-1] - '0'))))
case "/api/v1/repos/alice/project":
_, _ = w.Write([]byte(`{"full_name":"alice/project","default_branch":"main"}`))
case "/api/v1/repos/alice/project/branches": case "/api/v1/repos/alice/project/branches":
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc"}},{"name":"dev","commit":{"id":"def"}}]`)) _, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc"}},{"name":"dev","commit":{"id":"def"}}]`))
case "/api/v1/repos/alice/project/issues/5/comments": case "/api/v1/repos/alice/project/issues/5/comments":
@@ -172,7 +174,7 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
}{ }{
{[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed\n2 Issue 2 closed\n"}, {[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"issue", "close", "1", "2"}, "1 Issue 1 closed\n2 Issue 2 closed\n"}, {[]string{"issue", "close", "1", "2"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"branch", "list"}, "main abc false\ndev def false\n"}, {[]string{"branch", "list"}, "Showing 2 of 2 branches in alice/project\n\nNAME COMMIT PROTECTED UPDATED MESSAGE\nmain (default) abc\ndev def\n"},
{[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"}, {[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"},
} { } {
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) { t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
-7
View File
@@ -52,13 +52,6 @@ func timeField(label, raw string, d terminal.Display) terminal.Field {
return terminal.Field{Label: label, Value: terminal.RelativeTime(now, t)} return terminal.Field{Label: label, Value: terminal.RelativeTime(now, t)}
} }
func yesNo(value bool) string {
if value {
return "yes"
}
return "no"
}
// countOf words a count, such as "1 member" or "2 members". // countOf words a count, such as "1 member" or "2 members".
func countOf(n int, singular, plural string) string { func countOf(n int, singular, plural string) string {
if n == 1 { if n == 1 {
+73 -15
View File
@@ -5,15 +5,20 @@ package services
import ( import (
"context" "context"
"errors"
"net/http"
"strings"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport" "gitea.dev/tea/internal/transport"
) )
// Branch describes a remote branch and its protection. // Branch describes a remote branch, its last commit and its protection.
type Branch struct { type Branch struct {
Name string `json:"name"` Name string `json:"name"`
SHA string `json:"sha"` SHA string `json:"sha"`
Message string `json:"message"`
UpdatedAt string `json:"updated_at"`
Protected bool `json:"protected"` Protected bool `json:"protected"`
RequiredApprovals int64 `json:"required_approvals"` RequiredApprovals int64 `json:"required_approvals"`
CanPush bool `json:"can_push"` CanPush bool `json:"can_push"`
@@ -26,7 +31,7 @@ func branchFrom(branch *gitea.Branch) Branch {
CanPush: branch.UserCanPush, CanMerge: branch.UserCanMerge, CanPush: branch.UserCanPush, CanMerge: branch.UserCanMerge,
} }
if branch.Commit != nil { if branch.Commit != nil {
result.SHA = branch.Commit.ID result.SHA, result.Message, result.UpdatedAt = branch.Commit.ID, branch.Commit.Message, timestamp(branch.Commit.Timestamp)
} }
return result return result
} }
@@ -36,13 +41,25 @@ type Branches struct{ Client *transport.Client }
// List returns a page of remote branches. // List returns a page of remote branches.
func (s Branches) List(ctx context.Context, repo string, limit, page int) ([]Branch, error) { func (s Branches) List(ctx context.Context, repo string, limit, page int) ([]Branch, error) {
window, err := s.ListPage(ctx, repo, limit, page)
return window.Items, err
}
// ListPage is List that also reports whether more branches follow and how many there are.
func (s Branches) ListPage(ctx context.Context, repo string, limit, page int) (Page[Branch], error) {
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.Branch, http.Header, error) {
var headers http.Header
branches, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.Branch, *gitea.Response, error) { branches, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.Branch, *gitea.Response, error) {
return c.Repositories.ListRepoBranches(ctx, owner, name, gitea.ListRepoBranchesOptions{ListOptions: listOptions(limit, page)}) branches, resp, err := c.Repositories.ListRepoBranches(ctx, owner, name, gitea.ListRepoBranchesOptions{ListOptions: listOptions(limit, page)})
headers = headerOf(resp)
return branches, resp, err
})
return branches, headers, err
}) })
if err != nil { if err != nil {
return nil, err return Page[Branch]{}, err
} }
return convertAll(branches, branchFrom), nil return Page[Branch]{Items: convertAll(window.Items, branchFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil
} }
// View retrieves a remote branch. // View retrieves a remote branch.
@@ -52,12 +69,24 @@ func (s Branches) View(ctx context.Context, repo, branch string) (Branch, error)
}) })
} }
// Create creates a branch from the supplied source or repository default. // Create creates a branch from source, a branch, tag or commit, or from the default branch.
func (s Branches) Create(ctx context.Context, repo, branch, source string) (Branch, error) { func (s Branches) Create(ctx context.Context, repo, branch, source string) (Branch, error) {
if source == "" {
return repoConvert(ctx, s.Client, repo, branchFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Branch, *gitea.Response, error) { return repoConvert(ctx, s.Client, repo, branchFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Branch, *gitea.Response, error) {
return c.Repositories.CreateBranch(ctx, owner, name, gitea.CreateBranchOption{BranchName: branch, OldBranchName: source}) return c.Repositories.CreateBranch(ctx, owner, name, gitea.CreateBranchOption{BranchName: branch})
}) })
} }
if _, _, err := splitRepository(repo); err != nil {
return Branch{}, err
}
// TODO(sdk): CreateBranchOption lacks old_ref_name, which also accepts tags and commits.
var created gitea.Branch
payload := map[string]string{"new_branch_name": branch, "old_ref_name": source}
if err := sendJSON(ctx, s.Client, http.MethodPost, "repos/"+repo+"/branches", payload, &created); err != nil {
return Branch{}, err
}
return branchFrom(&created), nil
}
// Rename renames a remote branch. // Rename renames a remote branch.
func (s Branches) Rename(ctx context.Context, repo, oldName, newName string) error { func (s Branches) Rename(ctx context.Context, repo, oldName, newName string) error {
@@ -75,19 +104,48 @@ func (s Branches) Delete(ctx context.Context, repo, branch string) error {
}) })
} }
// Protect blocks direct pushes to the branch and requires one approving review. // Protection is a branch protection rule.
func (s Branches) Protect(ctx context.Context, repo, branch string) error { type Protection struct {
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { // Rule is the branch name, or a glob pattern such as release/*, that the rule protects.
Rule string `json:"rule"`
RequiredApprovals int64 `json:"required_approvals"`
// PushUsers and PushTeams may push directly; without them nobody can.
PushUsers []string `json:"push_users"`
PushTeams []string `json:"push_teams"`
// StatusChecks must pass before pull requests merge.
StatusChecks []string `json:"status_checks"`
BlockOnRejected bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
}
// Protect adds a protection rule. It reports false without a change when the rule exists already.
func (s Branches) Protect(ctx context.Context, repo string, rule Protection) (bool, error) {
push := len(rule.PushUsers)+len(rule.PushTeams) > 0
err := repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
_, resp, err := c.Repositories.CreateBranchProtection(ctx, owner, name, gitea.CreateBranchProtectionOption{ _, resp, err := c.Repositories.CreateBranchProtection(ctx, owner, name, gitea.CreateBranchProtectionOption{
BranchName: branch, RuleName: branch, RequiredApprovals: 1, RuleName: rule.Rule, RequiredApprovals: rule.RequiredApprovals,
EnablePush: push, EnablePushWhitelist: push, PushWhitelistUsernames: rule.PushUsers, PushWhitelistTeams: rule.PushTeams,
EnableStatusCheck: len(rule.StatusChecks) > 0, StatusCheckContexts: rule.StatusChecks,
BlockOnRejectedReviews: rule.BlockOnRejected, DismissStaleApprovals: rule.DismissStaleApprovals,
}) })
return resp, err return resp, err
}) })
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && strings.Contains(strings.ToLower(apiErr.Message), "already exist") {
return false, nil
}
return err == nil, err
} }
// Unprotect removes the named branch protection rule. // Unprotect removes the protection rule named rule. It reports false when there is none.
func (s Branches) Unprotect(ctx context.Context, repo, branch string) error { func (s Branches) Unprotect(ctx context.Context, repo, rule string) (bool, error) {
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { err := repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
return c.Repositories.DeleteBranchProtection(ctx, owner, name, branch) return c.Repositories.DeleteBranchProtection(ctx, owner, name, rule)
}) })
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == http.StatusNotFound {
if _, err := (Repositories{Client: s.Client}).get(ctx, repo); err != nil {
return false, err
}
return false, nil
}
return err == nil, err
} }
+41 -5
View File
@@ -34,12 +34,12 @@ func TestBranchListMapsServerFields(t *testing.T) {
assert.Equal(t, "/api/v1/repos/owner/repo/branches", r.URL.Path) assert.Equal(t, "/api/v1/repos/owner/repo/branches", r.URL.Path)
assert.Equal(t, "20", r.URL.Query().Get("limit")) assert.Equal(t, "20", r.URL.Query().Get("limit"))
assert.Equal(t, "3", r.URL.Query().Get("page")) assert.Equal(t, "3", r.URL.Query().Get("page"))
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc"},"protected":true,"required_approvals":2,"user_can_push":true,"user_can_merge":true},{"name":"orphan","commit":null}]`)) _, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc","message":"Fix\n\nbody","timestamp":"2026-10-01T12:00:00Z"},"protected":true,"required_approvals":2,"user_can_push":true,"user_can_merge":true},{"name":"orphan","commit":null}]`))
})) }))
items, err := (Branches{Client: client}).List(context.Background(), "owner/repo", 20, 3) items, err := (Branches{Client: client}).List(context.Background(), "owner/repo", 20, 3)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []Branch{ require.Equal(t, []Branch{
{Name: "main", SHA: "abc", Protected: true, RequiredApprovals: 2, CanPush: true, CanMerge: true}, {Name: "main", SHA: "abc", Message: "Fix\n\nbody", UpdatedAt: "2026-10-01T12:00:00Z", Protected: true, RequiredApprovals: 2, CanPush: true, CanMerge: true},
{Name: "orphan"}, {Name: "orphan"},
}, items) }, items)
} }
@@ -50,11 +50,10 @@ func TestBranchCreateAndProtectPayloads(t *testing.T) {
switch r.URL.Path { switch r.URL.Path {
case "/api/v1/repos/owner/repo/branches": case "/api/v1/repos/owner/repo/branches":
assert.Equal(t, "feature", payload["new_branch_name"]) assert.Equal(t, "feature", payload["new_branch_name"])
assert.Equal(t, "", payload["old_branch_name"]) assert.Empty(t, payload["old_branch_name"])
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"name":"feature","commit":{"id":"abc"}}`)) _, _ = w.Write([]byte(`{"name":"feature","commit":{"id":"abc"}}`))
case "/api/v1/repos/owner/repo/branch_protections": case "/api/v1/repos/owner/repo/branch_protections":
assert.Equal(t, "main", payload["branch_name"])
assert.Equal(t, "main", payload["rule_name"]) assert.Equal(t, "main", payload["rule_name"])
assert.Equal(t, false, payload["enable_push"]) assert.Equal(t, false, payload["enable_push"])
assert.EqualValues(t, 1, payload["required_approvals"]) assert.EqualValues(t, 1, payload["required_approvals"])
@@ -68,5 +67,42 @@ func TestBranchCreateAndProtectPayloads(t *testing.T) {
item, err := service.Create(context.Background(), "owner/repo", "feature", "") item, err := service.Create(context.Background(), "owner/repo", "feature", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, Branch{Name: "feature", SHA: "abc"}, item) require.Equal(t, Branch{Name: "feature", SHA: "abc"}, item)
require.NoError(t, service.Protect(context.Background(), "owner/repo", "main")) created, err := service.Protect(context.Background(), "owner/repo", Protection{Rule: "main", RequiredApprovals: 1})
require.NoError(t, err)
require.True(t, created)
}
func TestBranchCreateFromATagOrCommitSendsOldRefName(t *testing.T) {
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost+" /api/v1/repos/owner/repo/branches", r.Method+" "+r.URL.Path)
assert.Equal(t, map[string]any{"new_branch_name": "hotfix", "old_ref_name": "v1.0.0"}, requestJSON(t, r))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"name":"hotfix","commit":{"id":"abc"}}`))
}))
item, err := (Branches{Client: client}).Create(context.Background(), "owner/repo", "hotfix", "v1.0.0")
require.NoError(t, err)
require.Equal(t, Branch{Name: "hotfix", SHA: "abc"}, item)
}
func TestBranchProtectionIsIdempotent(t *testing.T) {
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /api/v1/repos/owner/repo/branch_protections":
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"Branch protection already exist"}`))
case "DELETE /api/v1/repos/owner/repo/branch_protections/main":
w.WriteHeader(http.StatusNotFound)
case "GET /api/v1/repos/owner/repo":
_, _ = w.Write([]byte(`{"full_name":"owner/repo"}`))
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
service := Branches{Client: client}
created, err := service.Protect(context.Background(), "owner/repo", Protection{Rule: "main"})
require.NoError(t, err)
require.False(t, created)
removed, err := service.Unprotect(context.Background(), "owner/repo", "main")
require.NoError(t, err)
require.False(t, removed)
} }