revert: tea 1.0 rewrite (#1122–#1126) (#1146)

Reverts #1126, #1125, #1124, #1123 and #1122.

`go.mod` keeps the Go 1.26 line with toolchain `go1.26.9` (instead of the original `go1.26.6`) so the security fix from #1131 isn't lost.

reopens #69
reopens #141
reopens #162
reopens #172
reopens #197
reopens #232
reopens #240
reopens #246
reopens #323
reopens #325
reopens #326
reopens #328
reopens #344
reopens #351
reopens #373
reopens #403
reopens #406
reopens #417
reopens #430
reopens #457
reopens #480
reopens #482
reopens #489
reopens #490
reopens #498
reopens #500
reopens #504
reopens #510
reopens #516
reopens #523
reopens #556
reopens #560
reopens #569
reopens #571
reopens #602
reopens #632
reopens #633
reopens #643
reopens #666
reopens #670
reopens #672
reopens #677
reopens #683
reopens #697
reopens #726
reopens #737
reopens #770
reopens #779
reopens #804
reopens #817
reopens #831
reopens #833
reopens #834
reopens #908
reopens #966
reopens #1023
reopens #1029
reopens #1032
reopens #1050
reopens #1052
reopens #1069
reopens #1073
reopens #1087
reopens #1091
reopens #1094
reopens #1110
reopens #1115
reopens #1116
reopens #1118

Reviewed-on: https://gitea.com/gitea/tea/pulls/1146
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
bircni committed 2026-10-18 20:56:33 +00:00
1 parent 33bb12729e
commit c5f674111a
604 files changed
+33711 -35545

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"fmt"
"os"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
"golang.org/x/term"
)
// ShowCommentsMaybeInteractive fetches & prints comments, depending on the --comments flag.
// If that flag is unset, and output is not piped, prompts the user first.
func ShowCommentsMaybeInteractive(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, totalComments int) error {
if ctx.Bool("comments") {
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
c := ctx.Login.Client()
comments, _, err := c.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, opts)
if err != nil {
return err
}
print.Comments(comments)
} else if print.IsInteractive() && !ctx.IsSet("comments") {
// if we're interactive, but --comments hasn't been explicitly set to false
if err := ShowCommentsPaginated(requestCtx, ctx, idx, totalComments); err != nil {
fmt.Printf("error while loading comments: %v\n", err)
}
}
return nil
}
// ShowCommentsPaginated prompts if issue/pr comments should be shown and continues to do so.
func ShowCommentsPaginated(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, totalComments int) error {
c := ctx.Login.Client()
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
prompt := "show comments?"
commentsLoaded := 0
// paginated fetch
// NOTE: as of gitea 1.13, pagination is not provided by this endpoint, but handles
// this function gracefully anyways.
for {
loadComments := true
if err := huh.NewConfirm().
Title(prompt).
Value(&loadComments).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
} else if !loadComments {
break
} else {
if comments, _, err := c.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, opts); err != nil {
return err
} else if len(comments) != 0 {
print.Comments(comments)
commentsLoaded += len(comments)
}
if commentsLoaded >= totalComments {
break
}
opts.ListOptions.Page++
prompt = "load more?"
}
}
return nil
}
// IsStdinPiped checks if stdin is piped
func IsStdinPiped() bool {
return !term.IsTerminal(int(os.Stdin.Fd()))
}
+218
View File
@@ -0,0 +1,218 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"context"
"fmt"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// IsQuitting checks if the user has aborted the interactive prompt
func IsQuitting(err error) bool {
return err == huh.ErrUserAborted
}
// CreateIssue interactively creates an issue
func CreateIssue(ctx context.Context, login *config.Login, owner, repo string) error {
owner, repo, err := promptRepoSlug(owner, repo)
if err != nil {
return err
}
printTitleAndContent("Target repo:", owner+"/"+repo)
var opts gitea.CreateIssueOption
if err := promptIssueProperties(ctx, login, owner, repo, &opts); err != nil {
return err
}
issue, err := task.CreateIssue(ctx, login, owner, repo, opts)
if err != nil {
return err
}
print.IssueDetails(issue, nil)
fmt.Println(issue.HTMLURL)
return nil
}
func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo string, o *gitea.CreateIssueOption) error {
var milestoneName string
var err error
selectableChan := make(chan (issueSelectables), 1)
go fetchIssueSelectables(ctx, login, owner, repo, selectableChan)
// title
if err := huh.NewInput().
Title("Issue title:").
Value(&o.Title).
Validate(huh.ValidateNotEmpty()).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Issue title:", o.Title)
// description
if err := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Issue description(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&o.Body),
),
).WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Issue description(markdown):", o.Body)
// wait until selectables are fetched
selectables := <-selectableChan
if selectables.Err != nil {
return selectables.Err
}
// skip remaining props if we don't have permission to set them
if !selectables.Repo.Permissions.Push {
return nil
}
// assignees
if o.Assignees, err = promptMultiSelect("Assignees:", selectables.Assignees, "[other]"); err != nil {
return err
}
printTitleAndContent("Assignees:", strings.Join(o.Assignees, "\n"))
// milestone
if len(selectables.MilestoneList) != 0 {
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]", ""); err != nil {
return err
}
o.Milestone = selectables.MilestoneMap[milestoneName]
printTitleAndContent("Milestone:", milestoneName)
}
// labels
if len(selectables.LabelList) != 0 {
options := make([]huh.Option[int64], 0, len(selectables.LabelList))
labelsMap := make(map[int64]string, len(selectables.LabelList))
for _, l := range selectables.LabelList {
options = append(options, huh.Option[int64]{Key: l, Value: selectables.LabelMap[l]})
labelsMap[selectables.LabelMap[l]] = l
}
if err := huh.NewMultiSelect[int64]().
Title("Labels:").
Options(options...).
Value(&o.Labels).
Run(); err != nil {
return err
}
var labels []string
for _, labelID := range o.Labels {
labels = append(labels, labelsMap[labelID])
}
printTitleAndContent("Labels:", strings.Join(labels, "\n"))
}
// deadline
if o.Deadline, err = promptDatetime("Due date:"); err != nil {
return err
}
deadlineStr := "No due date"
if o.Deadline != nil && !o.Deadline.IsZero() {
deadlineStr = o.Deadline.Format("2006-01-02")
}
printTitleAndContent("Due date:", deadlineStr)
return nil
}
type issueSelectables struct {
Repo *gitea.Repository
Assignees []string
MilestoneList []string
MilestoneMap map[string]int64
LabelList []string
LabelMap map[string]int64
Err error
}
func fetchIssueSelectables(ctx context.Context, login *config.Login, owner, repo string, done chan issueSelectables) {
// TODO PERF make these calls concurrent
r := issueSelectables{}
c := login.Client()
r.Repo, _, r.Err = c.Repositories.GetRepo(ctx, owner, repo)
if r.Err != nil {
done <- r
return
}
// we can set the following properties only if we have write access to the repo
// so we fastpath this if not.
if !r.Repo.Permissions.Push {
done <- r
return
}
assignees, _, err := c.Repositories.GetAssignees(ctx, owner, repo)
if err != nil {
r.Err = err
done <- r
return
}
r.Assignees = make([]string, len(assignees))
for i, u := range assignees {
r.Assignees[i] = u.UserName
}
milestones, _, err := c.Repositories.ListMilestones(ctx, owner, repo, gitea.ListMilestoneOption{})
if err != nil {
r.Err = err
done <- r
return
}
r.MilestoneMap = make(map[string]int64)
r.MilestoneList = make([]string, len(milestones))
for i, m := range milestones {
r.MilestoneMap[m.Title] = m.ID
r.MilestoneList[i] = m.Title
}
r.LabelMap = make(map[string]int64)
r.LabelList = make([]string, 0)
for page := 1; ; {
labels, resp, err := c.Repositories.ListRepoLabels(ctx, owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
r.Err = err
done <- r
return
}
for _, l := range labels {
r.LabelMap[l.Name] = l.ID
r.LabelList = append(r.LabelList, l.Name)
}
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
done <- r
}
+193
View File
@@ -0,0 +1,193 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"slices"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// EditIssue interactively edits an issue
func EditIssue(requestCtx stdctx.Context, ctx context.TeaContext, index int64) (*task.EditIssueOption, error) {
opts := task.EditIssueOption{}
var err error
ctx.Owner, ctx.Repo, err = promptRepoSlug(ctx.Owner, ctx.Repo)
if err != nil {
return &opts, err
}
printTitleAndContent("Target repo:", ctx.Owner+"/"+ctx.Repo)
c := ctx.Login.Client()
i, _, err := c.Issues.GetIssue(requestCtx, ctx.Owner, ctx.Repo, index)
if err != nil {
return &opts, err
}
opts = task.EditIssueOption{
Index: index,
Title: &i.Title,
Body: &i.Body,
Deadline: i.Deadline,
}
i.Assignees = cleanAssignees(i.Assignees)
if len(i.Assignees) != 0 {
for _, a := range i.Assignees {
opts.SetAssignees = append(opts.SetAssignees, a.UserName)
}
}
if len(i.Labels) != 0 {
for _, l := range i.Labels {
opts.AddLabels = append(opts.AddLabels, l.Name)
}
}
if i.Milestone != nil {
opts.Milestone = &i.Milestone.Title
}
if err := promptIssueEditProperties(requestCtx, &ctx, &opts); err != nil {
return &opts, err
}
return &opts, err
}
func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContext, o *task.EditIssueOption) error {
var milestoneName string
var labelsSelected []string
var err error
selectableChan := make(chan (issueSelectables), 1)
go fetchIssueSelectables(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, selectableChan)
// title
if err := huh.NewInput().
Title("Issue title:").
Value(o.Title).
Validate(huh.ValidateNotEmpty()).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Issue title:", *o.Title)
// description
if err := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Issue description(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(o.Body),
),
).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Issue description(markdown):", *o.Body)
// wait until selectables are fetched
selectables := <-selectableChan
if selectables.Err != nil {
return selectables.Err
}
// skip remaining props if we don't have permission to set them
if !selectables.Repo.Permissions.Push {
return nil
}
currAssignees := o.SetAssignees
newAssignees := selectables.Assignees
for _, c := range currAssignees {
if i := slices.Index(newAssignees, c); i != -1 {
newAssignees = slices.Delete(newAssignees, i, i+1)
}
}
// assignees
if currAssignees, err = promptMultiSelectWithPreselect("Set Assignees:", currAssignees, newAssignees, "[other]"); err != nil {
return err
}
if len(currAssignees) == 0 && len(o.SetAssignees) > 0 {
o.RemoveAssignees = o.SetAssignees
}
o.SetAssignees = currAssignees
printTitleAndContent("Assignees:", strings.Join(o.SetAssignees, "\n"))
// milestone
if len(selectables.MilestoneList) != 0 {
var defaultMS string
if o.Milestone != nil {
defaultMS = *o.Milestone
}
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]", defaultMS); err != nil {
return err
}
o.Milestone = &milestoneName
printTitleAndContent("Milestone:", milestoneName)
}
// labels
if len(selectables.LabelList) != 0 {
copy(labelsSelected, o.AddLabels)
if err := huh.NewMultiSelect[string]().
Title("Labels:").
Options(huh.NewOptions(selectables.LabelList...)...).
Value(&labelsSelected).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Labels:", strings.Join(labelsSelected, "\n"))
// removed labels
for _, l := range o.AddLabels {
if !slices.Contains(labelsSelected, l) {
o.RemoveLabels = append(o.RemoveLabels, l)
}
}
// added labels
o.AddLabels = make([]string, len(labelsSelected))
for i, l := range labelsSelected {
o.AddLabels[i] = l
}
}
// deadline
if o.Deadline, err = promptDatetime("Due date:"); err != nil {
return err
}
deadlineStr := "No due date"
if o.Deadline != nil && !o.Deadline.IsZero() {
deadlineStr = o.Deadline.Format("2006-01-02")
}
printTitleAndContent("Due date:", deadlineStr)
return nil
}
func cleanAssignees(list []*gitea.User) []*gitea.User {
out := make([]*gitea.User, 0, len(list))
for _, a := range list {
if strings.TrimSpace(a.UserName) != "" {
out = append(out, a)
}
}
return out
}
+328
View File
@@ -0,0 +1,328 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"context"
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/auth"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// CreateLogin create an login interactive
func CreateLogin(ctx context.Context) error {
var (
name, token, user, passwd, otp, scopes, sshKey, sshCertPrincipal, sshKeyFingerprint string
insecure, sshAgent, versionCheck, helper bool
)
versionCheck = true
helper = false
giteaURL := "https://gitea.com"
if err := huh.NewInput().
Title("URL of Gitea instance: ").
Value(&giteaURL).
Validate(func(s string) error {
s = strings.TrimSpace(s)
if len(s) == 0 {
return fmt.Errorf("URL is required")
}
_, err := url.Parse(s)
if err != nil {
return fmt.Errorf("invalid URL: %v", err)
}
return nil
}).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("URL of Gitea instance: ", giteaURL)
giteaURL = strings.TrimSuffix(strings.TrimSpace(giteaURL), "/")
name, err := task.GenerateLoginName(giteaURL, "")
if err != nil {
return err
}
validateFunc := func(s string) error {
if err := huh.ValidateNotEmpty()(s); err != nil {
return err
}
logins, err := config.GetLogins()
if err != nil {
return err
}
for _, login := range logins {
if login.Name == name {
return fmt.Errorf("login with name '%s' already exists", name)
}
}
return nil
}
if err := huh.NewInput().
Title("Name of new Login: ").
Value(&name).
Validate(validateFunc).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Name of new Login: ", name)
loginMethod, err := promptSelectV2("Login with: ", []string{"token", "ssh-key/certificate", "oauth"})
if err != nil {
return err
}
printTitleAndContent("Login with: ", loginMethod)
switch loginMethod {
case "oauth":
if err := huh.NewConfirm().
Title("Allow Insecure connections:").
Value(&insecure).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Allow Insecure connections:", strconv.FormatBool(insecure))
return auth.OAuthLoginWithOptions(ctx, name, giteaURL, insecure)
default: // token
var hasToken bool
if err := huh.NewConfirm().
Title("Do you have an access token?").
Value(&hasToken).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Do you have an access token?", strconv.FormatBool(hasToken))
if hasToken {
if err := huh.NewInput().
Title("Token:").
Value(&token).
Validate(huh.ValidateNotEmpty()).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Token:", token)
} else {
if err := huh.NewInput().
Title("Username:").
Value(&user).
Validate(huh.ValidateNotEmpty()).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Username:", user)
if err := huh.NewInput().
Title("Password:").
Value(&passwd).
Validate(huh.ValidateNotEmpty()).
EchoMode(huh.EchoModePassword).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Password:", "********")
var tokenScopes []string
if err := huh.NewMultiSelect[string]().
Title("Token Scopes:").
Options(huh.NewOptions(tokenScopeOpts...)...).
Value(&tokenScopes).
Validate(func(s []string) error {
if len(s) == 0 {
return errors.New("at least one scope is required")
}
return nil
}).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Token Scopes:", strings.Join(tokenScopes, "\n"))
scopes = strings.Join(tokenScopes, ",")
// Ask for OTP last so it's less likely to timeout
if err := huh.NewInput().
Title("OTP (if applicable):").
Value(&otp).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("OTP (if applicable):", otp)
}
case "ssh-key/certificate":
if err := huh.NewInput().
Title("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):").
Value(&sshKey).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):", sshKey)
if sshKey == "" {
pubKeys := task.ListSSHPubkey()
if len(pubKeys) == 0 {
fmt.Println("No SSH keys found in ~/.ssh or ssh-agent")
return nil
}
sshKey, err = promptSelect("Select ssh-key: ", pubKeys, "", "", "")
if err != nil {
return err
}
printTitleAndContent("Selected ssh-key:", sshKey)
sshKey, sshCertPrincipal, sshKeyFingerprint, sshAgent, err = parseSSHPubkeySelection(sshKey)
if err != nil {
return err
}
}
}
var optSettings bool
if err := huh.NewConfirm().
Title("Set Optional settings:").
Value(&optSettings).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Set Optional settings:", strconv.FormatBool(optSettings))
if optSettings {
if err := huh.NewInput().
Title("SSH Key Path (leave empty for auto-discovery):").
Value(&sshKey).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("SSH Key Path (leave empty for auto-discovery):", sshKey)
if err := huh.NewConfirm().
Title("Allow Insecure connections:").
Value(&insecure).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Allow Insecure connections:", strconv.FormatBool(insecure))
if err := huh.NewConfirm().
Title("Add git helper:").
Value(&helper).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Add git helper:", strconv.FormatBool(helper))
if err := huh.NewConfirm().
Title("Check version of Gitea instance:").
Value(&versionCheck).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Check version of Gitea instance:", strconv.FormatBool(versionCheck))
}
return task.CreateLogin(ctx, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent, versionCheck, helper)
}
func parseSSHPubkeySelection(display string) (sshKey, sshCertPrincipal, sshKeyFingerprint string, sshAgent bool, err error) {
if strings.Contains(display, "principals") {
if sshCertPrincipal, err = regexpSubmatch(regexp.MustCompile(`.*?principals: (.*?)[,|\s]`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate principal from %q: %w", display, err)
}
if strings.HasSuffix(display, "(ssh-agent)") {
return "", sshCertPrincipal, "", true, nil
}
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate path from %q: %w", display, err)
}
return strings.TrimSuffix(sshKey, "-cert.pub"), sshCertPrincipal, "", false, nil
}
if sshKeyFingerprint, err = regexpSubmatch(regexp.MustCompile(`(SHA256:.*?)\s`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH key fingerprint from %q: %w", display, err)
}
if strings.HasSuffix(display, "(ssh-agent)") {
return "", "", sshKeyFingerprint, true, nil
}
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH key path from %q: %w", display, err)
}
return strings.TrimSuffix(sshKey, ".pub"), "", sshKeyFingerprint, false, nil
}
func regexpSubmatch(re *regexp.Regexp, s string) (string, error) {
match := re.FindStringSubmatch(s)
if len(match) < 2 {
return "", fmt.Errorf("no match")
}
return match[1], nil
}
var tokenScopeOpts = []string{
string(gitea.AccessTokenScopeAll),
string(gitea.AccessTokenScopeRepo),
string(gitea.AccessTokenScopeRepoStatus),
string(gitea.AccessTokenScopePublicRepo),
string(gitea.AccessTokenScopeAdminOrg),
string(gitea.AccessTokenScopeWriteOrg),
string(gitea.AccessTokenScopeReadOrg),
string(gitea.AccessTokenScopeAdminPublicKey),
string(gitea.AccessTokenScopeWritePublicKey),
string(gitea.AccessTokenScopeReadPublicKey),
string(gitea.AccessTokenScopeAdminRepoHook),
string(gitea.AccessTokenScopeWriteRepoHook),
string(gitea.AccessTokenScopeReadRepoHook),
string(gitea.AccessTokenScopeAdminOrgHook),
string(gitea.AccessTokenScopeAdminUserHook),
string(gitea.AccessTokenScopeNotification),
string(gitea.AccessTokenScopeUser),
string(gitea.AccessTokenScopeReadUser),
string(gitea.AccessTokenScopeUserEmail),
string(gitea.AccessTokenScopeUserFollow),
string(gitea.AccessTokenScopeDeleteRepo),
string(gitea.AccessTokenScopePackage),
string(gitea.AccessTokenScopeWritePackage),
string(gitea.AccessTokenScopeReadPackage),
string(gitea.AccessTokenScopeDeletePackage),
string(gitea.AccessTokenScopeAdminGPGKey),
string(gitea.AccessTokenScopeWriteGPGKey),
string(gitea.AccessTokenScopeReadGPGKey),
string(gitea.AccessTokenScopeAdminApplication),
string(gitea.AccessTokenScopeWriteApplication),
string(gitea.AccessTokenScopeReadApplication),
string(gitea.AccessTokenScopeSudo),
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSSHPubkeySelection(t *testing.T) {
tests := []struct {
name string
display string
wantSSHKey string
wantCertPrincipal string
wantKeyFingerprint string
wantSSHAgent bool
wantErr bool
}{
{
name: "local ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (/home/user/.ssh/id_ed25519.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantKeyFingerprint: "SHA256:abc",
},
{
name: "agent ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (ssh-agent)",
wantKeyFingerprint: "SHA256:abc",
wantSSHAgent: true,
},
{
name: "local certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1,user2 (/home/user/.ssh/id_ed25519-cert.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantCertPrincipal: "user1",
},
{
name: "agent certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1 (ssh-agent)",
wantCertPrincipal: "user1",
wantSSHAgent: true,
},
{
name: "unexpected display",
display: "ssh-ed25519 comment",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sshKey, certPrincipal, keyFingerprint, sshAgent, err := parseSSHPubkeySelection(tt.display)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantSSHKey, sshKey)
assert.Equal(t, tt.wantCertPrincipal, certPrincipal)
assert.Equal(t, tt.wantKeyFingerprint, keyFingerprint)
assert.Equal(t, tt.wantSSHAgent, sshAgent)
})
}
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"fmt"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// CreateMilestone interactively creates a milestone
func CreateMilestone(requestCtx stdctx.Context, login *config.Login, owner, repo string) error {
var title, description, deadline string
// owner, repo
owner, repo, err := promptRepoSlug(owner, repo)
if err != nil {
return err
}
printTitleAndContent("Target repo:", fmt.Sprintf("%s/%s", owner, repo))
if err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Milestone title:").
Validate(huh.ValidateNotEmpty()).
Value(&title),
huh.NewText().
Title("Milestone description(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&description),
huh.NewInput().
Title("Milestone deadline:").
Placeholder("YYYY-MM-DD").
Validate(func(s string) error {
if s == "" {
return nil // no deadline
}
_, err := time.Parse("2006-01-02", s)
return err
}).
Value(&deadline),
),
).WithTheme(theme.GetTheme()).Run(); err != nil {
return err
}
var deadlineTM *time.Time
if deadline != "" {
tm, _ := time.Parse("2006-01-02", deadline)
deadlineTM = &tm
}
return task.CreateMilestone(requestCtx, login,
owner,
repo,
title,
description,
deadlineTM,
gitea.StateOpen)
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"fmt"
"gitea.dev/tea/modules/theme"
"charm.land/lipgloss/v2"
)
// printTitleAndContent prints a title and content with the gitea theme
func printTitleAndContent(title, content string) {
hasDarkBG := theme.HasDarkBackground()
style := lipgloss.NewStyle().
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
Padding(0, 1)
fmt.Print(style.Render(title), content+"\n")
}
+201
View File
@@ -0,0 +1,201 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"fmt"
"slices"
"strings"
"time"
"gitea.dev/tea/modules/theme"
"gitea.dev/tea/modules/utils"
"charm.land/huh/v2"
)
// PromptPassword asks for a password and blocks until input was made.
func PromptPassword(name string) (pass string, err error) {
err = huh.NewInput().
Title(name + " password:").
Validate(huh.ValidateNotEmpty()).EchoMode(huh.EchoModePassword).
Value(&pass).
WithTheme(theme.GetTheme()).
Run()
return
}
// promptRepoSlug interactively prompts for a Gitea repository or returns the current one
func promptRepoSlug(defaultOwner, defaultRepo string) (owner, repo string, err error) {
prompt := "Target repo:"
defaultVal := ""
required := true
if len(defaultOwner) != 0 && len(defaultRepo) != 0 {
defaultVal = fmt.Sprintf("%s/%s", defaultOwner, defaultRepo)
required = false
}
var repoSlug string
owner = defaultOwner
repo = defaultRepo
repoSlug = defaultVal
err = huh.NewInput().
Title(prompt).
Value(&repoSlug).
Validate(func(str string) error {
if !required && len(str) == 0 {
return nil
}
split := strings.Split(str, "/")
if len(split) != 2 || len(split[0]) == 0 || len(split[1]) == 0 {
return fmt.Errorf("must follow the <owner>/<repo> syntax")
}
return nil
}).WithTheme(theme.GetTheme()).Run()
if err == nil && len(repoSlug) != 0 {
repoSlugSplit := strings.Split(repoSlug, "/")
owner = repoSlugSplit[0]
repo = repoSlugSplit[1]
}
return
}
// promptDatetime prompts for a date or datetime string.
// Supports all formats understood by araddon/dateparse.
func promptDatetime(prompt string) (val *time.Time, err error) {
var date string
if err := huh.NewInput().
Title(prompt).
Placeholder("YYYY-MM-DD").
Validate(func(s string) error {
if s == "" {
return nil
}
_, err := time.Parse("2006-01-02", s)
return err
}).
Value(&date).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return nil, err
}
if date == "" {
return nil, nil // no date
}
t, _ := time.Parse("2006-01-02", date)
return &t, nil
}
// promptSelect creates a generic multiselect prompt, with processing of custom values.
func promptMultiSelect(prompt string, options []string, customVal string) ([]string, error) {
opts := huh.NewOptions(makeSelectOpts(options, customVal, "")...)
return runMultiSelect(prompt, opts, customVal)
}
// promptMultiSelectWithPreselect creates a generic multiselect prompt with preselected values and processing of custom values.
func promptMultiSelectWithPreselect(prompt string, selected []string, options []string, customVal string) ([]string, error) {
opts := make([]huh.Option[string], 0, len(selected)+len(options)+1)
for _, name := range selected {
opts = append(opts, huh.NewOption(name, name).Selected(true))
}
opts = append(opts, huh.NewOptions(makeSelectOpts(options, customVal, "")...)...)
return runMultiSelect(prompt, opts, customVal)
}
func runMultiSelect(prompt string, opts []huh.Option[string], customVal string) ([]string, error) {
var selection []string
if err := huh.NewMultiSelect[string]().
Title(prompt).
Options(opts...).
Value(&selection).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return nil, err
}
return promptCustomVal(prompt, customVal, selection)
}
// promptSelectV2 creates a generic select prompt
func promptSelectV2(prompt string, options []string) (string, error) {
if len(options) == 0 {
return "", nil
}
selection := options[0]
if err := huh.NewSelect[string]().
Title(prompt).
Options(huh.NewOptions(options...)...).
Value(&selection).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return "", err
}
return selection, nil
}
// promptSelect creates a generic select prompt, with processing of custom values or none-option.
func promptSelect(prompt string, options []string, customVal, noneVal, defaultVal string) (string, error) {
var selection string
if defaultVal == "" && noneVal != "" {
defaultVal = noneVal
}
if len(options) > 0 && !slices.Contains(options, defaultVal) {
defaultVal = options[0]
}
selection = defaultVal
if err := huh.NewSelect[string]().
Title(prompt).
Options(huh.NewOptions(makeSelectOpts(options, customVal, noneVal)...)...).
Value(&selection).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return "", err
}
if noneVal != "" && selection == noneVal {
return "", nil
}
if customVal != "" {
sel, err := promptCustomVal(prompt, customVal, []string{selection})
if err != nil {
return "", err
}
selection = sel[0]
}
return selection, nil
}
// makeSelectOpts adds cusotmVal & noneVal to opts if set.
func makeSelectOpts(opts []string, customVal, noneVal string) []string {
if customVal != "" {
opts = append(opts, customVal)
}
if noneVal != "" {
opts = append(opts, noneVal)
}
return opts
}
// promptCustomVal checks if customVal is present in selection, and prompts
// for custom input to add to the selection instead.
func promptCustomVal(prompt, customVal string, selection []string) ([]string, error) {
// check for custom value & prompt again with text input
if otherIndex := utils.IndexOf(selection, customVal); otherIndex != -1 {
var customAssignees string
if err := huh.NewInput().
Title(prompt).
Description("comma separated list").
Value(&customAssignees).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return nil, err
}
selection = append(selection[:otherIndex], selection[otherIndex+1:]...)
selection = append(selection, strings.Split(customAssignees, ",")...)
}
return selection, nil
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// CreatePull interactively creates a PR
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext) (err error) {
var (
base, head string
allowMaintainerEdits = true
agit bool
)
// owner, repo
if ctx.Owner, ctx.Repo, err = promptRepoSlug(ctx.Owner, ctx.Repo); err != nil {
return err
}
// base
if base, err = task.GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo); err != nil {
return err
}
var headOwner, headBranch string
validator := huh.ValidateNotEmpty()
if ctx.LocalRepo != nil {
headOwner, headBranch, err = task.GetDefaultPRHead(ctx.LocalRepo)
if err == nil {
validator = func(string) error { return nil }
}
}
if err := huh.NewConfirm().
Title("Do you want to create an agit flow pull request?").
Value(&agit).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
if agit {
var (
topic string
baseRemote string
)
topic = headBranch
head = "HEAD"
baseRemote = "origin"
if err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Target branch:").
Value(&base).
Validate(huh.ValidateNotEmpty()),
huh.NewInput().
Title("Source repo remote:").
Value(&baseRemote),
huh.NewInput().
Title("Topic branch:").
Value(&topic).
Validate(validator),
huh.NewInput().
Title("Head branch:").
Value(&head).
Validate(validator),
),
).Run(); err != nil {
return err
}
opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)}
if err = promptIssueProperties(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil {
return err
}
return task.CreateAgitFlowPull(
requestCtx,
ctx,
baseRemote,
head,
base,
topic,
&opts,
PromptPassword,
)
}
if err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Target branch:").
Value(&base).
Validate(huh.ValidateNotEmpty()),
huh.NewInput().
Title("Source repo owner:").
Value(&headOwner),
huh.NewInput().
Title("Source branch:").
Value(&headBranch).
Validate(validator),
huh.NewConfirm().
Title("Allow maintainers to push to the base branch:").
Value(&allowMaintainerEdits),
),
).Run(); err != nil {
return err
}
head = task.GetHeadSpec(headOwner, headBranch, ctx.Owner)
opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)}
if err = promptIssueProperties(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil {
return err
}
pr, err := task.CreatePull(
requestCtx,
ctx,
base,
head,
&allowMaintainerEdits,
&opts)
if err != nil {
return err
}
print.PullDetails(pr, nil, nil)
return nil
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"fmt"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/utils"
"charm.land/huh/v2"
)
// MergePull interactively creates a PR
func MergePull(requestCtx stdctx.Context, ctx *context.TeaContext) error {
if ctx.LocalRepo == nil {
return fmt.Errorf("pull request index is required")
}
branch, _, err := ctx.LocalRepo.TeaGetCurrentBranchNameAndSHA()
if err != nil {
return err
}
idx, err := getPullIndex(requestCtx, ctx, branch)
if err != nil {
return err
}
return task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{
Style: gitea.MergeStyle(ctx.String("style")),
Title: ctx.String("title"),
Message: ctx.String("message"),
})
}
// getPullIndex interactively determines the PR index
func getPullIndex(requestCtx stdctx.Context, ctx *context.TeaContext, branch string) (int64, error) {
c := ctx.Login.Client()
opts := gitea.ListPullRequestsOptions{
State: gitea.StateOpen,
ListOptions: flags.GetListOptions(ctx.Command),
}
selected := ""
loadMoreOption := "PR not found? Load more PRs..."
// paginated fetch
var prs []*gitea.PullRequest
for {
var err error
prs, _, err = c.PullRequests.ListRepoPullRequests(requestCtx, ctx.Owner, ctx.Repo, opts)
if err != nil {
return 0, err
}
if len(prs) == 0 {
return 0, fmt.Errorf("no open PRs found")
}
opts.ListOptions.Page++
prOptions := make([]string, 0)
// get the PR indexes where head branch is the current branch
for _, pr := range prs {
if pr.Head.Ref == branch {
prOptions = append(prOptions, fmt.Sprintf("#%d: %s", pr.Index, pr.Title))
}
}
// then get the PR indexes where base branch is the current branch
for _, pr := range prs {
// don't add the same PR twice, so `pr.Head.Ref != branch`
if pr.Base.Ref == branch && pr.Head.Ref != branch {
prOptions = append(prOptions, fmt.Sprintf("#%d: %s", pr.Index, pr.Title))
}
}
prOptions = append(prOptions, loadMoreOption)
if err := huh.NewSelect[string]().
Title("Select a PR to merge:").
Options(huh.NewOptions(prOptions...)...).
Value(&selected).
Filtering(true).
Run(); err != nil {
return 0, err
}
if selected != loadMoreOption {
break
}
}
// get the index from the selected option
before, _, _ := strings.Cut(selected, ":")
before = strings.TrimPrefix(before, "#")
idx, err := utils.ArgToIndex(before)
if err != nil {
return 0, err
}
return idx, nil
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"fmt"
"os"
"strconv"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
var reviewStates = map[string]gitea.ReviewStateType{
"approve": gitea.ReviewStateApproved,
"comment": gitea.ReviewStateComment,
"request changes": gitea.ReviewStateRequestChanges,
}
var reviewStateOptions = []string{"comment", "request changes", "approve"}
// ReviewPull interactively reviews a PR
func ReviewPull(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64) error {
var state gitea.ReviewStateType
var comment string
var codeComments []gitea.CreatePullReviewComment
var err error
// codeComments
reviewDiff := true
if err := huh.NewConfirm().
Title("Review / comment the diff?").
Value(&reviewDiff).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Review / comment the diff?", strconv.FormatBool(reviewDiff))
if reviewDiff {
if codeComments, err = DoDiffReview(requestCtx, ctx, idx); err != nil {
fmt.Printf("Error during diff review: %s\n", err)
}
fmt.Printf("Found %d code comments in your review\n", len(codeComments))
}
// state
var stateString string
if err := huh.NewSelect[string]().
Title("Your assessment:").
Options(huh.NewOptions(reviewStateOptions...)...).
Value(&stateString).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Your assessment:", stateString)
state = reviewStates[stateString]
// comment
field := huh.NewText().
Title("Concluding comment(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&comment)
if (state == gitea.ReviewStateComment && len(codeComments) == 0) || state == gitea.ReviewStateRequestChanges {
field = field.Validate(huh.ValidateNotEmpty())
}
if err := huh.NewForm(huh.NewGroup(field)).WithTheme(theme.GetTheme()).Run(); err != nil {
return err
}
printTitleAndContent("Concluding comment(markdown):", comment)
return task.CreatePullReview(requestCtx, ctx, idx, state, comment, codeComments)
}
// DoDiffReview (1) fetches & saves diff in tempfile, (2) starts $VISUAL or $EDITOR to comment on diff,
// (3) parses resulting file into code comments.
// It doesn't really make sense to use survey.Editor() here, as we'd read the file content at least twice.
func DoDiffReview(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64) ([]gitea.CreatePullReviewComment, error) {
tmpFile, err := task.SavePullDiff(requestCtx, ctx, idx)
if err != nil {
return nil, err
}
defer os.Remove(tmpFile)
if err = task.OpenFileInEditor(tmpFile); err != nil {
return nil, err
}
return task.ParseDiffComments(tmpFile)
}