Add commands for reviews (#315)

add interactive `tea pr review`

it's amazingly simple

vendor gitea.com/noerw/unidiff-comments

add `tea pr lgtm|reject` shorthands

vendor slimmed down diff parser

review diff: default to true

if users want a shortcut, they can use lgtm or reject subcmds

`tea pr approve`: accept optional comment

Co-authored-by: Norwin Roosen <git@nroo.de>
Reviewed-on: https://gitea.com/gitea/tea/pulls/315
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: 6543 <6543@obermui.de>
Co-Authored-By: Norwin <noerw@noreply.gitea.io>
Co-Committed-By: Norwin <noerw@noreply.gitea.io>
This commit is contained in:
Norwin
2020-12-21 23:22:22 +08:00
committed by 6543
parent 43a58bdba1
commit 8bb5c15745
18 changed files with 1121 additions and 0 deletions

View File

@ -0,0 +1,459 @@
package unidiff
import (
"bufio"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"time"
"gitea.com/noerw/unidiff-comments/types"
)
const (
stateStartOfDiff = "stateStartOfDiff"
stateDiffHeader = "stateDiffHeader"
stateHunkHeader = "stateHunkHeader"
stateHunkBody = "stateHunkBody"
stateComment = "stateComment"
stateCommentDelim = "stateCommentDelim"
stateCommentHeader = "stateCommentHeader"
stateDiffComment = "stateDiffComment"
stateDiffCommentDelim = "stateDiffCommentDelim"
stateDiffCommentHeader = "stateDiffCommentHeader"
ignorePrefix = "###"
)
var (
reDiffHeader = regexp.MustCompile(
`^--- |^\+\+\+ `)
reGitDiffHeader = regexp.MustCompile(
`^diff |^index `)
reFromFile = regexp.MustCompile(
`^--- (\S+)(\s+(.*))`)
reToFile = regexp.MustCompile(
`^\+\+\+ (\S+)(\s+(.*))`)
reHunk = regexp.MustCompile(
`^@@ -(\d+)(,(\d+))? \+(\d+)(,(\d+))? @@`)
reSegmentContext = regexp.MustCompile(
`^ `)
reSegmentAdded = regexp.MustCompile(
`^\+`)
reSegmentRemoved = regexp.MustCompile(
`^-`)
reCommentDelim = regexp.MustCompile(
`^#\s+---`)
reCommentHeader = regexp.MustCompile(
`^#\s+\[(\d+)@(\d+)\]\s+\|([^|]+)\|(.*)`)
reCommentText = regexp.MustCompile(
`^#(\s*)(.*)\s*`)
reIndent = regexp.MustCompile(
`^#(\s+)`)
reEmptyLine = regexp.MustCompile(
`^\n$`)
reIgnoredLine = regexp.MustCompile(
`^` + ignorePrefix)
)
type parser struct {
state string
changeset types.Changeset
diff *types.Diff
hunk *types.Hunk
segment *types.Segment
comment *types.Comment
line *types.Line
lineNumber int
segmentType string
commentsList []*types.Comment
}
type Error struct {
LineNumber int
Message string
}
func (err Error) Error() string {
return fmt.Sprintf("line %d: %s", err.LineNumber, err.Message)
}
func ReadChangeset(r io.Reader) (types.Changeset, error) {
buffer := bufio.NewReader(r)
current := parser{}
current.state = stateStartOfDiff
for {
current.lineNumber++
line, err := buffer.ReadString('\n')
if err != nil {
break
}
if reIgnoredLine.MatchString(line) {
continue
}
err = current.switchState(line)
if err != nil {
return current.changeset, err
}
err = current.createNodes(line)
if err != nil {
return current.changeset, err
}
err = current.locateNodes(line)
if err != nil {
return current.changeset, err
}
err = current.parseLine(line)
if err != nil {
return current.changeset, err
}
}
for _, comment := range current.commentsList {
comment.Text = strings.TrimSpace(comment.Text)
}
return current.changeset, nil
}
func (current *parser) switchState(line string) error {
inComment := false
switch current.state {
case stateStartOfDiff:
switch {
case reDiffHeader.MatchString(line), reGitDiffHeader.MatchString(line):
current.state = stateDiffHeader
case reCommentText.MatchString(line):
inComment = true
case reEmptyLine.MatchString(line):
// body intentionally left empty
default:
return Error{
current.lineNumber,
"expected diff header, but none found",
}
}
case stateDiffHeader:
switch {
case reHunk.MatchString(line):
current.state = stateHunkHeader
}
case stateDiffComment, stateDiffCommentDelim, stateDiffCommentHeader:
switch {
case reDiffHeader.MatchString(line), reGitDiffHeader.MatchString(line):
current.state = stateDiffHeader
case reCommentText.MatchString(line):
inComment = true
case reEmptyLine.MatchString(line):
current.state = stateStartOfDiff
}
case stateHunkHeader:
current.state = stateHunkBody
fallthrough
case stateHunkBody, stateComment, stateCommentDelim, stateCommentHeader:
switch {
case reSegmentContext.MatchString(line):
current.state = stateHunkBody
current.segmentType = types.SegmentTypeContext
case reSegmentRemoved.MatchString(line):
current.state = stateHunkBody
current.segmentType = types.SegmentTypeRemoved
case reSegmentAdded.MatchString(line):
current.state = stateHunkBody
current.segmentType = types.SegmentTypeAdded
case reHunk.MatchString(line):
current.state = stateHunkHeader
case reCommentText.MatchString(line):
inComment = true
case reGitDiffHeader.MatchString(line):
current.state = stateDiffHeader
current.diff = nil
current.hunk = nil
current.segment = nil
current.line = nil
case reEmptyLine.MatchString(line):
current.state = stateStartOfDiff
current.diff = nil
current.hunk = nil
current.segment = nil
current.line = nil
}
}
if !inComment {
current.comment = nil
} else {
switch current.state {
case stateStartOfDiff:
fallthrough
case stateDiffComment, stateDiffCommentDelim, stateDiffCommentHeader:
switch {
case reCommentDelim.MatchString(line):
current.state = stateDiffCommentDelim
case reCommentHeader.MatchString(line):
current.state = stateDiffCommentHeader
case reCommentText.MatchString(line):
current.state = stateDiffComment
}
case stateHunkBody:
fallthrough
case stateComment, stateCommentDelim, stateCommentHeader:
switch {
case reCommentDelim.MatchString(line):
current.state = stateCommentDelim
case reCommentHeader.MatchString(line):
current.state = stateCommentHeader
case reCommentText.MatchString(line):
current.state = stateComment
}
}
}
// Uncomment for debug state switching
// fmt.Printf("%20s : %#v\n", current.state, line)
return nil
}
func (current *parser) createNodes(line string) error {
switch current.state {
case stateDiffComment:
if current.comment != nil {
break
}
fallthrough
case stateDiffCommentDelim, stateDiffCommentHeader:
current.comment = &types.Comment{}
fallthrough
case stateDiffHeader:
if current.diff == nil {
current.diff = &types.Diff{}
current.changeset.Diffs = append(current.changeset.Diffs,
current.diff)
}
case stateHunkHeader:
current.hunk = &types.Hunk{}
current.segment = &types.Segment{}
case stateCommentDelim, stateCommentHeader:
current.comment = &types.Comment{}
case stateComment:
if current.comment == nil {
current.comment = &types.Comment{}
}
case stateHunkBody:
if current.segment.Type != current.segmentType {
current.segment = &types.Segment{Type: current.segmentType}
current.hunk.Segments = append(current.hunk.Segments,
current.segment)
}
current.line = &types.Line{}
current.segment.Lines = append(current.segment.Lines, current.line)
}
return nil
}
func (current *parser) locateNodes(line string) error {
switch current.state {
case stateComment, stateDiffComment:
current.locateComment(line)
case stateHunkBody:
current.locateLine(line)
}
return nil
}
func (current *parser) locateComment(line string) error {
if current.comment.Parented || strings.TrimSpace(line) == "#" {
return nil
}
current.commentsList = append(current.commentsList, current.comment)
current.comment.Parented = true
if current.hunk != nil {
current.comment.Anchor.LineType = current.segment.Type
current.comment.Anchor.Line = current.segment.GetLineNum(current.line)
current.comment.Anchor.Path = current.diff.Destination.ToString
current.comment.Anchor.SrcPath = current.diff.Source.ToString
}
current.comment.Indent = getIndentSize(line)
parent := current.findParentComment(current.comment)
if parent != nil {
parent.Comments = append(parent.Comments, current.comment)
} else {
if current.line != nil {
current.diff.LineComments = append(current.diff.LineComments,
current.comment)
current.line.Comments = append(current.line.Comments,
current.comment)
} else {
current.diff.FileComments = append(current.diff.FileComments,
current.comment)
}
}
return nil
}
func (current *parser) locateLine(line string) error {
sourceOffset := current.hunk.SourceLine - 1
destinationOffset := current.hunk.DestinationLine - 1
if len(current.hunk.Segments) > 1 {
prevSegment := current.hunk.Segments[len(current.hunk.Segments)-2]
lastLine := prevSegment.Lines[len(prevSegment.Lines)-1]
sourceOffset = lastLine.Source
destinationOffset = lastLine.Destination
}
hunkLength := int64(len(current.segment.Lines))
switch current.segment.Type {
case types.SegmentTypeContext:
current.line.Source = sourceOffset + hunkLength
current.line.Destination = destinationOffset + hunkLength
case types.SegmentTypeAdded:
current.line.Source = sourceOffset
current.line.Destination = destinationOffset + hunkLength
case types.SegmentTypeRemoved:
current.line.Source = sourceOffset + hunkLength
current.line.Destination = destinationOffset
}
return nil
}
func (current *parser) parseLine(line string) error {
switch current.state {
case stateDiffHeader:
current.parseDiffHeader(line)
case stateHunkHeader:
current.parseHunkHeader(line)
case stateHunkBody:
current.parseHunkBody(line)
case stateComment, stateDiffComment:
current.parseComment(line)
case stateCommentHeader, stateDiffCommentHeader:
current.parseCommentHeader(line)
}
return nil
}
func (current *parser) parseDiffHeader(line string) error {
switch {
case reFromFile.MatchString(line):
matches := reFromFile.FindStringSubmatch(line)
current.changeset.Path = matches[1]
current.diff.Source.ToString = matches[1]
current.changeset.FromHash = matches[3]
current.diff.Attributes.FromHash = []string{matches[3]}
case reToFile.MatchString(line):
matches := reToFile.FindStringSubmatch(line)
current.diff.Destination.ToString = matches[1]
current.changeset.ToHash = matches[3]
current.diff.Attributes.ToHash = []string{matches[3]}
default:
return Error{
current.lineNumber,
"expected diff header, but not found",
}
}
return nil
}
func (current *parser) parseHunkHeader(line string) error {
matches := reHunk.FindStringSubmatch(line)
current.hunk.SourceLine, _ = strconv.ParseInt(matches[1], 10, 64)
current.hunk.SourceSpan, _ = strconv.ParseInt(matches[3], 10, 64)
current.hunk.DestinationLine, _ = strconv.ParseInt(matches[4], 10, 64)
current.hunk.DestinationSpan, _ = strconv.ParseInt(matches[6], 10, 64)
current.diff.Hunks = append(current.diff.Hunks, current.hunk)
return nil
}
func (current *parser) parseHunkBody(line string) error {
current.line.Line = line[1 : len(line)-1]
return nil
}
func (current *parser) parseCommentHeader(line string) error {
matches := reCommentHeader.FindStringSubmatch(line)
current.comment.Author.DisplayName = strings.TrimSpace(matches[3])
current.comment.Id, _ = strconv.ParseInt(matches[1], 10, 64)
updatedDate, _ := time.ParseInLocation(time.ANSIC,
strings.TrimSpace(matches[4]),
time.Local)
current.comment.UpdatedDate = types.UnixTimestamp(updatedDate.Unix() * 1000)
version, _ := strconv.ParseInt(matches[2], 10, 64)
current.comment.Version = int(version)
return nil
}
func (current *parser) parseComment(line string) error {
matches := reCommentText.FindStringSubmatch(line)
if len(matches[1]) < current.comment.Indent {
return Error{
LineNumber: current.lineNumber,
Message: fmt.Sprintf(
"unexpected indent, should be at least: %d",
current.comment.Indent,
),
}
}
indentedLine := matches[1][current.comment.Indent:] + matches[2]
current.comment.Text += "\n" + indentedLine
return nil
}
func (current *parser) findParentComment(comment *types.Comment) *types.Comment {
for i := len(current.commentsList) - 1; i >= 0; i-- {
c := current.commentsList[i]
if comment.Indent > c.Indent {
return c
}
}
return nil
}
func getIndentSize(line string) int {
matches := reIndent.FindStringSubmatch(line)
if len(matches) == 0 {
return 0
}
return len(matches[1])
}

5
vendor/gitea.com/noerw/unidiff-comments/go.mod generated vendored Normal file
View File

@ -0,0 +1,5 @@
module gitea.com/noerw/unidiff-comments
go 1.15
require github.com/seletskiy/tplutil v0.0.0-20200921103632-f880f6245597

2
vendor/gitea.com/noerw/unidiff-comments/go.sum generated vendored Normal file
View File

@ -0,0 +1,2 @@
github.com/seletskiy/tplutil v0.0.0-20200921103632-f880f6245597 h1:nZY1S2jo+VtDrUfjO9XYI137O41hhRkxZNV5Fb5ixCA=
github.com/seletskiy/tplutil v0.0.0-20200921103632-f880f6245597/go.mod h1:F8CBHSOjnzjx9EeXyWJTAzJyVxN+Y8JH2WjLMn4utiw=

View File

@ -0,0 +1,47 @@
package types
type Changeset struct {
FromHash string
ToHash string
Path string
Whitespace string
Diffs []*Diff
}
func (r Changeset) ForEachComment(callback func(*Diff, *Comment, *Comment)) {
for _, diff := range r.Diffs {
stack := make([]*Comment, 0)
parents := make(map[*Comment]*Comment)
stack = append(stack, diff.FileComments...)
stack = append(stack, diff.LineComments...)
pos := 0
for pos < len(stack) {
comment := stack[pos]
if comment.Comments != nil {
stack = append(stack, comment.Comments...)
for _, c := range comment.Comments {
parents[c] = comment
}
}
callback(diff, comment, parents[comment])
pos++
}
}
}
func (r Changeset) ForEachLine(
callback func(*Diff, *Hunk, *Segment, *Line) error,
) error {
for _, diff := range r.Diffs {
err := diff.ForEachLine(callback)
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,121 @@
package types
import (
"regexp"
"time"
)
type UnixTimestamp int
func (u UnixTimestamp) String() string {
return time.Unix(int64(u/1000), 0).Format(time.ANSIC)
}
type Comment struct {
Id int64
Version int
Text string
CreatedDate UnixTimestamp
UpdatedDate UnixTimestamp
Comments CommentsTree
Author struct {
Name string
EmailAddress string
Id int
DisplayName string
Active bool
Slug string
Type string
}
Anchor CommentAnchor
PermittedOperations struct {
Editable bool
Deletable bool
}
Indent int
Parented bool
}
type CommentAnchor struct {
FromHash string
ToHash string
Line int64 `json:"line"`
LineType string `json:"lineType"`
Path string `json:"path"`
SrcPath string `json:"srcPath"`
FileType string `json:"fileType"`
}
type CommentsTree []*Comment
//const replyIndent = " "
var begOfLineRe = regexp.MustCompile("(?m)^")
//func (c Comment) String() string {
// comments, _ := commentTpl.Execute(c)
// for _, reply := range c.Comments {
// comments += reply.AsReply()
// }
// return comments
//}
//func (c Comment) AsReply() string {
// return begOfLineRe.ReplaceAllString(
// commentSpacing+c.String(),
// replyIndent,
// )
//}
var reWhiteSpace = regexp.MustCompile(`\s+`)
func (c Comment) Short(length int) string {
sticked := []rune(reWhiteSpace.ReplaceAllString(c.Text, " "))
if len(sticked) > length {
return string(sticked[:length]) + "..."
} else {
return string(sticked)
}
}
const ignorePrefix = "###"
var reBeginningOfLine = regexp.MustCompile(`(?m)^`)
var reIgnorePrefixSpace = regexp.MustCompile("(?m)^" + ignorePrefix + " $")
func Note(String string) string {
return reIgnorePrefixSpace.ReplaceAllString(
reBeginningOfLine.ReplaceAllString(String, ignorePrefix+" "),
ignorePrefix)
}
//const commentSpacing = "\n\n"
//const commentPrefix = "# "
//func (comments CommentsTree) String() string {
// res := ""
// if len(comments) > 0 {
// res = "---" + commentSpacing
// }
// for i, comment := range comments {
// res += comment.String()
// if i < len(comments)-1 {
// res += commentSpacing
// }
// }
// if len(comments) > 0 {
// return danglingSpacesRe.ReplaceAllString(
// begOfLineRe.ReplaceAllString(res, "# "), "")
// } else {
// return ""
// }
//}

61
vendor/gitea.com/noerw/unidiff-comments/types/diff.go generated vendored Normal file
View File

@ -0,0 +1,61 @@
package types
type Diff struct {
Truncated bool
Source struct {
Parent string
Name string
ToString string
}
Destination struct {
Parent string
Name string
ToString string
}
Hunks []*Hunk
FileComments CommentsTree
LineComments CommentsTree
Note string
// Lists made only for Stash API compatibility.
// TODO: move it to `ash`.
Attributes struct {
FromHash []string
ToHash []string
}
}
func (d Diff) GetHashFrom() string {
if len(d.Attributes.FromHash) > 0 {
return d.Attributes.FromHash[0]
} else {
return "???"
}
}
func (d Diff) GetHashTo() string {
if len(d.Attributes.ToHash) > 0 {
return d.Attributes.ToHash[0]
} else {
return "???"
}
}
func (d Diff) ForEachLine(
callback func(*Diff, *Hunk, *Segment, *Line) error,
) error {
for _, hunk := range d.Hunks {
for _, segment := range hunk.Segments {
for _, line := range segment.Lines {
err := callback(&d, hunk, segment, line)
if err != nil {
return err
}
}
}
}
return nil
}

10
vendor/gitea.com/noerw/unidiff-comments/types/hunk.go generated vendored Normal file
View File

@ -0,0 +1,10 @@
package types
type Hunk struct {
SourceLine int64
SourceSpan int64
DestinationLine int64
DestinationSpan int64
Truncated bool
Segments []*Segment
}

29
vendor/gitea.com/noerw/unidiff-comments/types/line.go generated vendored Normal file
View File

@ -0,0 +1,29 @@
package types
import "regexp"
type Line struct {
Destination int64
Source int64
Line string
Truncated bool
ConflictMarker string
CommentIds []int64
Comments CommentsTree
}
var danglingSpacesRe = regexp.MustCompile("(?m) +$")
//var lineTpl = tplutil.SparseTemplate("line", `
//{{.Line}}
//{{if .Comments}}
// {{"\n"}}
// {{.Comments}}
//{{end}}
//`)
//func (l Line) String() string {
// result, _ := lineTpl.Execute(l)
// return result
//}

View File

@ -0,0 +1,39 @@
package types
const (
SegmentTypeContext = "CONTEXT"
SegmentTypeRemoved = "REMOVED"
SegmentTypeAdded = "ADDED"
)
type Segment struct {
Type string
Truncated bool
Lines []*Line
}
func (s Segment) TextPrefix() string {
switch s.Type {
case SegmentTypeAdded:
return "+"
case SegmentTypeRemoved:
return "-"
case SegmentTypeContext:
return " "
default:
return "?"
}
}
func (s Segment) GetLineNum(l *Line) int64 {
switch s.Type {
case SegmentTypeContext:
fallthrough
case SegmentTypeRemoved:
return l.Source
case SegmentTypeAdded:
return l.Destination
}
return 0
}

3
vendor/modules.txt vendored
View File

@ -3,6 +3,9 @@ code.gitea.io/gitea-vet
code.gitea.io/gitea-vet/checks
# code.gitea.io/sdk/gitea v0.13.1-0.20201209180822-68eec69f472e
code.gitea.io/sdk/gitea
# gitea.com/noerw/unidiff-comments v0.0.0-20201219085024-64aec5658f2b
gitea.com/noerw/unidiff-comments
gitea.com/noerw/unidiff-comments/types
# github.com/AlecAivazis/survey/v2 v2.2.2
github.com/AlecAivazis/survey/v2
github.com/AlecAivazis/survey/v2/core