diff --git a/cmd/pulls.go b/cmd/pulls.go index 678b9dd7..4f732c05 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -78,6 +78,7 @@ var CmdPulls = cli.Command{ &pulls.CmdPullsApprove, &pulls.CmdPullsReject, &pulls.CmdPullsMerge, + &pulls.CmdPullsReply, &pulls.CmdPullsReviewComments, &pulls.CmdPullsResolve, &pulls.CmdPullsUnresolve, diff --git a/cmd/pulls/reply.go b/cmd/pulls/reply.go new file mode 100644 index 00000000..f66dcf86 --- /dev/null +++ b/cmd/pulls/reply.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdPullsReply replies to a review comment on a pull request. +var CmdPullsReply = cli.Command{ + Name: "reply", + Usage: "Reply to a pull request review comment", + Description: "Reply to a pull request review comment", + ArgsUsage: " []", + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + return runPullReviewReply(requestCtx, ctx) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/reply_test.go b/cmd/pulls/reply_test.go new file mode 100644 index 00000000..9dcf72b7 --- /dev/null +++ b/cmd/pulls/reply_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "context" + "testing" + + "gitea.dev/tea/modules/config" + + "github.com/stretchr/testify/assert" +) + +func TestReply(t *testing.T) { + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: "https://gitea.example.com", + Token: "test-token", + User: "testUser", + Default: true, + }}, + }) + t.Cleanup(func() { + config.SetConfigForTesting(config.LocalConfig{}) + }) + + tests := []struct { + name string + args []string + wantErr bool + errContains string + }{ + { + name: "no arguments", + args: []string{}, + wantErr: true, + errContains: "pull request index and comment ID are required", + }, + { + name: "missing comment id", + args: []string{"1"}, + wantErr: true, + errContains: "pull request index and comment ID are required", + }, + { + name: "pull index and comment id", + args: []string{"1", "2"}, + wantErr: true, + errContains: "no reply content provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := CmdPullsReply + args := append([]string{"reply"}, tt.args...) + args = append(args, "--login", "testLogin", "--repo", "user/repo") + err := cmd.Run(context.Background(), args) + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + }) + } +} diff --git a/cmd/pulls/review_helpers.go b/cmd/pulls/review_helpers.go index 8477e0d1..82db65e8 100644 --- a/cmd/pulls/review_helpers.go +++ b/cmd/pulls/review_helpers.go @@ -5,14 +5,21 @@ package pulls import ( stdctx "context" + "errors" "fmt" + "io" "strings" gitea "gitea.dev/sdk" + "gitea.dev/tea/modules/config" "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/theme" "gitea.dev/tea/modules/utils" + + "charm.land/huh/v2" ) // runPullReview handles the common logic for approving/rejecting pull requests @@ -60,3 +67,62 @@ func runResolveComment(requestCtx stdctx.Context, ctx *context.TeaContext, actio return action(requestCtx, ctx, commentID) } + +// runPullReviewReply handles replying to a specific review comment on a pull request. +func runPullReviewReply(requestCtx stdctx.Context, ctx *context.TeaContext) error { + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() < 2 { + return fmt.Errorf("pull request index and comment ID are required") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + commentID, err := utils.ArgToIndex(ctx.Args().Get(1)) + if err != nil { + return err + } + + body, err := getCommentBody(ctx, ctx.Args().Slice()[2:], "Reply(markdown):", "reply") + if err != nil { + return err + } + + return task.ReplyToPullReviewComment(requestCtx, ctx, idx, commentID, body) +} + +func getCommentBody(ctx *context.TeaContext, extraArgs []string, promptTitle, noun string) (string, error) { + body := strings.Join(extraArgs, " ") + if interact.IsStdinPiped() { + bodyStdin, err := io.ReadAll(ctx.Reader) + if err != nil { + return "", err + } + if len(bodyStdin) != 0 { + body = strings.Join([]string{body, string(bodyStdin)}, "\n\n") + } + } else if len(body) == 0 { + if err := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title(promptTitle). + ExternalEditor(config.GetPreferences().Editor). + EditorExtension("md"). + Value(&body), + ), + ).WithTheme(theme.GetTheme()).Run(); err != nil { + return "", err + } + } + + if len(strings.TrimSpace(body)) == 0 { + return "", errors.New("no " + noun + " content provided") + } + + return body, nil +} diff --git a/docs/CLI.md b/docs/CLI.md index b410da0a..1454623f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -507,6 +507,18 @@ Merge a pull request **--title, -t**="": Merge commit title +### reply + +Reply to a pull request review comment + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + ### review-comments, rc List review comments on a pull request diff --git a/go.mod b/go.mod index 93415537..70d96351 100644 --- a/go.mod +++ b/go.mod @@ -56,10 +56,8 @@ require ( github.com/fatih/color v1.19.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.9.0 // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect @@ -72,7 +70,6 @@ require ( github.com/olekukonko/ll v0.1.8 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.8.2 // indirect @@ -83,7 +80,6 @@ require ( golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/tools v0.45.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) retract v1.3.3 // accidental release, tag deleted diff --git a/go.sum b/go.sum index 742067d4..6e086b9d 100644 --- a/go.sum +++ b/go.sum @@ -72,7 +72,6 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= @@ -96,21 +95,14 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -136,15 +128,11 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= @@ -213,9 +201,8 @@ golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/git/cli_backend.go b/modules/git/cli_backend.go index 99989e5a..d458de2c 100644 --- a/modules/git/cli_backend.go +++ b/modules/git/cli_backend.go @@ -164,7 +164,20 @@ func (r *cliRepository) CreateTrackingBranch(localBranchName, remoteBranchName, } func (r *cliRepository) Checkout(ref ReferenceName) error { - _, err := r.git(nil, nil, "checkout", ref.String()) + args := []string{"checkout"} + switch { + case ref.IsBranch(): + // `git checkout refs/heads/` detaches HEAD, while the short branch + // name switches to the local branch as intended. + args = append(args, ref.Short()) + case ref.IsRemote(): + // Be explicit about detached HEAD when checking out a remote-tracking ref. + args = append(args, "--detach", ref.String()) + default: + args = append(args, ref.String()) + } + + _, err := r.git(nil, nil, args...) return err } diff --git a/modules/task/pull_checkout.go b/modules/task/pull_checkout.go index 78b3b2c3..2b4dd89d 100644 --- a/modules/task/pull_checkout.go +++ b/modules/task/pull_checkout.go @@ -77,9 +77,9 @@ func doPRFetch( localRemote *local_git.Remote, callback func(string) (string, error), ) (string, error) { + _ = callback localRemoteName := localRemote.Config().Name localBranchName := pr.Head.Ref - // get auth & fetch remote via its configured protocol url, err := localRepo.TeaRemoteURL(localRemoteName) if err != nil { return "", err diff --git a/modules/task/pull_review_comment.go b/modules/task/pull_review_comment.go index c4565211..4e010b22 100644 --- a/modules/task/pull_review_comment.go +++ b/modules/task/pull_review_comment.go @@ -56,6 +56,21 @@ func ResolvePullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext 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() diff --git a/tests/integration/git_branch_test.go b/tests/integration/git_branch_test.go new file mode 100644 index 00000000..94cb81a1 --- /dev/null +++ b/tests/integration/git_branch_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + teagit "gitea.dev/tea/modules/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTeaCheckoutRemoteReferenceKeepsWorktreeClean(t *testing.T) { + clonePath := setupGitCheckoutTestRepo(t) + t.Chdir(clonePath) + + repo, err := teagit.RepoFromPath(clonePath) + require.NoError(t, err) + + err = repo.TeaCheckout(teagit.NewRemoteReferenceName("origin", "feature/test-branch")) + require.NoError(t, err) + + assert.Empty(t, gitOutput(t, clonePath, "status", "--porcelain")) + assert.Equal(t, "HEAD", gitOutput(t, clonePath, "rev-parse", "--abbrev-ref", "HEAD")) +} + +func TestTeaCreateBranchTracksRemoteBranch(t *testing.T) { + clonePath := setupGitCheckoutTestRepo(t) + t.Chdir(clonePath) + + repo, err := teagit.RepoFromPath(clonePath) + require.NoError(t, err) + + err = repo.TeaCreateBranch("pulls/123", "feature/test-branch", "origin") + require.NoError(t, err) + + err = repo.TeaCheckout(teagit.NewBranchReferenceName("pulls/123")) + require.NoError(t, err) + + assert.Empty(t, gitOutput(t, clonePath, "status", "--porcelain")) + assert.Equal(t, "origin", gitOutput(t, clonePath, "config", "--get", "branch.pulls/123.remote")) + assert.Equal(t, "refs/heads/feature/test-branch", gitOutput(t, clonePath, "config", "--get", "branch.pulls/123.merge")) + assert.Equal(t, "pulls/123", gitOutput(t, clonePath, "rev-parse", "--abbrev-ref", "HEAD")) +} + +func setupGitCheckoutTestRepo(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + remotePath := filepath.Join(tmpDir, "remote.git") + seedPath := filepath.Join(tmpDir, "seed") + clonePath := filepath.Join(tmpDir, "clone") + + runGit(t, tmpDir, "init", "--bare", remotePath) + runGit(t, tmpDir, "init", seedPath) + runGit(t, seedPath, "config", "user.email", "test@example.com") + runGit(t, seedPath, "config", "user.name", "Test User") + + require.NoError(t, os.WriteFile(filepath.Join(seedPath, "README.md"), []byte("# Test Repo\n"), 0o644)) + runGit(t, seedPath, "add", "README.md") + runGit(t, seedPath, "commit", "-m", "Initial commit") + runGit(t, seedPath, "branch", "-M", "main") + runGit(t, seedPath, "remote", "add", "origin", remotePath) + runGit(t, seedPath, "push", "-u", "origin", "main") + + runGit(t, seedPath, "checkout", "-b", "feature/test-branch") + require.NoError(t, os.WriteFile(filepath.Join(seedPath, "feature.txt"), []byte("feature\n"), 0o644)) + runGit(t, seedPath, "add", "feature.txt") + runGit(t, seedPath, "commit", "-m", "Add feature") + runGit(t, seedPath, "push", "-u", "origin", "feature/test-branch") + + runGit(t, tmpDir, "clone", remotePath, clonePath) + return clonePath +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(output))) +} + +func gitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(output))) + return strings.TrimSpace(string(output)) +} diff --git a/tests/integration/pulls_reply_test.go b/tests/integration/pulls_reply_test.go new file mode 100644 index 00000000..523902fa --- /dev/null +++ b/tests/integration/pulls_reply_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "encoding/base64" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "gitea.dev/tea/cmd/pulls" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestPullsReply(t *testing.T) { + login := createIntegrationLogin(t) + client := login.Client() + timestamp := time.Now().UnixNano() + repoName := fmt.Sprintf("tea-pr-reply-%d", timestamp) + featureBranch := fmt.Sprintf("reply-test-%d", timestamp) + replyBody := fmt.Sprintf("Thanks for the review %d", timestamp) + + repo, _, err := client.Repositories.CreateRepo(t.Context(), gitea.CreateRepoOption{ + Name: repoName, + AutoInit: true, + DefaultBranch: "main", + }) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.Repositories.DeleteRepo(t.Context(), login.User, repoName); delErr != nil { + t.Logf("failed to delete integration test repo %q: %v", repoName, delErr) + } + }) + + baseBranch := repo.DefaultBranch + if baseBranch == "" { + baseBranch = "main" + } + + _, _, err = client.Repositories.CreateFile(t.Context(), login.User, repoName, "review.txt", gitea.CreateFileOptions{ + FileOptions: gitea.FileOptions{ + Message: "add review target", + BranchName: baseBranch, + NewBranchName: featureBranch, + }, + Content: base64.StdEncoding.EncodeToString([]byte("line for review\n")), + }) + require.NoError(t, err) + + pr, _, err := client.PullRequests.CreatePullRequest(t.Context(), login.User, repoName, gitea.CreatePullRequestOption{ + Base: baseBranch, + Head: featureBranch, + Title: "Integration test for pr reply", + Body: "Adds a file so we can reply to a review comment.", + }) + require.NoError(t, err) + + review, _, err := client.PullRequests.CreatePullReview(t.Context(), login.User, repoName, pr.Index, gitea.CreatePullReviewOptions{ + State: gitea.ReviewStateComment, + Body: "Please take another look.", + Comments: []gitea.CreatePullReviewComment{{ + Path: "review.txt", + Body: "Could you clarify this line?", + NewLineNum: 1, + }}, + }) + require.NoError(t, err) + + comments, _, err := client.PullRequests.ListPullReviewComments(t.Context(), login.User, repoName, pr.Index, review.ID) + require.NoError(t, err) + require.Len(t, comments, 1) + + pullsCmd := &cli.Command{ + Name: "pulls", + Commands: []*cli.Command{&pulls.CmdPullsReply}, + } + + err = pullsCmd.Run(context.Background(), []string{ + "pulls", + "reply", + strconv.FormatInt(pr.Index, 10), + strconv.FormatInt(comments[0].ID, 10), + replyBody, + "--login", + login.Name, + "--repo", + repo.FullName, + }) + if err != nil && strings.Contains(err.Error(), "unknown API error: 405") { + t.Skip("pull review comment replies are not supported by this integration Gitea instance") + } + require.NoError(t, err) + + require.Eventually(t, func() bool { + reviewComments, _, listErr := client.PullRequests.ListPullReviewComments(t.Context(), login.User, repoName, pr.Index, review.ID) + if listErr != nil { + t.Logf("failed to list review comments: %v", listErr) + return false + } + for _, reviewComment := range reviewComments { + if reviewComment.Body == replyBody && reviewComment.ReviewID == review.ID { + return true + } + } + return false + }, 10*time.Second, 500*time.Millisecond) +}