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

+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"strings"
gitea "gitea.dev/sdk"
)
// ResolveAssigneeOpts resolves assignee names to IssueAssigneesOption. Returns nil if names is empty.
func ResolveAssigneeOpts(names []string) *gitea.IssueAssigneesOption {
names = cleanAssignees(names)
if len(names) == 0 {
return nil
}
return &gitea.IssueAssigneesOption{Assignees: names}
}
// ApplyAssigneeChanges adds and removes assignees on an issue or pull request.
func ApplyAssigneeChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueAssigneesOption) error {
if rm != nil {
_, _, err := client.Issues.DeleteIssueAssignees(requestCtx, owner, repo, index, *rm)
if err != nil {
return fmt.Errorf("could not remove assignees: %s", err)
}
}
if add != nil {
_, _, err := client.Issues.AddIssueAssignees(requestCtx, owner, repo, index, *add)
if err != nil {
return fmt.Errorf("could not add assignees: %s", err)
}
}
return nil
}
func cleanAssignees(list []string) []string {
out := make([]string, 0, len(list))
for _, a := range list {
if strings.TrimSpace(a) != "" {
out = append(out, a)
}
}
return out
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
)
// CreateIssue creates an issue in the given repo and returns the created issue
func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) (*gitea.Issue, error) {
// title is required
if len(opts.Title) == 0 {
return nil, fmt.Errorf("title is required")
}
issue, _, err := rlogin.Client().Issues.CreateIssue(requestCtx, repoOwner, repoName, opts)
if err != nil {
return nil, fmt.Errorf("could not create issue: %s", err)
}
return issue, nil
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context"
)
// EditIssueOption wraps around gitea.EditIssueOption which has bad & incosistent semantics.
type EditIssueOption struct {
Index int64
Title *string
Body *string
Ref *string
Milestone *string
Deadline *time.Time
AddLabels []string
RemoveLabels []string
SetAssignees []string
AddAssignees []string
RemoveAssignees []string
AddReviewers []string
RemoveReviewers []string
}
// Normalizes the options into parameters that can be passed to the sdk.
// the returned value will be nil, when no change to this part of the issue is requested.
func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, *gitea.IssueAssigneesOption, *gitea.IssueAssigneesOption, error) {
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.AddLabels)
if err != nil {
return nil, nil, nil, nil, nil, err
}
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.RemoveLabels)
if err != nil {
return nil, nil, nil, nil, nil, err
}
addAssigneeOpts := ResolveAssigneeOpts(o.AddAssignees)
rmAssigneeOpts := ResolveAssigneeOpts(o.RemoveAssignees)
issueOpts := gitea.EditIssueOption{}
var issueOptsDirty bool
if o.Title != nil {
issueOpts.Title = *o.Title
issueOptsDirty = true
}
if o.Body != nil {
issueOpts.Body = o.Body
issueOptsDirty = true
}
if o.Ref != nil {
issueOpts.Ref = o.Ref
issueOptsDirty = true
}
if o.Milestone != nil {
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *o.Milestone)
if err != nil {
return nil, nil, nil, nil, nil, err
}
issueOpts.Milestone = gitea.OptionalInt64(id)
issueOptsDirty = true
}
if o.Deadline != nil {
issueOpts.Deadline = o.Deadline
issueOptsDirty = true
if o.Deadline.IsZero() {
issueOpts.RemoveDeadline = gitea.OptionalBool(true)
}
}
o.SetAssignees = cleanAssignees(o.SetAssignees)
if len(o.SetAssignees) != 0 {
issueOpts.Assignees = o.SetAssignees
issueOptsDirty = true
}
if issueOptsDirty {
return &issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
}
return nil, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
}
// EditIssue edits an issue and returns the updated issue.
func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.Issue, error) {
if client == nil {
client = ctx.Login.Client()
}
issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, err := opts.toSdkOptions(requestCtx, ctx, client)
if err != nil {
return nil, err
}
if err := ApplyLabelChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil {
return nil, err
}
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
return nil, err
}
var issue *gitea.Issue
if issueOpts != nil {
issue, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index, *issueOpts)
if err != nil {
return nil, fmt.Errorf("could not edit issue: %s", err)
}
} else {
issue, _, err = client.Issues.GetIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index)
if err != nil {
return nil, fmt.Errorf("could not get issue: %s", err)
}
}
return issue, nil
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/utils"
)
// ResolveLabelNames returns a list of label IDs for a given list of label names
func ResolveLabelNames(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
page := 1
for {
labels, resp, err := client.Repositories.ListRepoLabels(requestCtx, owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
if _, _, err := client.Organizations.GetOrg(requestCtx, owner); err == nil {
orgLabels, _, err := client.Organizations.ListOrgLabels(requestCtx, owner, gitea.ListOrgLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
labels = append(labels, orgLabels...)
}
for _, l := range labels {
if utils.Contains(labelNames, l.Name) {
labelIDs = append(labelIDs, l.ID)
}
}
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
return labelIDs, nil
}
// ResolveLabelOpts resolves label names to IssueLabelsOption. Returns nil if names is empty.
func ResolveLabelOpts(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, names []string) (*gitea.IssueLabelsOption, error) {
if len(names) == 0 {
return nil, nil
}
ids, err := ResolveLabelNames(requestCtx, client, owner, repo, names)
if err != nil {
return nil, err
}
return &gitea.IssueLabelsOption{Labels: ids}, nil
}
// ApplyLabelChanges adds and removes labels on an issue or pull request.
func ApplyLabelChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueLabelsOption) error {
if rm != nil {
// NOTE: as of 1.17, there is no API to remove multiple labels at once.
for _, id := range rm.Labels {
_, err := client.Issues.DeleteIssueLabel(requestCtx, owner, repo, index, id)
if err != nil {
return fmt.Errorf("could not remove labels: %s", err)
}
}
}
if add != nil {
_, _, err := client.Issues.AddIssueLabels(requestCtx, owner, repo, index, *add)
if err != nil {
return fmt.Errorf("could not add labels: %s", err)
}
}
return nil
}
// ApplyReviewerChanges adds and removes reviewers on a pull request.
func ApplyReviewerChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm []string) error {
if len(rm) != 0 {
_, err := client.PullRequests.DeleteReviewRequests(requestCtx, owner, repo, index, gitea.PullReviewRequestOptions{
Reviewers: rm,
})
if err != nil {
return fmt.Errorf("could not remove reviewers: %w", err)
}
}
if len(add) != 0 {
_, err := client.PullRequests.CreateReviewRequests(requestCtx, owner, repo, index, gitea.PullReviewRequestOptions{
Reviewers: add,
})
if err != nil {
return fmt.Errorf("could not add reviewers: %w", err)
}
}
return nil
}
// ResolveMilestoneID resolves a milestone name to its ID. Returns 0 for empty name.
func ResolveMilestoneID(requestCtx stdctx.Context, client *gitea.Client, owner, repo, name string) (int64, error) {
if name == "" {
return 0, nil
}
ms, _, err := client.Repositories.GetMilestoneByName(requestCtx, owner, repo, name)
if err != nil {
return 0, fmt.Errorf("could not resolve milestone '%s': %w", name, err)
}
return ms.ID, nil
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"os"
"gitea.dev/sdk"
)
// LabelsExport save list of labels to disc
func LabelsExport(labels []*gitea.Label, path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
for _, label := range labels {
if _, err := fmt.Fprintf(f, "#%s %s\n", label.Color, label.Name); err != nil {
return err
}
}
return nil
}
+258
View File
@@ -0,0 +1,258 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"os"
"os/exec"
"strings"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/utils"
)
// SetupHelper add tea helper to config global
func SetupHelper(login config.Login) (ok bool, err error) {
// Check that the URL is not blank
if login.URL == "" {
return false, fmt.Errorf("invalid Gitea URL")
}
// get all helper to URL in git config
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
var currentHelpers []byte
if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", helperKey).Output(); err != nil {
currentHelpers = []byte{}
}
// Check if ared added tea helper
for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") {
if strings.HasSuffix(strings.TrimSpace(line), "login helper") {
return false, nil
}
}
// Add tea helper
if _, err = exec.Command("git", "config", "--global", helperKey, "").Output(); err != nil {
return false, fmt.Errorf("git config --global %s, error: %s", helperKey, err)
} else if _, err = exec.Command("git", "config", "--global", "--add", helperKey, "!tea login helper").Output(); err != nil {
return false, fmt.Errorf("git config --global --add %s %s, error: %s", helperKey, "!tea login helper", err)
}
return true, nil
}
// HasGitCredentialHelper reports whether tea is registered as a git credential
// helper for the given login. It mirrors the global git config lookup used by
// SetupHelper.
func HasGitCredentialHelper(login config.Login) bool {
if login.URL == "" {
return false
}
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
currentHelpers, err := exec.Command("git", "config", "--global", "--get-all", helperKey).Output()
if err != nil {
return false
}
for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") {
if strings.HasSuffix(strings.TrimSpace(line), "login helper") {
return true
}
}
return false
}
// CreateLogin create a login to be stored in config
func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error {
// checks ...
// ... if we have a url
if len(giteaURL) == 0 {
return fmt.Errorf("Gitea server URL is required")
}
// ... if there already exist a login with same name
if login, err := config.GetLoginByName(name); err != nil {
return err
} else if login != nil {
return fmt.Errorf("login name '%s' has already been used", login.Name)
}
// ... if we already use this token
if shouldCheckTokenUniqueness(token, sshAgent, sshKey, sshCertPrincipal, sshKeyFingerprint) {
login, err := config.GetLoginByToken(token)
if err != nil {
return err
}
if login != nil {
return fmt.Errorf("token already been used, delete login '%s' first", login.Name)
}
}
serverURL, err := utils.ValidateAuthenticationMethod(
giteaURL,
token,
user,
passwd,
sshAgent,
sshKey,
sshCertPrincipal,
)
if err != nil {
return err
}
// check if it's a certificate the principal doesn't matter as the user
// has explicitly selected this private key
if _, err := os.Stat(sshKey + "-cert.pub"); err == nil {
sshCertPrincipal = "yes"
}
login := config.Login{
Name: name,
URL: serverURL.String(),
Token: token,
Insecure: insecure,
SSHKey: sshKey,
SSHCertPrincipal: sshCertPrincipal,
SSHKeyFingerprint: sshKeyFingerprint,
SSHAgent: sshAgent,
Created: time.Now().Unix(),
VersionCheck: versionCheck,
}
if len(token) == 0 && sshCertPrincipal == "" && !sshAgent && sshKey == "" {
if login.Token, err = generateToken(ctx, login, user, passwd, otp, scopes); err != nil {
return err
}
}
client := login.Client()
// Verify if authentication works and get user info
u, _, err := client.Users.GetMyUserInfo(ctx)
if err != nil {
return err
}
login.User = u.UserName
if len(login.Name) == 0 {
if login.Name, err = GenerateLoginName(giteaURL, login.User); err != nil {
return err
}
}
// we do not have a method to get SSH config from api,
// so we just use the host
login.SSHHost = serverURL.Host
if shouldFindSSHKey(sshKey, sshAgent) {
login.SSHKey, err = findSSHKey(ctx, client)
if err != nil {
fmt.Printf("Warning: problem while finding a SSH key: %s\n", err)
}
}
if err = config.AddLogin(&login); err != nil {
return err
}
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
if addHelper {
if _, err := SetupHelper(login); err != nil {
return err
}
} else {
fmt.Println("Tip: pass --git-credentials (or run 'tea login helper setup') to authenticate 'git push' and 'git clone' over HTTPS with this token.")
}
return nil
}
func shouldFindSSHKey(sshKey string, sshAgent bool) bool {
return sshKey == "" && !sshAgent
}
func shouldCheckTokenUniqueness(token string, sshAgent bool, sshKey, sshCertPrincipal, sshKeyFingerprint string) bool {
if sshAgent || sshKey != "" || sshCertPrincipal != "" || sshKeyFingerprint != "" {
return false
}
return true
}
// generateToken creates a new token when given BasicAuth credentials
func generateToken(ctx stdctx.Context, login config.Login, user, pass, otp, scopes string) (string, error) {
opts := []gitea.ClientOption{gitea.SetBasicAuth(user, pass)}
if otp != "" {
opts = append(opts, gitea.SetOTP(otp))
}
client := login.Client(opts...)
var tl []*gitea.AccessToken
for page := 1; ; {
page_tokens, resp, err := client.Users.ListAccessTokens(ctx, gitea.ListAccessTokensOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return "", err
}
tl = append(tl, page_tokens...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
host, _ := os.Hostname()
tokenName := host + "-tea"
// append timestamp, if a token with this hostname already exists
for i := range tl {
if tl[i].Name == tokenName {
tokenName += time.Now().Format("2006-01-02_15-04-05")
break
}
}
var tokenScopes []gitea.AccessTokenScope
if len(scopes) == 0 {
tokenScopes = []gitea.AccessTokenScope{gitea.AccessTokenScopeAll}
} else {
for _, scope := range strings.Split(scopes, ",") {
tokenScopes = append(tokenScopes, gitea.AccessTokenScope(strings.TrimSpace(scope)))
}
}
t, _, err := client.Users.CreateAccessToken(ctx, gitea.CreateAccessTokenOption{
Name: tokenName,
Scopes: tokenScopes,
})
return t.Token, err
}
// GenerateLoginName generates a name string based on instance URL & adds username if the result is not unique
func GenerateLoginName(url, user string) (string, error) {
parsedURL, err := utils.NormalizeURL(url)
if err != nil {
return "", err
}
name := parsedURL.Host
// append user name if login name already exists
if len(user) != 0 {
if login, err := config.GetLoginByName(name); err != nil {
return "", err
} else if login != nil {
return name + "_" + user, nil
}
}
return name, nil
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import "testing"
func TestShouldCheckTokenUniqueness(t *testing.T) {
tests := []struct {
name string
token string
sshAgent bool
sshKey string
sshCertPrincipal string
sshKeyFingerprint string
wantCheckUniqueness bool
}{
{
name: "token only",
token: "token",
wantCheckUniqueness: true,
},
{
name: "token with ssh agent",
token: "token",
sshAgent: true,
wantCheckUniqueness: false,
},
{
name: "token with ssh key path",
token: "token",
sshKey: "~/.ssh/id_ed25519",
wantCheckUniqueness: false,
},
{
name: "token with ssh cert principal",
token: "token",
sshCertPrincipal: "principal",
wantCheckUniqueness: false,
},
{
name: "token with ssh key fingerprint",
token: "token",
sshKeyFingerprint: "SHA256:example",
wantCheckUniqueness: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldCheckTokenUniqueness(tt.token, tt.sshAgent, tt.sshKey, tt.sshCertPrincipal, tt.sshKeyFingerprint)
if got != tt.wantCheckUniqueness {
t.Fatalf("expected %v, got %v", tt.wantCheckUniqueness, got)
}
})
}
}
func TestShouldFindSSHKey(t *testing.T) {
tests := []struct {
name string
sshKey string
sshAgent bool
want bool
}{
{
name: "explicit key",
sshKey: "~/.ssh/id_ed25519",
want: false,
},
{
name: "ssh agent",
sshAgent: true,
want: false,
},
{
name: "no key configured",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldFindSSHKey(tt.sshKey, tt.sshAgent); got != tt.want {
t.Fatalf("expected %v, got %v", tt.want, got)
}
})
}
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"os"
"path/filepath"
"strings"
"gitea.dev/sdk"
"golang.org/x/crypto/ssh"
"gitea.dev/tea/modules/utils"
)
// ListSSHPubkey lists all the ssh keys in the ssh agent and the ~/.ssh/*.pub files
// It returns a list of SSH keys in the format of:
// "fingerprint keytype comment - principals: principals (ssh-agent or path to pubkey file)"
func ListSSHPubkey() []string {
var keys []string
keys = append(keys, getAgentKeys()...)
keys = append(keys, getLocalKeys()...)
return keys
}
func getAgentKeys() []string {
ag, err := gitea.GetAgent()
if err != nil {
return []string{}
}
akeys, err := ag.List()
if err != nil {
return nil
}
var keys []string
for _, akey := range akeys {
if key := parseKeys([]byte(akey.String()), "ssh-agent"); key != "" {
keys = append(keys, key)
}
}
return keys
}
func getLocalKeys() []string {
var keys []string
// enumerate ~/.ssh/*.pub files
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
if err != nil {
return []string{}
}
localPubkeyPaths, err := filepath.Glob(glob)
if err != nil {
return []string{}
}
// parse each local key with present privkey & compare fingerprints to online keys
for _, pubkeyPath := range localPubkeyPaths {
var pubkeyFile []byte
pubkeyFile, err = os.ReadFile(pubkeyPath)
if err != nil {
continue
}
if key := parseKeys(pubkeyFile, pubkeyPath); key != "" {
keys = append(keys, key)
}
}
return keys
}
func parseKeys(pkinput []byte, sshPath string) string {
pkey, comment, _, _, err := ssh.ParseAuthorizedKey(pkinput)
if err != nil {
return ""
}
if strings.Contains(pkey.Type(), "cert-v01@openssh.com") {
principals := pkey.(*ssh.Certificate).ValidPrincipals
return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment +
" - principals: " + strings.Join(principals, ",") + " (" + sshPath + ")"
}
return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment + " (" + sshPath + ")"
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"encoding/base64"
"os"
"path/filepath"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/utils"
"golang.org/x/crypto/ssh"
)
// findSSHKey retrieves the ssh keys registered in gitea, and tries to find
// a matching private key in ~/.ssh/. If no match is found, path is empty.
func findSSHKey(ctx stdctx.Context, client *gitea.Client) (string, error) {
// get keys registered on gitea instance
var keys []*gitea.PublicKey
for page := 1; ; {
page_keys, resp, err := client.Users.ListMyPublicKeys(ctx, gitea.ListPublicKeysOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return "", err
}
keys = append(keys, page_keys...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
if len(keys) == 0 {
return "", nil
}
// enumerate ~/.ssh/*.pub files
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
if err != nil {
return "", err
}
localPubkeyPaths, err := filepath.Glob(glob)
if err != nil {
return "", err
}
// parse each local key with present privkey & compare fingerprints to online keys
for _, pubkeyPath := range localPubkeyPaths {
var pubkeyFile []byte
pubkeyFile, err = os.ReadFile(pubkeyPath)
if err != nil {
continue
}
fields := strings.Split(string(pubkeyFile), " ")
if len(fields) < 2 { // first word is key type, second word is key material
continue
}
var keymaterial []byte
keymaterial, err = base64.StdEncoding.DecodeString(fields[1])
if err != nil {
continue
}
var pubkey ssh.PublicKey
pubkey, err = ssh.ParsePublicKey(keymaterial)
if err != nil {
continue
}
privkeyPath := strings.TrimSuffix(pubkeyPath, ".pub")
var exists bool
exists, err = utils.FileExist(privkeyPath)
if err != nil || !exists {
continue
}
// if pubkey fingerprints match, return path to corresponding privkey.
fingerprint := ssh.FingerprintSHA256(pubkey)
for _, key := range keys {
if fingerprint == key.Fingerprint {
return privkeyPath, nil
}
}
}
return "", err
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"strings"
"time"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
)
// CheckLoginStatus verifies the stored token for a login against the server and
// returns a printable status. Unlike config.Login.Client, refresh failures are
// captured in the returned status instead of terminating the process.
func CheckLoginStatus(ctx context.Context, login *config.Login) print.LoginStatus {
status := print.LoginStatus{
Name: login.Name,
URL: login.URL,
AuthMethod: loginAuthMethod(login),
TokenExpiry: loginTokenExpiry(login),
Helper: HasGitCredentialHelper(*login),
Default: login.Default,
}
if login.GetAccessToken() == "" {
status.Error = "Login failed: no access token configured"
return status
}
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
status.Error = "Token refresh failed: " + strings.TrimPrefix(err.Error(), "failed to refresh token: ")
return status
}
// A successful refresh updates the token in the secure store, so re-read the
// expiry for the status line.
status.TokenExpiry = loginTokenExpiry(login)
user, _, err := login.ClientWithoutRefresh().Users.GetMyUserInfo(ctx)
if err != nil {
status.Error = fmt.Sprintf("Login failed: %s", err)
return status
}
status.Valid = true
status.User = user.UserName
return status
}
func loginAuthMethod(login *config.Login) string {
if login.IsOAuth() {
return config.AuthMethodOAuth
}
return "token"
}
func loginTokenExpiry(login *config.Login) time.Time {
expiry := login.GetTokenExpiry()
if expiry.Equal(time.Unix(0, 0)) {
return time.Time{}
}
return expiry
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
)
func TestCheckLoginStatus(t *testing.T) {
// Keep helper detection isolated from the developer's real git config.
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), ".gitconfig"))
t.Run("valid token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/user", r.URL.Path)
assert.Equal(t, "token secret-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":1,"login":"alice"}`))
}))
defer server.Close()
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
})
assert.True(t, status.Valid)
assert.Empty(t, status.Error)
assert.Equal(t, "test", status.Name)
assert.Equal(t, server.URL, status.URL)
assert.Equal(t, "alice", status.User)
assert.Equal(t, "token", status.AuthMethod)
assert.False(t, status.Helper)
})
t.Run("invalid token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"token is invalid"}`))
}))
defer server.Close()
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: server.URL,
Token: "expired-token",
VersionCheck: false,
})
assert.False(t, status.Valid)
assert.Contains(t, status.Error, "token is invalid")
})
t.Run("missing token", func(t *testing.T) {
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: "https://gitea.example.com",
})
assert.False(t, status.Valid)
assert.Contains(t, status.Error, "no access token configured")
})
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
)
// CreateMilestone creates a milestone in the given repo and prints the result
func CreateMilestone(ctx stdctx.Context, login *config.Login, repoOwner, repoName, title, description string, deadline *time.Time, state gitea.StateType) error {
// title is required
if len(title) == 0 {
return fmt.Errorf("title is required")
}
mile, _, err := login.Client().Repositories.CreateMilestone(ctx, repoOwner, repoName, gitea.CreateMilestoneOption{
Title: title,
Description: description,
Deadline: deadline,
State: state,
})
if err != nil {
return err
}
print.MilestoneDetails(mile)
return nil
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
local_git "gitea.dev/tea/modules/git"
)
// PullCheckout checkout current workdir to the head branch of specified pull request
func PullCheckout(
ctx stdctx.Context,
login *config.Login,
repoOwner, repoName string,
forceCreateBranch bool,
index int64,
callback func(string) (string, error),
) error {
client := login.Client()
pr, _, err := client.PullRequests.GetPullRequest(ctx, repoOwner, repoName, index)
if err != nil {
return fmt.Errorf("couldn't fetch PR: %s", err)
}
// FIXME: should use ctx.LocalRepo..?
localRepo, err := local_git.RepoForWorkdir()
if err != nil {
return err
}
// find or create a matching remote
remoteURL := remoteURLForPR(login, pr)
newRemoteName := fmt.Sprintf("pulls/%v", pr.Head.Repository.Owner.UserName)
// verify related remote is in local repo, otherwise add it
localRemote, err := localRepo.GetOrCreateRemote(remoteURL, newRemoteName)
if err != nil {
return err
}
localRemoteName := localRemote.Config().Name
localRemoteBranchName, err := doPRFetch(login, pr, localRepo, localRemote, callback)
if err != nil {
return err
}
return doPRCheckout(localRepo, pr, localRemoteName, localRemoteBranchName, remoteURL, forceCreateBranch)
}
func isRemoteDeleted(pr *gitea.PullRequest) bool {
return pr.Head.Ref == fmt.Sprintf("refs/pull/%d/head", pr.Index)
}
func remoteURLForPR(login *config.Login, pr *gitea.PullRequest) string {
repo := pr.Head.Repository
if isRemoteDeleted(pr) {
repo = pr.Base.Repository
}
if login.SSHKeyPath() != "" || login.SSHAgent {
// Use SSH when a key file is configured, or when the login authenticates
// through a running ssh-agent. In both cases we have a working SSH setup.
return repo.SSHURL
}
return repo.CloneURL
}
func doPRFetch(
login *config.Login,
pr *gitea.PullRequest,
localRepo *local_git.TeaRepo,
localRemote *local_git.Remote,
callback func(string) (string, error),
) (string, error) {
_ = callback
localRemoteName := localRemote.Config().Name
localBranchName := pr.Head.Ref
url, err := localRepo.TeaRemoteURL(localRemoteName)
if err != nil {
return "", err
}
auth, err := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKeyPath(), callback)
if err != nil {
return "", err
}
refspecs := []string{}
if isRemoteDeleted(pr) {
// When the head branch is already deleted, pr.Head.Ref points to
// `refs/pull/<idx>/head`, where the commits stay available.
// This ref must be fetched explicitly, and does not allow pushing, so we use it
// only in this case as fallback.
localBranchName = fmt.Sprintf("pulls/%d", pr.Index)
refspecs = []string{fmt.Sprintf("%s:refs/remotes/%s/%s",
pr.Head.Ref,
localRemoteName,
localBranchName,
)}
}
fmt.Printf("Fetching PR %v (head %s:%s) from remote '%s'\n", pr.Index, url, pr.Head.Ref, localRemoteName)
err = localRepo.Fetch(localRemoteName, refspecs, auth)
if err != nil {
return "", err
}
return localBranchName, nil
}
func doPRCheckout(
localRepo *local_git.TeaRepo,
pr *gitea.PullRequest,
localRemoteName,
localRemoteBranchName,
remoteURL string,
forceCreateBranch bool,
) error {
// determine the ref to checkout, depending on existence of a matching commit on a local branch
var info string
var checkoutRef local_git.ReferenceName
if b, _ := localRepo.TeaFindBranchBySha(pr.Head.Sha, remoteURL); b != nil {
// if a matching branch exists, use that
checkoutRef = local_git.NewBranchReferenceName(b.Name)
info = fmt.Sprintf("Found matching local branch %s, checking it out", checkoutRef.Short())
} else if forceCreateBranch {
// create a branch if wanted
localBranchName := fmt.Sprintf("pulls/%v", pr.Index)
if isRemoteDeleted(pr) {
localBranchName += "-" + pr.Head.Ref
}
checkoutRef = local_git.NewBranchReferenceName(localBranchName)
if err := localRepo.TeaCreateBranch(localBranchName, localRemoteBranchName, localRemoteName); err == nil {
info = fmt.Sprintf("Created branch '%s'\n", localBranchName)
} else if err == local_git.ErrBranchExists {
info = "There may be changes since you last checked out, run `git pull` to get them."
} else {
return err
}
} else {
// use the remote tracking branch
checkoutRef = local_git.NewRemoteReferenceName(localRemoteName, localRemoteBranchName)
info = fmt.Sprintf(
"Checking out remote tracking branch %s. To make changes, create a new branch:\n git checkout %s",
checkoutRef.String(), localRemoteBranchName)
}
fmt.Println(info)
return localRepo.TeaCheckout(checkoutRef)
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
local_git "gitea.dev/tea/modules/git"
)
// PullClean deletes local & remote feature-branches for a closed pull
func PullClean(ctx stdctx.Context, login *config.Login, repoOwner, repoName string, index int64, ignoreSHA bool, callback func(string) (string, error)) error {
client := login.Client()
repo, _, err := client.Repositories.GetRepo(ctx, repoOwner, repoName)
if err != nil {
return err
}
defaultBranch := repo.DefaultBranch
if len(defaultBranch) == 0 {
defaultBranch = "master"
}
// fetch PR source-repo & -branch from gitea
pr, _, err := client.PullRequests.GetPullRequest(ctx, repoOwner, repoName, index)
if err != nil {
return err
}
if pr.State == gitea.StateOpen {
return fmt.Errorf("PR is still open, won't delete branches")
}
// if remote head branch is already deleted, pr.Head.Ref points to "pulls/<idx>/head"
remoteBranch := pr.Head.Ref
remoteDeleted := isRemoteDeleted(pr)
if remoteDeleted {
remoteBranch = pr.Head.Name // this still holds the original branch name
fmt.Printf("Remote branch '%s' already deleted.\n", remoteBranch)
}
r, err := local_git.RepoForWorkdir()
if err != nil {
return err
}
// find a branch with matching sha or name, that has a remote matching the repo url
var branch *local_git.Branch
if ignoreSHA {
branch, err = r.TeaFindBranchByName(remoteBranch, pr.Head.Repository.CloneURL)
} else {
branch, err = r.TeaFindBranchBySha(pr.Head.Sha, pr.Head.Repository.CloneURL)
}
if err != nil {
return err
}
if branch == nil {
if ignoreSHA {
return fmt.Errorf("remote branch %s not found in local repo", remoteBranch)
}
return fmt.Errorf(`remote branch %s not found in local repo.
Either you don't track this PR, or the local branch has diverged from the remote.
If you still want to continue & are sure you don't loose any important commits,
call me again with the --ignore-sha flag`, remoteBranch)
}
// prepare deletion of local branch:
headRef, err := r.Head()
if err != nil {
return err
}
if headRef.Name().Short() == branch.Name {
fmt.Printf("Checking out '%s' to delete local branch '%s'\n", defaultBranch, branch.Name)
ref := local_git.NewBranchReferenceName(defaultBranch)
if err = r.TeaCheckout(ref); err != nil {
return err
}
}
// remove local & remote branch
fmt.Printf("Deleting local branch %s\n", branch.Name)
err = r.TeaDeleteLocalBranch(branch)
if err != nil {
return err
}
if !remoteDeleted && pr.Head.Repository.Permissions.Push {
fmt.Printf("Deleting remote branch %s\n", remoteBranch)
url, urlErr := r.TeaRemoteURL(branch.Remote)
if urlErr != nil {
return urlErr
}
auth, authErr := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKeyPath(), callback)
if authErr != nil {
return authErr
}
return r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, auth)
}
return nil
}
+219
View File
@@ -0,0 +1,219 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"regexp"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context"
local_git "gitea.dev/tea/modules/git"
"gitea.dev/tea/modules/utils"
)
var (
spaceRegex = regexp.MustCompile(`[\s_-]+`)
noSpace = regexp.MustCompile(`^[^a-zA-Z\s]*`)
consecutive = regexp.MustCompile(`[\s]{2,}`)
)
// CreatePull creates a PR in the given repo and returns the created PR
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (*gitea.PullRequest, error) {
var err error
// default is default branch
if len(base) == 0 {
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
if err != nil {
return nil, err
}
}
// default is current one
if len(head) == 0 {
if ctx.LocalRepo == nil {
return nil, fmt.Errorf("no local git repo detected, please specify head branch")
}
headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo)
if err != nil {
return nil, err
}
head = GetHeadSpec(headOwner, headBranch, ctx.Owner)
}
// head & base may not be the same
if head == base {
return nil, fmt.Errorf("can't create PR from %s to %s", head, base)
}
// default is head branch name
if len(opts.Title) == 0 {
opts.Title = GetDefaultPRTitle(head)
}
// title is required
if len(opts.Title) == 0 {
return nil, fmt.Errorf("title is required")
}
client := ctx.Login.Client()
pr, _, err := client.PullRequests.CreatePullRequest(requestCtx, ctx.Owner, ctx.Repo, gitea.CreatePullRequestOption{
Head: head,
Base: base,
Title: opts.Title,
Body: opts.Body,
Assignees: opts.Assignees,
Labels: opts.Labels,
Milestone: opts.Milestone,
Deadline: opts.Deadline,
})
if err != nil {
return nil, fmt.Errorf("could not create PR from %s to %s:%s: %s", head, ctx.Owner, base, err)
}
if allowMaintainerEdits != nil && pr.AllowMaintainerEdit != *allowMaintainerEdits {
pr, _, err = client.PullRequests.EditPullRequest(requestCtx, ctx.Owner, ctx.Repo, pr.Index, gitea.EditPullRequestOption{
AllowMaintainerEdit: allowMaintainerEdits,
})
if err != nil {
return nil, fmt.Errorf("could not enable maintainer edit on pull: %v", err)
}
}
return pr, nil
}
// GetDefaultPRBase retrieves the default base branch for the given repo
func GetDefaultPRBase(requestCtx stdctx.Context, login *config.Login, owner, repo string) (string, error) {
meta, _, err := login.Client().Repositories.GetRepo(requestCtx, owner, repo)
if err != nil {
return "", fmt.Errorf("could not fetch repo meta: %s", err)
}
return meta.DefaultBranch, nil
}
// GetDefaultPRHead uses the currently checked out branch, tries to find a remote
// that has a branch with the same name, and extracts the owner from its URL.
// If no remote matches, owner is empty, meaning same as head repo owner.
func GetDefaultPRHead(localRepo *local_git.TeaRepo) (owner, branch string, err error) {
var sha string
if branch, sha, err = localRepo.TeaGetCurrentBranchNameAndSHA(); err != nil {
return
}
remote, err := localRepo.TeaFindBranchRemote(branch, sha)
if err != nil {
err = fmt.Errorf("could not determine remote for current branch: %s", err)
return
}
if remote == nil {
// if no remote branch is found for the local branch,
// we leave owner empty, meaning "use same repo as head" to gitea.
return
}
url, err := local_git.ParseURL(remote.Config().URLs[0])
if err != nil {
return
}
owner, _ = utils.GetOwnerAndRepo(url.Path, "")
return
}
// GetHeadSpec creates a head string as expected by gitea API
func GetHeadSpec(owner, branch, baseOwner string) string {
if len(owner) != 0 && owner != baseOwner {
return fmt.Sprintf("%s:%s", owner, branch)
}
return branch
}
// GetDefaultPRTitle transforms a string like a branchname to a readable text
func GetDefaultPRTitle(header string) string {
// Extract the part after the last colon in the input string
colonIndex := strings.LastIndex(header, ":")
if colonIndex != -1 {
header = header[colonIndex+1:]
}
title := noSpace.ReplaceAllString(header, "")
title = spaceRegex.ReplaceAllString(title, " ")
title = strings.TrimSpace(title)
title = strings.Title(strings.ToLower(title))
title = consecutive.ReplaceAllString(title, " ")
return title
}
// CreateAgitFlowPull creates a agit flow PR in the given repo and prints the result
func CreateAgitFlowPull(requestCtx stdctx.Context, ctx *context.TeaContext, remote, head, base, topic string,
opts *gitea.CreateIssueOption,
callback func(string) (string, error),
) (err error) {
// default is default branch
if len(base) == 0 {
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
if err != nil {
return err
}
}
// default is current one
if len(head) == 0 {
if ctx.LocalRepo == nil {
return fmt.Errorf("no local git repo detected, please specify topic branch")
}
headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo)
if err != nil {
return err
}
head = GetHeadSpec(headOwner, headBranch, ctx.Owner)
}
if len(remote) == 0 {
return fmt.Errorf("remote is required for agit flow PR")
}
if len(topic) == 0 {
topic = head
}
if head == base || topic == base {
return fmt.Errorf("can't create PR from %s to %s", topic, base)
}
// default is head branch name
if len(opts.Title) == 0 {
opts.Title = GetDefaultPRTitle(head)
}
// title is required
if len(opts.Title) == 0 {
return fmt.Errorf("title is required")
}
localRepo, err := local_git.RepoForWorkdir()
if err != nil {
return err
}
url, err := localRepo.RemoteURL(remote)
if err != nil {
return err
}
auth, err := local_git.GetAuthForURL(url, ctx.Login.GetAccessToken(), ctx.Login.SSHKeyPath(), callback)
if err != nil {
return err
}
return localRepo.PushToCreatAgitFlowPR(remote, head, base, topic, opts.Title, opts.Body, auth)
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import "testing"
func TestGetDefaultPRTitle(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: "Add new feature", want: "Add New Feature"},
{input: "update-docs: Fix typo", want: "Fix Typo"},
{input: "remove_long-string", want: "Remove Long String"},
{input: "Replace_Underscores_With_Spaces", want: "Replace Underscores With Spaces"},
{input: " leading-and-trailing-spaces ", want: "Leading And Trailing Spaces"},
{input: "-----No--Upper--Case-----", want: "No Upper Case"},
{input: "", want: ""},
}
for _, test := range tests {
got := GetDefaultPRTitle(test.input)
if got != test.want {
t.Errorf("GetDefaultPRTitle(%q) = %q, want %q", test.input, got, test.want)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context"
)
// EditPull edits a pull request and returns the updated pull request.
func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.PullRequest, error) {
if client == nil {
client = ctx.Login.Client()
}
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, opts.AddLabels)
if err != nil {
return nil, err
}
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, opts.RemoveLabels)
if err != nil {
return nil, err
}
addAssigneeOpts := ResolveAssigneeOpts(opts.AddAssignees)
rmAssigneeOpts := ResolveAssigneeOpts(opts.RemoveAssignees)
prOpts := gitea.EditPullRequestOption{}
var prOptsDirty bool
if opts.Title != nil {
prOpts.Title = *opts.Title
prOptsDirty = true
}
if opts.Body != nil {
prOpts.Body = opts.Body
prOptsDirty = true
}
if opts.Milestone != nil {
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *opts.Milestone)
if err != nil {
return nil, err
}
prOpts.Milestone = id
prOptsDirty = true
}
if opts.Deadline != nil {
prOpts.Deadline = opts.Deadline
prOptsDirty = true
if opts.Deadline.IsZero() {
prOpts.RemoveDeadline = gitea.OptionalBool(true)
}
}
opts.SetAssignees = cleanAssignees(opts.SetAssignees)
if len(opts.SetAssignees) != 0 {
prOpts.Assignees = opts.SetAssignees
prOptsDirty = true
}
if err := ApplyLabelChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil {
return nil, err
}
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
return nil, err
}
if err := ApplyReviewerChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil {
return nil, err
}
var pr *gitea.PullRequest
if prOptsDirty {
pr, _, err = client.PullRequests.EditPullRequest(requestCtx, ctx.Owner, ctx.Repo, opts.Index, prOpts)
if err != nil {
return nil, fmt.Errorf("could not edit pull request: %s", err)
}
} else {
pr, _, err = client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, opts.Index)
if err != nil {
return nil, fmt.Errorf("could not get pull request: %s", err)
}
}
return pr, nil
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
)
// PullMerge merges a PR
func PullMerge(requestCtx stdctx.Context, login *config.Login, repoOwner, repoName string, index int64, opt gitea.MergePullRequestOption) error {
client := login.Client()
success, _, err := client.PullRequests.MergePullRequest(requestCtx, repoOwner, repoName, index, opt)
if err != nil {
return err
}
if success {
return nil
}
return fmt.Errorf("failed to merge PR #%d: %s", index,
mergeFailureReason(requestCtx, client, repoOwner, repoName, index))
}
// mergeFailureReason returns why merging was refused. The SDK reports refusal as
// success=false and discards Gitea's explanatory body, so the reason has to be
// re-derived from the PR. Costs one API call, on the failure path only.
func mergeFailureReason(requestCtx stdctx.Context, client *gitea.Client, repoOwner, repoName string, index int64) string {
// Fallback naming the conditions tea cannot observe, used when the PR looks
// mergeable but the merge was refused anyway.
const refused = "the server refused the merge; check required status checks, requested reviews, or branch protection rules"
pr, _, err := client.PullRequests.GetPullRequest(requestCtx, repoOwner, repoName, index)
if err != nil || pr == nil {
return refused
}
switch {
case pr.HasMerged:
return "it has already been merged"
case pr.State == gitea.StateClosed:
return "it is closed"
case pr.Draft:
return "it is a draft; mark it ready for review first"
case !pr.Mergeable:
return "it has conflicting files or is otherwise not mergeable"
default:
return refused
}
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mergeTestServer answers the merge POST with mergeStatus and the PR GET with
// prJSON, or a 404 if prJSON is empty.
func mergeTestServer(t *testing.T, prJSON string, mergeStatus int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/merge"):
w.WriteHeader(mergeStatus)
// Gitea explains itself here; the SDK discards it.
_, _ = w.Write([]byte(`{"message":"Please try again later"}`))
case r.Method == http.MethodGet:
if prJSON == "" {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"pull request does not exist"}`))
return
}
_, _ = w.Write([]byte(prJSON))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
}))
}
func pullJSON(state string, merged, draft, mergeable bool) string {
return fmt.Sprintf(
`{"number":3,"state":%q,"merged":%t,"draft":%t,"mergeable":%t,"head":{"sha":"abc123"}}`,
state, merged, draft, mergeable)
}
func TestPullMerge(t *testing.T) {
tests := []struct {
name string
pr string
mergeStatus int
wantErr string
}{
{
name: "success",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusOK,
},
{
name: "created is also success",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusCreated,
},
{
// gitea/tea#1022: an open PR with conflicts was reported as
// possibly not open.
name: "conflicting files",
pr: pullJSON("open", false, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it has conflicting files or is otherwise not mergeable",
},
{
name: "already merged",
pr: pullJSON("closed", true, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it has already been merged",
},
{
name: "closed",
pr: pullJSON("closed", false, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it is closed",
},
{
name: "draft",
pr: pullJSON("open", false, true, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it is a draft; mark it ready for review first",
},
{
// Open and mergeable, yet refused.
name: "refused while mergeable",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: the server refused the merge; check required status checks, requested reviews, or branch protection rules",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := mergeTestServer(t, tt.pr, tt.mergeStatus)
defer server.Close()
err := PullMerge(t.Context(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
}, "owner", "repo", 3, gitea.MergePullRequestOption{Style: gitea.MergeStyleMerge})
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Equal(t, tt.wantErr, err.Error())
})
}
}
// A refusal must still explain itself when the follow-up PR lookup fails.
func TestPullMergeReasonUnavailable(t *testing.T) {
server := mergeTestServer(t, "", http.StatusMethodNotAllowed)
defer server.Close()
err := PullMerge(t.Context(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
}, "owner", "repo", 3, gitea.MergePullRequestOption{
Style: gitea.MergeStyleMerge,
// Set so the SDK skips its own pre-merge PR lookup.
HeadCommitId: "abc123",
})
require.Error(t, err)
assert.Equal(t, "failed to merge PR #3: the server refused the merge; check required status checks, requested reviews, or branch protection rules", err.Error())
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"os"
"os/exec"
"strings"
gitea "gitea.dev/sdk"
unidiff "gitea.com/noerw/unidiff-comments"
"gitea.dev/tea/modules/context"
)
var diffReviewHelp = `# This is the current diff of PR #%d on %s.
# To add code comments, just insert a line inside the diff with your comment,
# prefixed with '# '. For example:
#
# - foo: string,
# - bar: string,
# + foo: int,
# # This is a code comment
# + bar: int,
`
// CreatePullReview submits a review for a PR
func CreatePullReview(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, status gitea.ReviewStateType, comment string, codeComments []gitea.CreatePullReviewComment) error {
c := ctx.Login.Client()
review, _, err := c.PullRequests.CreatePullReview(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.CreatePullReviewOptions{
State: status,
Body: comment,
Comments: codeComments,
})
if err != nil {
return err
}
fmt.Println(review.HTMLURL)
return nil
}
// SavePullDiff fetches the diff of a pull request and stores it as a temporary file.
// The path to the file is returned.
func SavePullDiff(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64) (string, error) {
diff, _, err := ctx.Login.Client().PullRequests.GetPullRequestDiff(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.PullRequestDiffOptions{})
if err != nil {
return "", err
}
writer, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("pull-%d-review-*.diff", idx))
if err != nil {
return "", err
}
defer writer.Close()
// add a help header before the actual diff
if _, err = fmt.Fprintf(writer, diffReviewHelp, idx, ctx.RepoSlug); err != nil {
return "", err
}
if _, err = writer.Write(diff); err != nil {
return "", err
}
return writer.Name(), nil
}
// ParseDiffComments reads a diff, extracts comments from it & returns them in a gitea compatible struct
func ParseDiffComments(diffFile string) ([]gitea.CreatePullReviewComment, error) {
reader, err := os.Open(diffFile)
if err != nil {
return nil, fmt.Errorf("couldn't load diff: %s", err)
}
defer reader.Close()
changeset, err := unidiff.ReadChangeset(reader)
if err != nil {
return nil, fmt.Errorf("couldn't parse patch: %s", err)
}
var comments []gitea.CreatePullReviewComment
for _, file := range changeset.Diffs {
for _, c := range file.LineComments {
comment := gitea.CreatePullReviewComment{
Body: c.Text,
Path: c.Anchor.Path,
}
comment.Path = strings.TrimPrefix(comment.Path, "a/")
comment.Path = strings.TrimPrefix(comment.Path, "b/")
switch c.Anchor.LineType {
case "ADDED":
comment.NewLineNum = c.Anchor.Line
case "REMOVED", "CONTEXT":
comment.OldLineNum = c.Anchor.Line
}
comments = append(comments, comment)
}
}
return comments, nil
}
// OpenFileInEditor opens filename in a text editor, and blocks until the editor terminates.
func OpenFileInEditor(filename string) error {
editor := os.Getenv("VISUAL")
if editor == "" {
editor = os.Getenv("EDITOR")
if editor == "" {
fmt.Println("No $VISUAL or $EDITOR env is set, defaulting to vim")
editor = "vi"
}
}
// Get the full executable path for the editor.
executable, err := exec.LookPath(editor)
if err != nil {
return err
}
cmd := exec.Command(executable, filename)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context"
)
// ListPullReviewComments lists all review comments across all reviews for a PR
func ListPullReviewComments(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) {
c := ctx.Login.Client()
var reviews []*gitea.PullReview
for page := 1; ; {
page_reviews, resp, err := c.PullRequests.ListPullReviews(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
reviews = append(reviews, page_reviews...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
var allComments []*gitea.PullReviewComment
for _, review := range reviews {
comments, _, err := c.PullRequests.ListPullReviewComments(requestCtx, ctx.Owner, ctx.Repo, idx, review.ID)
if err != nil {
return nil, err
}
allComments = append(allComments, comments...)
}
return allComments, nil
}
// ResolvePullReviewComment resolves a review comment
func ResolvePullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext, commentID int64) error {
c := ctx.Login.Client()
_, err := c.PullRequests.ResolvePullReviewComment(requestCtx, ctx.Owner, ctx.Repo, commentID)
if err != nil {
return err
}
fmt.Printf("Comment %d resolved\n", commentID)
return nil
}
// ReplyToPullReviewComment replies to a review comment on a pull request.
func ReplyToPullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext, idx, commentID int64, body string) error {
c := ctx.Login.Client()
comment, _, err := c.PullRequests.CreatePullReviewCommentReply(requestCtx, ctx.Owner, ctx.Repo, idx, commentID, gitea.CreatePullReviewCommentReplyOptions{
Body: body,
})
if err != nil {
return err
}
fmt.Println(comment.HTMLURL)
return nil
}
// UnresolvePullReviewComment unresolves a review comment
func UnresolvePullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext, commentID int64) error {
c := ctx.Login.Client()
_, err := c.PullRequests.UnresolvePullReviewComment(requestCtx, ctx.Owner, ctx.Repo, commentID)
if err != nil {
return err
}
fmt.Printf("Comment %d unresolved\n", commentID)
return nil
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"net/url"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
local_git "gitea.dev/tea/modules/git"
)
// RepoClone creates a local git clone in the given path, and sets up upstream remote
// for fork repos, for good usability with tea.
func RepoClone(
ctx stdctx.Context,
path string,
login *config.Login,
repoOwner, repoName string,
callback func(string) (string, error),
depth int,
) (*local_git.TeaRepo, error) {
repoMeta, _, err := login.Client().Repositories.GetRepo(ctx, repoOwner, repoName)
if err != nil {
return nil, err
}
originURL, err := cloneURL(repoMeta, login)
if err != nil {
return nil, err
}
auth, err := local_git.GetAuthForURL(originURL, login.GetAccessToken(), login.SSHKeyPath(), callback)
if err != nil {
return nil, err
}
// default path behavior as native git
if path == "" {
path = repoName
}
repo, err := local_git.Clone(path, originURL.String(), auth, depth, login.Insecure)
if err != nil {
return nil, err
}
// set up upstream remote for forks
if repoMeta.Fork && repoMeta.Parent != nil {
upstreamURL, err := cloneURL(repoMeta.Parent, login)
if err != nil {
return nil, err
}
upstreamBranch := repoMeta.Parent.DefaultBranch
if err = repo.AddRemote("upstream", upstreamURL.String()); err != nil {
return nil, err
}
if err = repo.SetBranchUpstream(upstreamBranch, "upstream", upstreamBranch); err != nil {
return nil, err
}
}
return repo, nil
}
func cloneURL(repo *gitea.Repository, login *config.Login) (*url.URL, error) {
urlStr := repo.CloneURL
if login.SSHKeyPath() != "" || login.SSHAgent {
urlStr = repo.SSHURL
}
return local_git.ParseURL(urlStr)
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"gitea.dev/sdk"
teaContext "gitea.dev/tea/modules/context"
)
// WikiWriteClient defines the wiki write methods required by task helpers.
type WikiWriteClient interface {
CreatePage(ctx stdctx.Context, owner, repo string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error)
EditPage(ctx stdctx.Context, owner, repo, pageName string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error)
DeletePage(ctx stdctx.Context, owner, repo, pageName string) (*gitea.Response, error)
}
// CreateWikiPage creates a wiki page and returns the created page.
func CreateWikiPage(ctx *teaContext.TeaContext, client WikiWriteClient, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, error) {
if opt.Title == "" {
return nil, fmt.Errorf("title is required")
}
if opt.ContentBase64 == "" {
return nil, fmt.Errorf("content is required")
}
page, _, err := client.CreatePage(stdctx.Background(), ctx.Owner, ctx.Repo, opt)
if err != nil {
return nil, fmt.Errorf("could not create wiki page: %s", err)
}
return page, nil
}
// EditWikiPage edits a wiki page and returns the updated page.
func EditWikiPage(ctx *teaContext.TeaContext, client WikiWriteClient, pageName string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, error) {
if pageName == "" {
return nil, fmt.Errorf("page name is required")
}
if opt.ContentBase64 == "" {
return nil, fmt.Errorf("content is required")
}
page, _, err := client.EditPage(stdctx.Background(), ctx.Owner, ctx.Repo, pageName, opt)
if err != nil {
return nil, fmt.Errorf("could not edit wiki page: %s", err)
}
return page, nil
}
// DeleteWikiPage deletes a wiki page.
func DeleteWikiPage(ctx *teaContext.TeaContext, client WikiWriteClient, pageName string) error {
if pageName == "" {
return fmt.Errorf("page name is required")
}
_, err := client.DeletePage(stdctx.Background(), ctx.Owner, ctx.Repo, pageName)
if err != nil {
return fmt.Errorf("could not delete wiki page: %s", err)
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"testing"
"gitea.dev/sdk"
teaContext "gitea.dev/tea/modules/context"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeWikiWriteClient struct {
owner string
repo string
pageName string
create gitea.CreateWikiPageOptions
edit gitea.CreateWikiPageOptions
created *gitea.WikiPage
edited *gitea.WikiPage
deleted bool
}
func (f *fakeWikiWriteClient) CreatePage(_ stdctx.Context, owner, repo string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error) {
f.owner = owner
f.repo = repo
f.create = opt
return f.created, nil, nil
}
func (f *fakeWikiWriteClient) EditPage(_ stdctx.Context, owner, repo, pageName string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error) {
f.owner = owner
f.repo = repo
f.pageName = pageName
f.edit = opt
return f.edited, nil, nil
}
func (f *fakeWikiWriteClient) DeletePage(_ stdctx.Context, owner, repo, pageName string) (*gitea.Response, error) {
f.owner = owner
f.repo = repo
f.pageName = pageName
f.deleted = true
return nil, nil
}
func newWikiTaskContext() *teaContext.TeaContext {
return &teaContext.TeaContext{Owner: "octo", Repo: "tea"}
}
func TestCreateWikiPageCallsSDK(t *testing.T) {
client := &fakeWikiWriteClient{created: &gitea.WikiPage{Title: "Home"}}
page, err := CreateWikiPage(newWikiTaskContext(), client, gitea.CreateWikiPageOptions{Title: "Home", ContentBase64: "IyBIb21l"})
require.NoError(t, err)
assert.Equal(t, "octo", client.owner)
assert.Equal(t, "tea", client.repo)
assert.Equal(t, "Home", client.create.Title)
assert.Equal(t, "Home", page.Title)
}
func TestEditWikiPageCallsSDK(t *testing.T) {
client := &fakeWikiWriteClient{edited: &gitea.WikiPage{Title: "Home"}}
page, err := EditWikiPage(newWikiTaskContext(), client, "Home", gitea.CreateWikiPageOptions{ContentBase64: "IyBVcGRhdGVk"})
require.NoError(t, err)
assert.Equal(t, "octo", client.owner)
assert.Equal(t, "tea", client.repo)
assert.Equal(t, "Home", client.pageName)
assert.Equal(t, "IyBVcGRhdGVk", client.edit.ContentBase64)
assert.Equal(t, "Home", page.Title)
}
func TestDeleteWikiPageCallsSDK(t *testing.T) {
client := &fakeWikiWriteClient{}
err := DeleteWikiPage(newWikiTaskContext(), client, "Home")
require.NoError(t, err)
assert.Equal(t, "octo", client.owner)
assert.Equal(t, "tea", client.repo)
assert.Equal(t, "Home", client.pageName)
assert.True(t, client.deleted)
}