Files
gitea-tea/internal/app/pull_review_test.go
T

330 lines
15 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
// pullJSON answers GET /pulls/N for review tests: pull request 13 "Add login" by author.
func pullJSON(author string) string {
return fmt.Sprintf(`{"number":13,"title":"Add login","user":{"login":%q},"head":{"ref":"feat/login"},"base":{"ref":"main"}}`, author)
}
func TestReviewReplyPostsBodyFromStdin(t *testing.T) {
mutations := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mutations++
require.Equal(t, "token token", r.Header.Get("Authorization"))
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/installation/api/v1/repos/owner/repo/pulls/1/comments/7/replies", r.URL.Path)
var body map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
require.Equal(t, map[string]string{"body": "Reply from stdin"}, body)
_, _ = w.Write([]byte(`{"id":9,"body":"Reply from stdin","user":{"login":"alice"},"pull_request_review_id":3}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL+"/installation")
deps.Streams.In = strings.NewReader("Reply from stdin")
err := Execute(context.Background(), deps, []string{"pr", "review", "reply", "1", "7", "-R", "owner/repo", "--body-file=-", "--json=id,review_id,author"})
require.NoError(t, err)
require.Equal(t, 1, mutations)
require.JSONEq(t, `{"id":9,"review_id":3,"author":"alice"}`, out.String())
require.Empty(t, diagnostics.String())
}
func TestReviewPromptAndFlagsReachIdenticalOptions(t *testing.T) {
var payloads []map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/repo/pulls/1" {
_, _ = w.Write([]byte(`{"number":1,"title":"Add login","user":{"login":"bob"}}`))
return
}
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/owner/repo/pulls/1/reviews", r.URL.Path)
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
payloads = append(payloads, payload)
_, _ = w.Write([]byte(`{"id":7,"state":"REQUEST_CHANGES","user":{"login":"alice"},"html_url":"https://example.test/review/7"}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
prompt := &scriptedPrompter{}
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
args := []string{"pr", "review", "1", "-R", "owner/repo", "--event", "REQUEST_CHANGES"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, []string{"Body (markdown): "}, prompt.texts)
require.Empty(t, prompt.confirmations, "no confirmation right after typing the body")
require.Equal(t, "https://example.test/review/7\n", out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, append(args, "--body", "typed body", "--json=id,author,url")))
require.Len(t, prompt.texts, 1)
require.Equal(t, payloads[0], payloads[1])
require.JSONEq(t, `{"id":7,"author":"alice","url":"https://example.test/review/7"}`, out.String())
require.Empty(t, diagnostics.String())
}
func TestReviewTypeFlags(t *testing.T) {
for _, tc := range []struct {
args []string
event, body string
}{
{[]string{"--approve"}, "APPROVED", ""},
{[]string{"-a", "-b", "LGTM"}, "APPROVED", "LGTM"},
{[]string{"-r", "--body", "Hash it"}, "REQUEST_CHANGES", "Hash it"},
{[]string{"-c", "-b", "Nit"}, "COMMENT", "Nit"},
{[]string{"--pending", "-b", "Draft"}, "PENDING", "Draft"},
{[]string{"--event", "approve"}, "APPROVED", ""},
{[]string{"--event", "request_changes", "-b", "x"}, "REQUEST_CHANGES", "x"},
{[]string{"--event", "Comment", "-b", "x"}, "COMMENT", "x"},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
var payload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/x/pulls/13/reviews" {
_, _ = w.Write([]byte(`[]`))
return
}
if r.Method == http.MethodGet {
require.Equal(t, "/api/v1/repos/alice/x/pulls/13", r.URL.Path)
_, _ = w.Write([]byte(pullJSON("bob")))
return
}
require.Equal(t, "/api/v1/repos/alice/x/pulls/13/reviews", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
_, _ = fmt.Fprintf(w, `{"id":7,"state":%q,"html_url":"http://gitea/alice/x/pulls/13#pullrequestreview-7"}`, payload["event"])
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
deps.Streams.OutTTY = true
require.NoError(t, Execute(context.Background(), deps, append([]string{"pr", "review", "13", "-R", "alice/x"}, tc.args...)))
require.Equal(t, tc.event, payload["event"])
require.Equal(t, tc.body, payload["body"])
require.Equal(t, "http://gitea/alice/x/pulls/13#pullrequestreview-7\n", out.String())
require.Equal(t, "✓ "+reviewVerbs[tc.event]+" pull request alice/x#13 (Add login)\n", diagnostics.String())
})
}
}
func TestReviewCreateIsTheReviewCommand(t *testing.T) {
var event any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/x/pulls/13/reviews" {
_, _ = w.Write([]byte(`[]`))
return
}
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(pullJSON("bob")))
return
}
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
event = payload["event"]
_, _ = w.Write([]byte(`{"id":7,"state":"APPROVED"}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "review", "create", "#13", "-R", "alice/x", "-a"}))
require.Equal(t, "APPROVED", event)
}
func TestReviewUsageErrors(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
{[]string{"pr", "review", "13"}, "choose a review type: --approve, --request-changes, or --comment\nUsage: tea pr review [<number> | <url> | <branch>] [flags]\nRun 'tea pr review --help' for examples."},
{[]string{"pr", "review", "create", "13"}, "Run 'tea pr review create --help' for examples."},
{[]string{"pr", "review", "13", "--body", "x"}, "choose a review type"},
{[]string{"pr", "review", "13", "-a", "-c"}, "choose one review type"},
{[]string{"pr", "review", "13", "--approve", "--event", "comment"}, "choose one review type"},
{[]string{"pr", "review", "13", "--event", "bogus"}, "--event must be"},
{[]string{"pr", "review", "approve", "13"}, `unknown review command "approve"; to approve run: tea pr review 13 --approve (see tea pr review --help)`},
{[]string{"pr", "review", "reject"}, `to request changes run: tea pr review <number> --request-changes`},
{[]string{"pr", "review", "lst", "13"}, `unknown command "lst" for "tea pr review"; did you mean list?`},
{[]string{"pr", "review", "13", "14", "-a"}, "too many arguments"},
{[]string{"pr", "review", "submit", "13"}, "choose a review type"},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
requireUsageBeforeRequests(t, tc.want, tc.args...)
})
}
}
func TestReviewRefusesOwnApprovalBeforeTheEditor(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method, "no review may be submitted")
_, _ = w.Write([]byte(pullJSON("alice")))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
editor := &failingPathEditor{}
deps.Editor = editor
err := Execute(context.Background(), deps, []string{"pr", "review", "13", "-R", "alice/x", "--approve", "--editor"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.EqualError(t, err, "you can't approve or request changes on your own pull request #13; use --comment")
}
func TestReviewNeedsABodyWithoutPrompts(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method, "no review may be submitted")
_, _ = w.Write([]byte(pullJSON("bob")))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"pr", "review", "13", "-R", "alice/x", "--request-changes"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.EqualError(t, err, "a change request needs a body; pass --body, --body-file or --editor")
}
func TestReviewSubmitFindsThePendingReview(t *testing.T) {
for _, tc := range []struct {
name, reviews, want string
}{
{"one", `[{"id":5,"state":"APPROVED","user":{"login":"alice"}},{"id":6,"state":"PENDING","user":{"login":"bob"}},{"id":7,"state":"PENDING","user":{"login":"alice"}}]`, ""},
{"none", `[{"id":6,"state":"PENDING","user":{"login":"bob"}}]`, "no pending review by alice on alice/x#13; to post a new review run: tea pr review 13 --approve"},
{"several", `[{"id":7,"state":"PENDING","user":{"login":"alice"}},{"id":8,"state":"PENDING","user":{"login":"alice"}}]`, "alice has several pending reviews on alice/x#13 (7, 8); pass a review ID"},
} {
t.Run(tc.name, func(t *testing.T) {
submitted := ""
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/repos/alice/x/pulls/13":
_, _ = w.Write([]byte(pullJSON("bob")))
case r.URL.Path == "/api/v1/repos/alice/x/pulls/13/reviews" && r.URL.Query().Get("page") == "1":
_, _ = w.Write([]byte(tc.reviews))
case r.URL.Path == "/api/v1/repos/alice/x/pulls/13/reviews":
_, _ = w.Write([]byte(`[]`))
case r.URL.Path == "/api/v1/repos/alice/x/pulls/13/reviews/7" && r.Method == http.MethodGet:
_, _ = w.Write([]byte(`{"id":7,"state":"PENDING","body":"First pass"}`))
case r.URL.Path == "/api/v1/repos/alice/x/pulls/13/reviews/7":
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
submitted = fmt.Sprint(payload["event"], " ", payload["body"])
_, _ = w.Write([]byte(`{"id":7,"state":"APPROVED"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"pr", "review", "submit", "13", "-R", "alice/x", "--approve"})
if tc.want != "" {
require.EqualError(t, err, tc.want)
return
}
require.NoError(t, err)
require.Equal(t, "APPROVED First pass", submitted)
})
}
}
func TestReviewListTable(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/x/pulls/13/reviews", r.URL.Path)
_, _ = w.Write([]byte(`[{"id":5,"state":"COMMENT","body":"Self note","user":{"login":"alice"},"submitted_at":"2026-10-11T10:00:00Z"},
{"id":6,"state":"APPROVED","dismissed":true,"user":{"login":"bob"},"submitted_at":"2026-10-11T11:00:00Z","comments_count":2}]`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
deps.Streams.OutTTY = true
deps.Now = func() time.Time { return time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC) }
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "review", "list", "13", "-R", "alice/x"}))
require.Equal(t, `Showing 2 of 2 reviews on alice/x#13
ID REVIEWER STATE SUBMITTED COMMENTS BODY
5 alice Commented about 2 hours ago 0 Self note
6 bob Approved (dismissed) about 1 hour ago 2
`, out.String())
}
func TestReviewCommentsFileValidatedBeforeRequests(t *testing.T) {
invalidPosition := "each inline comment requires path, body, and exactly one positive old_position or new_position"
for _, tc := range []struct{ content, want string }{
{`null`, "inline comments must be a JSON array"},
{`[{}]`, invalidPosition},
{`[{"path":"x","body":"b","new_position":1,"old_position":1}]`, invalidPosition},
{`[] []`, "inline comments file must contain exactly one JSON array"},
{`[{"unexpected":true}]`, `unknown field "unexpected"`},
} {
t.Run(tc.content, func(t *testing.T) {
file := filepath.Join(t.TempDir(), "comments.json")
require.NoError(t, os.WriteFile(file, []byte(tc.content), 0o600))
requireUsageBeforeRequests(t, tc.want, "pr", "review", "1", "--comments-file", file, "--comment", "--body", "Supplied")
})
}
}
func TestReviewDismissChecksTheReviewStateFirst(t *testing.T) {
mutations := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
mutations++
}
_, _ = w.Write([]byte(`{"id":2,"state":"COMMENT","user":{"login":"bob"}}`))
}))
defer server.Close()
deps, _, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"pr", "review", "dismiss", "1", "2", "-R", "owner/repo", "--body", "Reason", "--yes"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "only approvals and change requests can be dismissed")
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "review", "undismiss", "1", "2", "-R", "owner/repo"}))
require.Contains(t, diagnostics.String(), "is not dismissed")
require.Zero(t, mutations)
}
func TestReviewDismissOpensNoEditorForAnUndismissableReview(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
_, _ = w.Write([]byte(`{"id":2,"state":"COMMENT","user":{"login":"bob"}}`))
}))
defer server.Close()
deps, _, _ := testDeps(t)
editor, _ := newDraftEditor(t, "Reason")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "editor"})
err := Execute(context.Background(), deps, []string{"pr", "review", "dismiss", "1", "2", "-R", "alice/project", "--editor", "--no-input", "--yes"})
require.ErrorContains(t, err, "only approvals and change requests can be dismissed")
require.Empty(t, editor.command, "the editor must not open")
}
func TestReviewDeleteAndDismissRequireYesWithoutInput(t *testing.T) {
mutations := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
mutations++
}
_, _ = w.Write([]byte(`{"id":2,"state":"APPROVED","user":{"login":"bob"}}`))
}))
defer server.Close()
for _, tc := range []struct{ verb, want string }{
{"delete", "confirmation required to delete review 2 by bob on owner/repo#1; rerun with --yes"},
{"dismiss", "confirmation required to dismiss bob's approval on owner/repo#1; rerun with --yes"},
} {
deps, _, _ := serverDeps(t, server.URL)
args := []string{"pr", "review", tc.verb, "1", "2", "-R", "owner/repo", "--no-input"}
if tc.verb == "dismiss" {
args = append(args, "--body", "Reason")
}
err := Execute(context.Background(), deps, args)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.EqualError(t, err, tc.want)
}
require.Zero(t, mutations)
}